@blamejs/core 0.12.37 → 0.12.38
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/CHANGELOG.md +2 -0
- package/README.md +1 -0
- package/index.js +1 -0
- package/lib/tsa.js +688 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.38 (2026-05-24) — **`b.tsa` — RFC 3161 trusted timestamping client (build / parse / verify).** A timestamp authority binds a hash of your data to a trusted time, producing a token that proves the data existed at that instant — timestamp a release artifact, an audit-log checkpoint, a b.scitt signed statement, or a contract. b.tsa is the requester/verifier side of RFC 3161: buildRequest produces the DER TimeStampReq (the message imprint plus an optional nonce and a cert request), parseResponse reads the TimeStampResp (PKIStatus, failure-info bits, and the token), and verifyToken checks a token against your data and returns the asserted time. Verification is done in full per §2.4.2 / §2.3: the token is a CMS SignedData (b.cms) whose eContentType must be id-ct-TSTInfo; the message imprint must equal the hash of your data (constant-time); a sent nonce must round-trip; the signer certificate's extendedKeyUsage must be a critical, sole id-kp-timeStamping; and the CMS signature over the signed attributes must verify after the messageDigest attribute is matched to the recomputed eContent digest. An optional trust-anchor set verifies the certificate chain and validity at the asserted time. The HTTP transport to the TSA is the operator's to make. Composes b.cms and the in-tree ASN.1 DER codec; no new runtime dependency. **Added:** *`b.tsa.buildRequest(data, opts?)` / `b.tsa.parseResponse(der)` / `b.tsa.verifyToken(token, opts)`* — `buildRequest` returns `{ der, nonce, hashAlg, messageImprint }`; the imprint hash defaults to SHA-512 and may be SHA-256/384/512 or SHA3-256/512, a random 64-bit nonce and a certificate request are included by default, and a pre-hashed input is accepted with `hashed: true`. `parseResponse` returns `{ granted, status, statusString, failInfo, token }`, decoding the PKIFailureInfo bits for a non-granted response rather than throwing. `verifyToken` enforces the imprint match (`opts.data` or `opts.hash`), the nonce round-trip, the critical/sole `id-kp-timeStamping` EKU, and the CMS signature, returning `{ genTime, policy, serialHex, accuracy, hashAlg, signerCertPem }`; pass `opts.trustAnchorsPem` to also verify the certificate chain and validity at the asserted time. Timestamp tokens are third-party artifacts, so verification accepts the classical RSA (PKCS#1 v1.5 and PSS) and ECDSA-over-SHA-2 signatures that public TSAs emit — the same consume-what-exists posture as `b.cose` verification, not a framework signing default.
|
|
12
|
+
|
|
11
13
|
- v0.12.37 (2026-05-24) — **`b.scitt.signStatement` / `b.scitt.verifyStatement` — SCITT signed statements over COSE (RFC 9052 + RFC 9597).** A SCITT signed statement is a signed, attributable claim about an artifact — a signed SBOM, a build attestation, a release approval. It is a COSE_Sign1 (b.cose) whose integrity-protected CWT_Claims header (label 15, RFC 9597) binds the issuer (who makes the statement) and the subject (the artifact it is about); the artifact, or a hash/reference to it, is the payload. signStatement places iss/sub in the protected header and declares the payload media type; verifyStatement checks the COSE signature (the algorithm allowlist is mandatory) and refuses any statement that lacks the iss/sub binding, with optional expected-issuer/subject matching. Signing uses the same algorithms as b.cose — classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward). This is the issuer side of SCITT, buildable today on finalized RFCs; the transparency receipt (an inclusion proof from a transparency service, the COSE Receipts draft) is not yet shipped — a statement produced here is the input a transparency service registers, and the receipt format is the part still in flux. It opts in when COSE Receipts publishes. **Added:** *`b.scitt.signStatement(payload, opts)` / `b.scitt.verifyStatement(statement, opts)`* — `signStatement` produces a COSE_Sign1 whose protected CWT_Claims header (label 15) carries `iss` (`opts.issuer`) and `sub` (`opts.subject`), with the payload media type declared via `opts.contentType` and extra CWT claims allowed by integer label (iss/sub cannot be overridden through `opts.claims`). `verifyStatement` verifies the signature through `b.cose.verify` (passing `opts.algorithms` as the mandatory allowlist), then requires a CWT_Claims header with both `iss` and `sub` — a bare COSE_Sign1 with no such binding is refused with `scitt/missing-cwt-claims` — and enforces `expectedIssuer` / `expectedSubject` when given. Returns `{ payload, issuer, subject, cwtClaims, alg, protectedHeaders, unprotectedHeaders }`. Because the identity binding lives in the integrity-protected header it is covered by the signature and cannot be substituted without detection. **Changed:** *`b.cose.sign` accepts `protectedHeaders` and a media-type-string `contentType`* — `opts.protectedHeaders` (a numeric-keyed object or Map) adds extra integrity-protected header parameters — the CWT_Claims map (label 15) is the SCITT case. Label 1 (alg) is reserved and managed via `opts.alg`; setting it through `protectedHeaders` is refused with `cose/reserved-header`. `opts.contentType` now accepts a media-type string (RFC 9052 §3.1 tstr form, e.g. `"application/spdx+json"`) in addition to a CoAP Content-Format uint; a string was previously dropped.
|
|
12
14
|
|
|
13
15
|
- v0.12.36 (2026-05-24) — **`b.cose.encrypt0` / `b.cose.decrypt0` — COSE_Encrypt0 single-recipient AEAD (RFC 9052 §5.2).** Completes the COSE family with encryption alongside the v0.12.33 signing: COSE_Encrypt0 is the single-recipient AEAD container where the recipient already holds the symmetric key (direct mode). The default algorithm is ChaCha20/Poly1305 (COSE alg 24) — AES-GCM stays opt-in, since hard-rule #2 forbids AES-GCM as a default. The Enc_structure (`["Encrypt0", protected, external_aad]`) is bound as the AEAD associated data so the algorithm + any external context are authenticated, and the authentication tag is appended to the ciphertext per COSE. Composes the in-tree `b.cbor` codec and `node:crypto` AEAD. **Added:** *`b.cose.encrypt0(plaintext, opts)` / `b.cose.decrypt0(coseEncrypt0, opts)`* — `encrypt0` produces a tagged COSE_Encrypt0 with `alg` in the protected header and a random 12-byte IV in the unprotected header (label 5); `alg` is `"ChaCha20-Poly1305"` (default), `"A256GCM"`, or `"A128GCM"`, with the key length enforced (32 / 16 bytes). `decrypt0` reads the algorithm from the protected header (must be in the required `opts.algorithms` allowlist), reconstructs the Enc_structure as the AEAD AAD, and returns `{ plaintext, alg, protectedHeaders, unprotectedHeaders }`; a wrong key, tampered ciphertext, or `external_aad` mismatch fails AEAD authentication and is refused with `cose/decrypt-failed`. `external_aad` binds request context into the tag.
|
package/README.md
CHANGED
|
@@ -130,6 +130,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
130
130
|
- **CBOR Web Token** — `b.cwt` CWT sign/verify (RFC 8392) over `b.cose`: standard-claim mapping (iss/sub/aud/exp/nbf/iat/cti) + `exp`/`nbf` clock-skew enforcement + `iss`/`aud` matching; the CBOR-native JWT for constrained / IoT / FIDO / verifiable-credential contexts
|
|
131
131
|
- **Entity Attestation Token** — `b.eat` EAT sign/verify (RFC 9711) over `b.cwt`: device + software attestation claims (ueid / oemid / hwmodel / measurements / submods) with verifier-nonce freshness binding, `dbgstat` debug-status policy, and `eat_profile` pinning
|
|
132
132
|
- **SCITT signed statements** — `b.scitt` sign/verify a signed, attributable claim about an artifact (signed SBOM, build attestation, release approval) over `b.cose`: the issuer + subject bind in the integrity-protected CWT_Claims header (RFC 9597); verification refuses any statement missing the iss/sub binding. The issuer side, on finalized RFCs; the transparency receipt (COSE Receipts draft) opts in on publication
|
|
133
|
+
- **Trusted timestamping** — `b.tsa` RFC 3161 timestamp client: `buildRequest` a TimeStampReq, `parseResponse`, and `verifyToken` against your data — the message imprint, sent nonce, critical/sole `id-kp-timeStamping` EKU, and CMS signature are all checked, with optional certificate-chain verification. Timestamp a release artifact, audit checkpoint, or signed statement against any RFC 3161 TSA. Composes `b.cms` + the in-tree ASN.1 DER codec
|
|
133
134
|
- **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
|
|
134
135
|
- **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
|
|
135
136
|
### Content-safety gates
|
package/index.js
CHANGED
package/lib/tsa.js
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.tsa
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title Timestamping (RFC 3161)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* An RFC 3161 Time-Stamp Protocol client — the requester / verifier
|
|
9
|
+
* side, not a TSA. A timestamp authority binds a hash of your data to
|
|
10
|
+
* a trusted time, producing a timestamp token that proves the data
|
|
11
|
+
* existed at that instant: timestamp a release artifact, an audit-log
|
|
12
|
+
* checkpoint, a <code>b.scitt</code> signed statement, a contract.
|
|
13
|
+
*
|
|
14
|
+
* <code>b.tsa.buildRequest(data, opts)</code> produces the DER
|
|
15
|
+
* TimeStampReq (a message imprint = the hash of your data, plus an
|
|
16
|
+
* optional nonce and a request for the TSA's certificate);
|
|
17
|
+
* <code>b.tsa.parseResponse(der)</code> reads the TimeStampResp,
|
|
18
|
+
* surfacing the PKIStatus (and any failure-info bits) and the token;
|
|
19
|
+
* <code>b.tsa.verifyToken(token, opts)</code> verifies the token
|
|
20
|
+
* against your data and returns the asserted time. The transport (an
|
|
21
|
+
* HTTP POST of <code>application/timestamp-query</code> to the TSA's
|
|
22
|
+
* URL) is the operator's to make — the framework builds the request
|
|
23
|
+
* and verifies the response.
|
|
24
|
+
*
|
|
25
|
+
* <strong>Verification</strong> (RFC 3161 §2.4.2 / §2.3) is the
|
|
26
|
+
* security-bearing part and is done in full: the token is a CMS
|
|
27
|
+
* SignedData (<code>b.cms</code>) whose eContentType must be
|
|
28
|
+
* <code>id-ct-TSTInfo</code>; the message imprint inside the TSTInfo
|
|
29
|
+
* must equal the hash of your data (constant-time compare); a sent
|
|
30
|
+
* nonce must round-trip; the signer's certificate must carry the
|
|
31
|
+
* <code>id-kp-timeStamping</code> extended key usage, marked critical
|
|
32
|
+
* and as the <em>only</em> EKU; and the CMS signature over the signed
|
|
33
|
+
* attributes must verify (the <code>messageDigest</code> attribute is
|
|
34
|
+
* checked against the recomputed eContent digest first). An optional
|
|
35
|
+
* trust-anchor set verifies the certificate chain and validity at the
|
|
36
|
+
* asserted time.
|
|
37
|
+
*
|
|
38
|
+
* <strong>Algorithms.</strong> Timestamp tokens are third-party
|
|
39
|
+
* artifacts: public TSAs sign with classical RSA (PKCS#1 v1.5 or PSS)
|
|
40
|
+
* or ECDSA over SHA-2, so verification accepts those — the same
|
|
41
|
+
* consume-what-exists stance as <code>b.cose</code> verification. This
|
|
42
|
+
* is not a signing default (the framework is not the TSA); it is
|
|
43
|
+
* verification of externally-produced tokens. The message-imprint
|
|
44
|
+
* hash you request defaults to SHA-512 and may be any of SHA-256 /
|
|
45
|
+
* 384 / 512 or SHA3-256 / 512 the TSA supports.
|
|
46
|
+
*
|
|
47
|
+
* @card
|
|
48
|
+
* RFC 3161 timestamp client — build a TimeStampReq, parse the
|
|
49
|
+
* response, and verify a timestamp token in full (imprint match,
|
|
50
|
+
* nonce, id-kp-timeStamping EKU critical+sole, CMS signature, optional
|
|
51
|
+
* chain). Composes b.cms + the in-tree ASN.1 DER codec.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
var nodeCrypto = require("node:crypto");
|
|
55
|
+
var bCrypto = require("./crypto");
|
|
56
|
+
var asn1 = require("./asn1-der");
|
|
57
|
+
var cms = require("./cms-codec");
|
|
58
|
+
var validateOpts = require("./validate-opts");
|
|
59
|
+
var { defineClass } = require("./framework-error");
|
|
60
|
+
|
|
61
|
+
var TsaError = defineClass("TsaError", { alwaysPermanent: true });
|
|
62
|
+
|
|
63
|
+
// id-ct-TSTInfo (RFC 3161 §2.4.2) — the eContentType of a timestamp token.
|
|
64
|
+
var OID_TST_INFO = "1.2.840.113549.1.9.16.1.4";
|
|
65
|
+
// id-kp-timeStamping (RFC 3161 §2.3) — the required, critical, sole EKU.
|
|
66
|
+
var OID_KP_TIMESTAMPING = "1.3.6.1.5.5.7.3.8";
|
|
67
|
+
var OID_EXT_EKU = "2.5.29.37"; // certificate extendedKeyUsage extension
|
|
68
|
+
var OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4"; // RFC 5652 messageDigest signed attribute
|
|
69
|
+
var OID_CONTENT_TYPE = "1.2.840.113549.1.9.3"; // RFC 5652 contentType signed attribute
|
|
70
|
+
|
|
71
|
+
// Message-imprint hash algorithms: name to { oid, nodeHash }.
|
|
72
|
+
var IMPRINT_HASHES = {
|
|
73
|
+
"SHA-256": { oid: "2.16.840.1.101.3.4.2.1", nodeHash: "sha256" },
|
|
74
|
+
"SHA-384": { oid: "2.16.840.1.101.3.4.2.2", nodeHash: "sha384" },
|
|
75
|
+
"SHA-512": { oid: "2.16.840.1.101.3.4.2.3", nodeHash: "sha512" },
|
|
76
|
+
"SHA3-256": { oid: "2.16.840.1.101.3.4.2.8", nodeHash: "sha3-256" },
|
|
77
|
+
"SHA3-512": { oid: "2.16.840.1.101.3.4.2.10", nodeHash: "sha3-512" },
|
|
78
|
+
};
|
|
79
|
+
var OID_TO_IMPRINT_HASH = {};
|
|
80
|
+
Object.keys(IMPRINT_HASHES).forEach(function (n) { OID_TO_IMPRINT_HASH[IMPRINT_HASHES[n].oid] = n; });
|
|
81
|
+
|
|
82
|
+
// Signer signature algorithms a real TSA emits to node verify parameters.
|
|
83
|
+
// scheme "rsa" = PKCS#1 v1.5, "pss" = RSASSA-PSS, "ecdsa" = ECDSA(DER).
|
|
84
|
+
var SIG_ALGS = {
|
|
85
|
+
// Bare rsaEncryption — RFC 8933 / common CMS producers (incl. OpenSSL
|
|
86
|
+
// ts) put this in SignerInfo.signatureAlgorithm and take the hash from
|
|
87
|
+
// the separate digestAlgorithm field.
|
|
88
|
+
"1.2.840.113549.1.1.1": { hash: null, scheme: "rsa" }, // rsaEncryption (hash from digestAlg)
|
|
89
|
+
"1.2.840.113549.1.1.11": { hash: "sha256", scheme: "rsa" }, // sha256WithRSAEncryption
|
|
90
|
+
"1.2.840.113549.1.1.12": { hash: "sha384", scheme: "rsa" }, // sha384WithRSAEncryption
|
|
91
|
+
"1.2.840.113549.1.1.13": { hash: "sha512", scheme: "rsa" }, // sha512WithRSAEncryption
|
|
92
|
+
"1.2.840.113549.1.1.10": { hash: null, scheme: "pss" }, // RSASSA-PSS (hash from signerInfo digestAlg)
|
|
93
|
+
// Bare id-ecPublicKey appears in some producers' SignerInfo too.
|
|
94
|
+
"1.2.840.10045.2.1": { hash: null, scheme: "ecdsa" }, // id-ecPublicKey (hash from digestAlg)
|
|
95
|
+
"1.2.840.10045.4.3.2": { hash: "sha256", scheme: "ecdsa" }, // ecdsa-with-SHA256
|
|
96
|
+
"1.2.840.10045.4.3.3": { hash: "sha384", scheme: "ecdsa" }, // ecdsa-with-SHA384
|
|
97
|
+
"1.2.840.10045.4.3.4": { hash: "sha512", scheme: "ecdsa" }, // ecdsa-with-SHA512
|
|
98
|
+
};
|
|
99
|
+
var DIGEST_OID_TO_NODE = {
|
|
100
|
+
"2.16.840.1.101.3.4.2.1": "sha256",
|
|
101
|
+
"2.16.840.1.101.3.4.2.2": "sha384",
|
|
102
|
+
"2.16.840.1.101.3.4.2.3": "sha512",
|
|
103
|
+
"2.16.840.1.101.3.4.2.8": "sha3-256",
|
|
104
|
+
"2.16.840.1.101.3.4.2.10": "sha3-512",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function _bytes(x, what) {
|
|
108
|
+
if (Buffer.isBuffer(x)) return x;
|
|
109
|
+
if (x instanceof Uint8Array) return Buffer.from(x);
|
|
110
|
+
if (typeof x === "string") return Buffer.from(x, "utf8");
|
|
111
|
+
throw new TsaError("tsa/bad-bytes", "tsa: " + what + " must be Buffer / Uint8Array / string");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _normHex(h) {
|
|
115
|
+
return String(h).replace(/[^0-9a-fA-F]/g, "").replace(/^0+(?=.)/, "").toUpperCase();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Resolve the message imprint: hash the data, or use a pre-computed hash.
|
|
119
|
+
function _imprint(data, opts, fnName) {
|
|
120
|
+
var hashName = opts.hashAlg || "SHA-512";
|
|
121
|
+
var h = IMPRINT_HASHES[hashName];
|
|
122
|
+
if (!h) {
|
|
123
|
+
throw new TsaError("tsa/bad-hash-alg",
|
|
124
|
+
fnName + ": hashAlg must be one of " + Object.keys(IMPRINT_HASHES).join(" / "));
|
|
125
|
+
}
|
|
126
|
+
var digest;
|
|
127
|
+
if (opts.hashed) {
|
|
128
|
+
digest = _bytes(data, "hash");
|
|
129
|
+
var expectLen = nodeCrypto.createHash(h.nodeHash).update(Buffer.alloc(0)).digest().length;
|
|
130
|
+
if (digest.length !== expectLen) {
|
|
131
|
+
throw new TsaError("tsa/bad-hash-length",
|
|
132
|
+
fnName + ": pre-hashed input is " + digest.length + " bytes, expected " +
|
|
133
|
+
expectLen + " for " + hashName);
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
digest = nodeCrypto.createHash(h.nodeHash).update(_bytes(data, "data")).digest();
|
|
137
|
+
}
|
|
138
|
+
return { hashName: hashName, hashOid: h.oid, digest: digest };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @primitive b.tsa.buildRequest
|
|
143
|
+
* @signature b.tsa.buildRequest(data, opts?)
|
|
144
|
+
* @since 0.12.38
|
|
145
|
+
* @status experimental
|
|
146
|
+
* @compliance soc2
|
|
147
|
+
* @related b.tsa.parseResponse, b.tsa.verifyToken
|
|
148
|
+
*
|
|
149
|
+
* Build a DER-encoded RFC 3161 TimeStampReq for <code>data</code>. POST
|
|
150
|
+
* the returned bytes to the TSA as <code>application/timestamp-query</code>;
|
|
151
|
+
* keep the returned <code>nonce</code> to pass to
|
|
152
|
+
* <code>verifyToken</code>. By default a random 64-bit nonce is included
|
|
153
|
+
* and the TSA is asked to return its certificate.
|
|
154
|
+
*
|
|
155
|
+
* @opts
|
|
156
|
+
* {
|
|
157
|
+
* hashAlg: string, // "SHA-512" (default) | "SHA-256" | "SHA-384" | "SHA3-256" | "SHA3-512"
|
|
158
|
+
* hashed: boolean, // true means `data` is already the digest (must match hashAlg length)
|
|
159
|
+
* reqPolicy: string, // request a specific TSA policy OID (dotted)
|
|
160
|
+
* nonce: Buffer, // explicit nonce bytes, or false to omit (default: random 8 bytes)
|
|
161
|
+
* certReq: boolean, // ask the TSA to include its cert (default true)
|
|
162
|
+
* }
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* var req = b.tsa.buildRequest(releaseTarball, { hashAlg: "SHA-512" });
|
|
166
|
+
* // POST req.der to the TSA; keep req.nonce for verifyToken
|
|
167
|
+
* // → { der, nonce, hashAlg, messageImprint }
|
|
168
|
+
*/
|
|
169
|
+
function buildRequest(data, opts) {
|
|
170
|
+
opts = opts || {};
|
|
171
|
+
validateOpts.requireObject(opts, "tsa.buildRequest", TsaError);
|
|
172
|
+
validateOpts(opts, ["hashAlg", "hashed", "reqPolicy", "nonce", "certReq"], "tsa.buildRequest");
|
|
173
|
+
var imp = _imprint(data, opts, "tsa.buildRequest");
|
|
174
|
+
|
|
175
|
+
var algId = asn1.writeSequence([asn1.writeOid(imp.hashOid), asn1.writeNull()]);
|
|
176
|
+
var messageImprint = asn1.writeSequence([algId, asn1.writeOctetString(imp.digest)]);
|
|
177
|
+
|
|
178
|
+
var children = [asn1.writeInteger(Buffer.from([1])), messageImprint]; // version 1
|
|
179
|
+
if (typeof opts.reqPolicy === "string") children.push(asn1.writeOid(opts.reqPolicy));
|
|
180
|
+
|
|
181
|
+
var nonce = null;
|
|
182
|
+
if (opts.nonce !== false) {
|
|
183
|
+
nonce = Buffer.isBuffer(opts.nonce) ? opts.nonce : nodeCrypto.randomBytes(8); // allow:raw-byte-literal — RFC 3161 nonce: 64-bit random
|
|
184
|
+
children.push(asn1.writeInteger(nonce));
|
|
185
|
+
}
|
|
186
|
+
// certReq DEFAULT FALSE — only encode when true (the default we want).
|
|
187
|
+
var certReq = opts.certReq !== false;
|
|
188
|
+
if (certReq) children.push(asn1.writeBoolean(true));
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
der: asn1.writeSequence(children),
|
|
192
|
+
nonce: nonce,
|
|
193
|
+
hashAlg: imp.hashName,
|
|
194
|
+
messageImprint: imp.digest,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @primitive b.tsa.parseResponse
|
|
200
|
+
* @signature b.tsa.parseResponse(der)
|
|
201
|
+
* @since 0.12.38
|
|
202
|
+
* @status experimental
|
|
203
|
+
* @compliance soc2
|
|
204
|
+
* @related b.tsa.buildRequest, b.tsa.verifyToken
|
|
205
|
+
*
|
|
206
|
+
* Parse a DER RFC 3161 TimeStampResp. Returns the PKIStatus and, when
|
|
207
|
+
* the request was granted, the timestamp token (the DER ContentInfo to
|
|
208
|
+
* pass to <code>verifyToken</code>). A non-granted status surfaces the
|
|
209
|
+
* status integer, any free-text, and the decoded failure-info flags
|
|
210
|
+
* rather than throwing — the caller decides how to react.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* var resp = b.tsa.parseResponse(httpBodyBytes);
|
|
214
|
+
* if (resp.granted) { var out = b.tsa.verifyToken(resp.token, { data: tarball, nonce: req.nonce }); }
|
|
215
|
+
* // → { granted: true, status: 0, token, statusString: null, failInfo: [] }
|
|
216
|
+
*/
|
|
217
|
+
function parseResponse(der) {
|
|
218
|
+
var buf = _bytes(der, "der");
|
|
219
|
+
var root;
|
|
220
|
+
try { root = asn1.readNode(buf, 0); } catch (e) {
|
|
221
|
+
throw new TsaError("tsa/malformed", "tsa.parseResponse: not DER: " + ((e && e.message) || e));
|
|
222
|
+
}
|
|
223
|
+
if (root.tag !== asn1.TAG.SEQUENCE || root.tagClass !== asn1.TAG_CLASS.UNIVERSAL) {
|
|
224
|
+
throw new TsaError("tsa/malformed", "tsa.parseResponse: TimeStampResp must be a SEQUENCE");
|
|
225
|
+
}
|
|
226
|
+
var children = asn1.readSequence(root.value);
|
|
227
|
+
if (children.length < 1 || children[0].tag !== asn1.TAG.SEQUENCE) {
|
|
228
|
+
throw new TsaError("tsa/malformed", "tsa.parseResponse: missing PKIStatusInfo");
|
|
229
|
+
}
|
|
230
|
+
var statusInfo = asn1.readSequence(children[0].value);
|
|
231
|
+
if (statusInfo.length < 1 || statusInfo[0].tag !== asn1.TAG.INTEGER) {
|
|
232
|
+
throw new TsaError("tsa/malformed", "tsa.parseResponse: PKIStatusInfo missing status INTEGER");
|
|
233
|
+
}
|
|
234
|
+
var status = asn1.readUnsignedInt(statusInfo[0]);
|
|
235
|
+
if (typeof status !== "number") {
|
|
236
|
+
throw new TsaError("tsa/malformed", "tsa.parseResponse: status integer out of range");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
var statusString = null;
|
|
240
|
+
var failInfo = [];
|
|
241
|
+
for (var i = 1; i < statusInfo.length; i += 1) {
|
|
242
|
+
var n = statusInfo[i];
|
|
243
|
+
if (n.tag === asn1.TAG.SEQUENCE && statusString === null) {
|
|
244
|
+
var texts = asn1.readSequence(n.value).map(function (t) { return t.value.toString("utf8"); });
|
|
245
|
+
statusString = texts.join("; ");
|
|
246
|
+
} else if (n.tag === asn1.TAG.BIT_STRING) {
|
|
247
|
+
failInfo = _decodeFailInfo(n);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// granted(0) / grantedWithMods(1) carry a token; 2..5 are failures.
|
|
252
|
+
var granted = status === 0 || status === 1;
|
|
253
|
+
var token = null;
|
|
254
|
+
if (granted) {
|
|
255
|
+
var tokNode = children[1];
|
|
256
|
+
if (!tokNode) {
|
|
257
|
+
throw new TsaError("tsa/no-token",
|
|
258
|
+
"tsa.parseResponse: status granted but no timeStampToken present");
|
|
259
|
+
}
|
|
260
|
+
token = tokNode.raw;
|
|
261
|
+
}
|
|
262
|
+
return { granted: granted, status: status, statusString: statusString, failInfo: failInfo, token: token };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// PKIFailureInfo bit names (RFC 3161 §2.4.2 / RFC 2510).
|
|
266
|
+
var FAIL_INFO_BITS = { // allow:raw-byte-literal — RFC 3161 PKIFailureInfo bit positions
|
|
267
|
+
0: "badAlg", 2: "badRequest", 5: "badDataFormat", 14: "timeNotAvailable",
|
|
268
|
+
15: "unacceptedPolicy", 16: "unacceptedExtension", 17: "addInfoNotAvailable", 25: "systemFailure",
|
|
269
|
+
};
|
|
270
|
+
function _decodeFailInfo(bitStringNode) {
|
|
271
|
+
var out = [];
|
|
272
|
+
var v = bitStringNode.value;
|
|
273
|
+
if (v.length <= 1) return out; // first byte = unused-bit count
|
|
274
|
+
var bits = v.slice(1);
|
|
275
|
+
for (var byteIdx = 0; byteIdx < bits.length; byteIdx += 1) {
|
|
276
|
+
for (var b = 0; b < 8; b += 1) { // allow:raw-byte-literal — 8 bits per byte
|
|
277
|
+
if (bits[byteIdx] & (0x80 >> b)) {
|
|
278
|
+
var pos = byteIdx * 8 + b; // allow:raw-byte-literal — 8 bits per byte
|
|
279
|
+
out.push(FAIL_INFO_BITS[pos] || ("bit" + pos));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Parse GeneralizedTime "YYYYMMDDHHMMSS[.fff]Z" to Date (UTC).
|
|
287
|
+
function _parseGeneralizedTime(node) {
|
|
288
|
+
var s = node.value.toString("ascii");
|
|
289
|
+
var m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?Z$/.exec(s);
|
|
290
|
+
if (!m) {
|
|
291
|
+
throw new TsaError("tsa/bad-gentime", "tsa: genTime is not a 'Z'-terminated GeneralizedTime: " + s);
|
|
292
|
+
}
|
|
293
|
+
// Fractional seconds (rare in timestamps) → milliseconds from the
|
|
294
|
+
// first three fractional digits, zero-padded; no float arithmetic.
|
|
295
|
+
var frac = m[7] ? m[7].slice(1) : "";
|
|
296
|
+
var ms = frac ? parseInt((frac + "000").slice(0, 3), 10) : 0;
|
|
297
|
+
var t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], ms);
|
|
298
|
+
return new Date(t);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// TSTInfo (RFC 3161 §2.4.2) to fields. messageImprint stays raw so
|
|
302
|
+
// verifyToken can compare hash-alg OID + hashed bytes.
|
|
303
|
+
function _parseTstInfo(eContent) {
|
|
304
|
+
var root = asn1.readNode(eContent, 0);
|
|
305
|
+
if (root.tag !== asn1.TAG.SEQUENCE) {
|
|
306
|
+
throw new TsaError("tsa/malformed", "tsa: TSTInfo must be a SEQUENCE");
|
|
307
|
+
}
|
|
308
|
+
var c = asn1.readSequence(root.value);
|
|
309
|
+
if (c.length < 5) throw new TsaError("tsa/malformed", "tsa: TSTInfo too short");
|
|
310
|
+
var idx = 0;
|
|
311
|
+
idx += 1; // version
|
|
312
|
+
var policy = asn1.readOid(c[idx]); idx += 1;
|
|
313
|
+
var miNode = c[idx]; idx += 1;
|
|
314
|
+
var serialNode = c[idx]; idx += 1;
|
|
315
|
+
var genTime = _parseGeneralizedTime(c[idx]); idx += 1;
|
|
316
|
+
|
|
317
|
+
// Parse messageImprint { hashAlgorithm AlgId, hashedMessage OCTET STRING }.
|
|
318
|
+
var mi = asn1.readSequence(miNode.value);
|
|
319
|
+
var miAlg = asn1.readSequence(mi[0].value);
|
|
320
|
+
var miHashOid = asn1.readOid(miAlg[0]);
|
|
321
|
+
var miHash = asn1.readOctetString(mi[1]);
|
|
322
|
+
|
|
323
|
+
var accuracy = null;
|
|
324
|
+
var nonce = null;
|
|
325
|
+
for (; idx < c.length; idx += 1) {
|
|
326
|
+
var n = c[idx];
|
|
327
|
+
if (n.tagClass === asn1.TAG_CLASS.UNIVERSAL && n.tag === asn1.TAG.SEQUENCE && accuracy === null) {
|
|
328
|
+
accuracy = _parseAccuracy(n);
|
|
329
|
+
} else if (n.tagClass === asn1.TAG_CLASS.UNIVERSAL && n.tag === asn1.TAG.INTEGER) {
|
|
330
|
+
nonce = n.value; // raw INTEGER bytes
|
|
331
|
+
}
|
|
332
|
+
// ordering BOOLEAN, [0] tsa, [1] extensions are read but not surfaced in v1.
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
var serialHex = Buffer.isBuffer(serialNode.value) ? serialNode.value.toString("hex") : null;
|
|
336
|
+
return {
|
|
337
|
+
policy: policy,
|
|
338
|
+
genTime: genTime,
|
|
339
|
+
accuracy: accuracy,
|
|
340
|
+
nonce: nonce,
|
|
341
|
+
serialHex: serialHex,
|
|
342
|
+
imprintHashOid: miHashOid,
|
|
343
|
+
imprintHash: miHash,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _parseAccuracy(node) {
|
|
348
|
+
var c = asn1.readSequence(node.value);
|
|
349
|
+
var out = { seconds: 0, millis: 0, micros: 0 };
|
|
350
|
+
for (var i = 0; i < c.length; i += 1) {
|
|
351
|
+
var n = c[i];
|
|
352
|
+
if (n.tagClass === asn1.TAG_CLASS.UNIVERSAL && n.tag === asn1.TAG.INTEGER) {
|
|
353
|
+
out.seconds = asn1.readUnsignedInt(n);
|
|
354
|
+
} else if (n.tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC && n.tag === 0) {
|
|
355
|
+
out.millis = _ctxInt(n);
|
|
356
|
+
} else if (n.tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC && n.tag === 1) {
|
|
357
|
+
out.micros = _ctxInt(n);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
function _ctxInt(node) {
|
|
363
|
+
// [n] IMPLICIT INTEGER — value bytes are the integer content directly.
|
|
364
|
+
var v = node.value, n = 0;
|
|
365
|
+
for (var i = 0; i < v.length; i += 1) n = (n * 256) + v[i]; // allow:raw-byte-literal — base-256 integer accumulation
|
|
366
|
+
return n;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Walk a certificate's extensions for one OID to { critical, valueBytes } or null.
|
|
370
|
+
function _certExtension(certDer, wantOid) {
|
|
371
|
+
var cert = asn1.readNode(certDer, 0);
|
|
372
|
+
var top = asn1.readSequence(cert.value); // [ tbs, sigAlg, sigValue ]
|
|
373
|
+
var tbs = asn1.readSequence(top[0].value);
|
|
374
|
+
// Find the [3] EXPLICIT extensions wrapper.
|
|
375
|
+
var extsWrapper = asn1.findChild(tbs, function (n) {
|
|
376
|
+
return n.tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC && n.tag === 3;
|
|
377
|
+
});
|
|
378
|
+
if (!extsWrapper) return null;
|
|
379
|
+
var extsSeq = asn1.unwrapExplicit(extsWrapper, 3);
|
|
380
|
+
var exts = asn1.readSequence(extsSeq.value);
|
|
381
|
+
for (var i = 0; i < exts.length; i += 1) {
|
|
382
|
+
var ec = asn1.readSequence(exts[i].value);
|
|
383
|
+
var oid = asn1.readOid(ec[0]);
|
|
384
|
+
if (oid !== wantOid) continue;
|
|
385
|
+
var critical = false;
|
|
386
|
+
var valueNode = null;
|
|
387
|
+
for (var j = 1; j < ec.length; j += 1) {
|
|
388
|
+
if (ec[j].tag === asn1.TAG.BOOLEAN) critical = ec[j].value[0] !== 0;
|
|
389
|
+
else if (ec[j].tag === asn1.TAG.OCTET_STRING) valueNode = ec[j];
|
|
390
|
+
}
|
|
391
|
+
if (!valueNode) return null;
|
|
392
|
+
return { critical: critical, valueBytes: asn1.readOctetString(valueNode) };
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// RFC 3161 §2.3: the signing cert's EKU MUST contain id-kp-timeStamping,
|
|
398
|
+
// MUST be critical, and MUST be the only key purpose.
|
|
399
|
+
function _checkTimestampingEku(certDer) {
|
|
400
|
+
var ext = _certExtension(certDer, OID_EXT_EKU);
|
|
401
|
+
if (!ext) {
|
|
402
|
+
throw new TsaError("tsa/bad-eku",
|
|
403
|
+
"tsa.verifyToken: signer certificate has no extendedKeyUsage (RFC 3161 §2.3 requires id-kp-timeStamping)");
|
|
404
|
+
}
|
|
405
|
+
if (!ext.critical) {
|
|
406
|
+
throw new TsaError("tsa/bad-eku",
|
|
407
|
+
"tsa.verifyToken: extendedKeyUsage is not marked critical (RFC 3161 §2.3)");
|
|
408
|
+
}
|
|
409
|
+
var purposes = asn1.readSequence(asn1.readNode(ext.valueBytes, 0).value).map(asn1.readOid);
|
|
410
|
+
if (purposes.length !== 1 || purposes[0] !== OID_KP_TIMESTAMPING) {
|
|
411
|
+
throw new TsaError("tsa/bad-eku",
|
|
412
|
+
"tsa.verifyToken: extendedKeyUsage must be exactly { id-kp-timeStamping } (got " +
|
|
413
|
+
purposes.join(", ") + ")");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Pick the signing cert from the token: prefer the one whose serial
|
|
418
|
+
// matches the SignerInfo sid (IssuerAndSerialNumber); else fall through
|
|
419
|
+
// to all certs (the EKU + signature checks remain authoritative).
|
|
420
|
+
function _candidateSigners(sidRaw, certs) {
|
|
421
|
+
var wantSerial = null;
|
|
422
|
+
try {
|
|
423
|
+
var sid = asn1.readNode(sidRaw, 0);
|
|
424
|
+
if (sid.tag === asn1.TAG.SEQUENCE && sid.tagClass === asn1.TAG_CLASS.UNIVERSAL) {
|
|
425
|
+
var parts = asn1.readSequence(sid.value); // IssuerAndSerialNumber { issuer, serial }
|
|
426
|
+
var serialNode = parts[parts.length - 1];
|
|
427
|
+
if (serialNode && serialNode.tag === asn1.TAG.INTEGER) {
|
|
428
|
+
wantSerial = _normHex(serialNode.value.toString("hex"));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch (_e) { wantSerial = null; }
|
|
432
|
+
if (wantSerial === null) return certs.slice();
|
|
433
|
+
var matched = certs.filter(function (d) {
|
|
434
|
+
try { return _normHex(new nodeCrypto.X509Certificate(d).serialNumber) === wantSerial; }
|
|
435
|
+
catch (_e) { return false; }
|
|
436
|
+
});
|
|
437
|
+
return matched.length ? matched : certs.slice();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _verifyCmsSignature(si, eContent, signerCertDer) {
|
|
441
|
+
var alg = SIG_ALGS[si.sigAlgOid];
|
|
442
|
+
if (!alg) {
|
|
443
|
+
throw new TsaError("tsa/bad-sig-alg",
|
|
444
|
+
"tsa.verifyToken: signer signature algorithm " + si.sigAlgOid +
|
|
445
|
+
" is not a supported RSA / ECDSA timestamp algorithm");
|
|
446
|
+
}
|
|
447
|
+
if (!si.signedAttrsRaw) {
|
|
448
|
+
throw new TsaError("tsa/no-signed-attrs",
|
|
449
|
+
"tsa.verifyToken: SignerInfo has no signed attributes (RFC 3161 tokens always sign attributes)");
|
|
450
|
+
}
|
|
451
|
+
var digestNode = DIGEST_OID_TO_NODE[si.digestAlgOid];
|
|
452
|
+
if (!digestNode) {
|
|
453
|
+
throw new TsaError("tsa/bad-digest",
|
|
454
|
+
"tsa.verifyToken: SignerInfo digest algorithm " + si.digestAlgOid + " unsupported");
|
|
455
|
+
}
|
|
456
|
+
// The eContent digest must equal the messageDigest signed attribute,
|
|
457
|
+
// and the contentType attribute must be id-ct-TSTInfo.
|
|
458
|
+
var attrs = _parseSignedAttrs(si.signedAttrsRaw);
|
|
459
|
+
if (!attrs.messageDigest) {
|
|
460
|
+
throw new TsaError("tsa/no-message-digest", "tsa.verifyToken: signed attrs missing messageDigest");
|
|
461
|
+
}
|
|
462
|
+
var actual = nodeCrypto.createHash(digestNode).update(eContent).digest();
|
|
463
|
+
if (!bCrypto.timingSafeEqual(actual, attrs.messageDigest)) {
|
|
464
|
+
throw new TsaError("tsa/message-digest-mismatch",
|
|
465
|
+
"tsa.verifyToken: recomputed eContent digest does not match the messageDigest attribute");
|
|
466
|
+
}
|
|
467
|
+
if (attrs.contentType && attrs.contentType !== OID_TST_INFO) {
|
|
468
|
+
throw new TsaError("tsa/bad-content-type-attr",
|
|
469
|
+
"tsa.verifyToken: signed contentType attribute is " + attrs.contentType + ", expected id-ct-TSTInfo");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
var pubKey = new nodeCrypto.X509Certificate(signerCertDer).publicKey;
|
|
473
|
+
// For algorithm ids that don't name the hash (bare rsaEncryption /
|
|
474
|
+
// id-ecPublicKey / RSASSA-PSS), the hash comes from the digestAlgorithm.
|
|
475
|
+
var hashName = alg.hash || digestNode;
|
|
476
|
+
var keyParam = pubKey;
|
|
477
|
+
if (alg.scheme === "pss") {
|
|
478
|
+
keyParam = { key: pubKey, padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING,
|
|
479
|
+
saltLength: nodeCrypto.constants.RSA_PSS_SALTLEN_AUTO };
|
|
480
|
+
}
|
|
481
|
+
var ok;
|
|
482
|
+
try { ok = nodeCrypto.verify(hashName, si.signedAttrsRaw, keyParam, si.signature); }
|
|
483
|
+
catch (e) {
|
|
484
|
+
throw new TsaError("tsa/verify-threw",
|
|
485
|
+
"tsa.verifyToken: signature verification threw: " + ((e && e.message) || e));
|
|
486
|
+
}
|
|
487
|
+
if (!ok) {
|
|
488
|
+
throw new TsaError("tsa/bad-signature",
|
|
489
|
+
"tsa.verifyToken: CMS signature over the signed attributes did not verify");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Parse the universal-SET signedAttrs bytes for contentType + messageDigest.
|
|
494
|
+
function _parseSignedAttrs(signedAttrsRaw) {
|
|
495
|
+
var set = asn1.readNode(signedAttrsRaw, 0);
|
|
496
|
+
var attrs = asn1.readSequence(set.value);
|
|
497
|
+
var out = { contentType: null, messageDigest: null };
|
|
498
|
+
for (var i = 0; i < attrs.length; i += 1) {
|
|
499
|
+
var a = asn1.readSequence(attrs[i].value); // Attribute { type OID, values SET }
|
|
500
|
+
var type = asn1.readOid(a[0]);
|
|
501
|
+
var valueSet = asn1.readSequence(a[1].value);
|
|
502
|
+
if (!valueSet.length) continue;
|
|
503
|
+
if (type === OID_MESSAGE_DIGEST) out.messageDigest = asn1.readOctetString(valueSet[0]);
|
|
504
|
+
else if (type === OID_CONTENT_TYPE) out.contentType = asn1.readOid(valueSet[0]);
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Optional: verify the signer chains to a trust anchor and is valid at `at`.
|
|
510
|
+
function _verifyChain(signerCertDer, tokenCerts, trustAnchorsPem, at) {
|
|
511
|
+
var anchors = trustAnchorsPem.map(function (p) { return new nodeCrypto.X509Certificate(p); });
|
|
512
|
+
var pool = tokenCerts.map(function (d) { return new nodeCrypto.X509Certificate(d); });
|
|
513
|
+
var current = new nodeCrypto.X509Certificate(signerCertDer);
|
|
514
|
+
var seen = 0;
|
|
515
|
+
var atTime = at.getTime();
|
|
516
|
+
while (seen <= pool.length + 1) {
|
|
517
|
+
_assertValidAt(current, atTime);
|
|
518
|
+
// Anchor reached?
|
|
519
|
+
for (var a = 0; a < anchors.length; a += 1) {
|
|
520
|
+
if (_issued(anchors[a], current)) { _assertValidAt(anchors[a], atTime); return; }
|
|
521
|
+
if (current.fingerprint256 === anchors[a].fingerprint256) return;
|
|
522
|
+
}
|
|
523
|
+
// Walk up through the token's intermediates.
|
|
524
|
+
var parent = null;
|
|
525
|
+
for (var p = 0; p < pool.length; p += 1) {
|
|
526
|
+
if (pool[p].fingerprint256 !== current.fingerprint256 && _issued(pool[p], current)) {
|
|
527
|
+
parent = pool[p]; break;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (!parent) {
|
|
531
|
+
throw new TsaError("tsa/untrusted-chain",
|
|
532
|
+
"tsa.verifyToken: signer certificate does not chain to any supplied trust anchor");
|
|
533
|
+
}
|
|
534
|
+
current = parent;
|
|
535
|
+
seen += 1;
|
|
536
|
+
}
|
|
537
|
+
throw new TsaError("tsa/chain-loop", "tsa.verifyToken: certificate chain did not terminate");
|
|
538
|
+
}
|
|
539
|
+
function _issued(issuer, subject) {
|
|
540
|
+
try { return subject.checkIssued(issuer) && subject.verify(issuer.publicKey); }
|
|
541
|
+
catch (_e) { return false; }
|
|
542
|
+
}
|
|
543
|
+
function _assertValidAt(cert, atMs) {
|
|
544
|
+
if (atMs < cert.validFromDate.getTime() || atMs > cert.validToDate.getTime()) {
|
|
545
|
+
throw new TsaError("tsa/cert-expired",
|
|
546
|
+
"tsa.verifyToken: certificate '" + cert.subject + "' is not valid at the asserted time");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @primitive b.tsa.verifyToken
|
|
552
|
+
* @signature b.tsa.verifyToken(token, opts)
|
|
553
|
+
* @since 0.12.38
|
|
554
|
+
* @status experimental
|
|
555
|
+
* @compliance soc2
|
|
556
|
+
* @related b.tsa.buildRequest, b.cms.parseSignedData
|
|
557
|
+
*
|
|
558
|
+
* Verify an RFC 3161 timestamp token against your data and return the
|
|
559
|
+
* asserted time. Performs the full §2.4.2 / §2.3 check: eContentType is
|
|
560
|
+
* <code>id-ct-TSTInfo</code>, the message imprint equals the hash of
|
|
561
|
+
* <code>opts.data</code> (or <code>opts.hash</code>), a sent nonce
|
|
562
|
+
* round-trips, the signer cert's extendedKeyUsage is a critical, sole
|
|
563
|
+
* <code>id-kp-timeStamping</code>, and the CMS signature verifies. Pass
|
|
564
|
+
* <code>opts.trustAnchorsPem</code> to also verify the certificate
|
|
565
|
+
* chain and validity at the asserted time.
|
|
566
|
+
*
|
|
567
|
+
* @opts
|
|
568
|
+
* {
|
|
569
|
+
* data: Buffer, // the timestamped data (hashed with hashAlg)
|
|
570
|
+
* hash: Buffer, // OR a pre-computed digest (with hashAlg)
|
|
571
|
+
* hashAlg: string, // default "SHA-512" — must match the imprint
|
|
572
|
+
* nonce: Buffer, // require the token nonce to match (from buildRequest)
|
|
573
|
+
* trustAnchorsPem: string|string[], // PEM root(s) — enables chain + validity verification
|
|
574
|
+
* at: Date, // validity instant for chain check (default: genTime); must be a valid Date
|
|
575
|
+
* }
|
|
576
|
+
*
|
|
577
|
+
* @example
|
|
578
|
+
* var out = b.tsa.verifyToken(resp.token, { data: tarball, hashAlg: "SHA-512", nonce: req.nonce });
|
|
579
|
+
* // → { genTime, policy, serialHex, accuracy, hashAlg, signerCertPem }
|
|
580
|
+
*/
|
|
581
|
+
function verifyToken(token, opts) {
|
|
582
|
+
validateOpts.requireObject(opts, "tsa.verifyToken", TsaError);
|
|
583
|
+
validateOpts(opts, ["data", "hash", "hashAlg", "nonce", "trustAnchorsPem", "at"], "tsa.verifyToken");
|
|
584
|
+
if (opts.data == null && opts.hash == null) {
|
|
585
|
+
throw new TsaError("tsa/no-data", "tsa.verifyToken: pass opts.data or opts.hash to bind the token");
|
|
586
|
+
}
|
|
587
|
+
var imp = _imprint(opts.hash != null ? opts.hash : opts.data,
|
|
588
|
+
{ hashAlg: opts.hashAlg, hashed: opts.hash != null }, "tsa.verifyToken");
|
|
589
|
+
|
|
590
|
+
var sd;
|
|
591
|
+
try { sd = cms.parseSignedData(_bytes(token, "token")); }
|
|
592
|
+
catch (e) {
|
|
593
|
+
throw new TsaError("tsa/not-cms", "tsa.verifyToken: token is not CMS SignedData: " + ((e && e.message) || e));
|
|
594
|
+
}
|
|
595
|
+
if (sd.encapContent.eContentType !== OID_TST_INFO) {
|
|
596
|
+
throw new TsaError("tsa/not-tst",
|
|
597
|
+
"tsa.verifyToken: eContentType is " + sd.encapContent.eContentType + ", expected id-ct-TSTInfo");
|
|
598
|
+
}
|
|
599
|
+
if (!sd.encapContent.eContent) {
|
|
600
|
+
throw new TsaError("tsa/detached", "tsa.verifyToken: timestamp token has no embedded TSTInfo (detached not allowed)");
|
|
601
|
+
}
|
|
602
|
+
if (!sd.signerInfos.length) {
|
|
603
|
+
throw new TsaError("tsa/no-signer", "tsa.verifyToken: token has no SignerInfo");
|
|
604
|
+
}
|
|
605
|
+
if (!sd.certificates.length) {
|
|
606
|
+
throw new TsaError("tsa/no-cert",
|
|
607
|
+
"tsa.verifyToken: token carries no certificate — request one with certReq (the default)");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
var tst = _parseTstInfo(sd.encapContent.eContent);
|
|
611
|
+
|
|
612
|
+
// (3) message imprint must match the data.
|
|
613
|
+
if (OID_TO_IMPRINT_HASH[tst.imprintHashOid] !== imp.hashName) {
|
|
614
|
+
throw new TsaError("tsa/imprint-alg-mismatch",
|
|
615
|
+
"tsa.verifyToken: token imprint hash (" + tst.imprintHashOid + ") differs from " + imp.hashName);
|
|
616
|
+
}
|
|
617
|
+
if (!bCrypto.timingSafeEqual(tst.imprintHash, imp.digest)) {
|
|
618
|
+
throw new TsaError("tsa/imprint-mismatch",
|
|
619
|
+
"tsa.verifyToken: token message imprint does not match the supplied data");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// (4) nonce round-trip.
|
|
623
|
+
if (opts.nonce != null) {
|
|
624
|
+
var want = _bytes(opts.nonce, "nonce");
|
|
625
|
+
var got = tst.nonce == null ? Buffer.alloc(0) : Buffer.from(tst.nonce);
|
|
626
|
+
// Compare as unsigned integers (ignore a DER sign-pad byte difference).
|
|
627
|
+
if (_normHex(want.toString("hex")) !== _normHex(got.toString("hex"))) {
|
|
628
|
+
throw new TsaError("tsa/nonce-mismatch", "tsa.verifyToken: token nonce does not match the request nonce");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// (5)+(6) signer cert + EKU + CMS signature.
|
|
633
|
+
var si = sd.signerInfos[0];
|
|
634
|
+
var candidates = _candidateSigners(si.sid, sd.certificates);
|
|
635
|
+
var signerCertDer = null;
|
|
636
|
+
var lastErr = null;
|
|
637
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
638
|
+
try {
|
|
639
|
+
_checkTimestampingEku(candidates[i]);
|
|
640
|
+
_verifyCmsSignature(si, sd.encapContent.eContent, candidates[i]);
|
|
641
|
+
signerCertDer = candidates[i];
|
|
642
|
+
break;
|
|
643
|
+
} catch (e) { lastErr = e; }
|
|
644
|
+
}
|
|
645
|
+
if (!signerCertDer) {
|
|
646
|
+
throw lastErr || new TsaError("tsa/no-valid-signer",
|
|
647
|
+
"tsa.verifyToken: no certificate in the token both carries the timestamping EKU and verifies the signature");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// (7) optional chain + validity. Accept a single PEM string or an
|
|
651
|
+
// array — never silently skip chain verification when the caller
|
|
652
|
+
// supplied an anchor in an unexpected shape (a fail-open).
|
|
653
|
+
if (opts.trustAnchorsPem !== undefined && opts.trustAnchorsPem !== null) {
|
|
654
|
+
var anchors = typeof opts.trustAnchorsPem === "string" ? [opts.trustAnchorsPem] : opts.trustAnchorsPem;
|
|
655
|
+
if (!Array.isArray(anchors) || anchors.length === 0 ||
|
|
656
|
+
!anchors.every(function (a) { return typeof a === "string" && a.length > 0; })) {
|
|
657
|
+
throw new TsaError("tsa/bad-trust-anchors",
|
|
658
|
+
"tsa.verifyToken: trustAnchorsPem must be a non-empty PEM string or array of PEM strings");
|
|
659
|
+
}
|
|
660
|
+
// A supplied opts.at must be a valid Date — an Invalid Date would
|
|
661
|
+
// make every validity-window comparison NaN (silently disabling it).
|
|
662
|
+
var at = tst.genTime;
|
|
663
|
+
if (opts.at !== undefined && opts.at !== null) {
|
|
664
|
+
if (!(opts.at instanceof Date) || !isFinite(opts.at.getTime())) {
|
|
665
|
+
throw new TsaError("tsa/bad-at", "tsa.verifyToken: opts.at must be a valid Date");
|
|
666
|
+
}
|
|
667
|
+
at = opts.at;
|
|
668
|
+
}
|
|
669
|
+
_verifyChain(signerCertDer, sd.certificates, anchors, at);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
genTime: tst.genTime,
|
|
674
|
+
policy: tst.policy,
|
|
675
|
+
serialHex: tst.serialHex,
|
|
676
|
+
accuracy: tst.accuracy,
|
|
677
|
+
hashAlg: imp.hashName,
|
|
678
|
+
signerCertPem: new nodeCrypto.X509Certificate(signerCertDer).toString(),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
module.exports = {
|
|
683
|
+
buildRequest: buildRequest,
|
|
684
|
+
parseResponse: parseResponse,
|
|
685
|
+
verifyToken: verifyToken,
|
|
686
|
+
IMPRINT_HASHES: IMPRINT_HASHES,
|
|
687
|
+
TsaError: TsaError,
|
|
688
|
+
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:5183e917-7caf-48d3-b87d-5efa87791e85",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-25T02:32:18.883Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.12.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.12.38",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.12.
|
|
25
|
+
"version": "0.12.38",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.12.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.12.38",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.12.
|
|
57
|
+
"ref": "@blamejs/core@0.12.38",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|