@blamejs/core 0.12.36 → 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 +4 -0
- package/README.md +2 -0
- package/index.js +2 -0
- package/lib/cose.js +30 -4
- package/lib/scitt.js +243 -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,10 @@ 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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- v0.12.35 (2026-05-24) — **`b.eat` — Entity Attestation Token (RFC 9711) over `b.cwt`.** An EAT is the token a Relying Party asks a device or software entity to produce to prove what it is and what state it is in — a freshness nonce, a Universal Entity ID, OEM / hardware identifiers, debug status, software measurements, and nested submodule attestations. `b.eat` is the RFC 9711 profile over the v0.12.34 `b.cwt`: it maps the EAT claim names to their IANA CWT claim-key integer labels and adds the attestation-specific verification on top of the CWT signature + time checks. The central control is the verifier-nonce binding: when the Relying Party supplies a fresh `expectedNonce`, the token's `eat_nonce` (claim 10) must match it (constant-time compare) — without it a captured attestation replays forever. `verify` also enforces a debug-status policy (`requireDebugDisabled` refuses an `enabled` or absent `dbgstat`) and pins the `eat_profile`. RFC 9711 is a finalized standard; signing follows `b.cwt` / `b.cose` (ES256/384/512 + EdDSA interoperable today, ML-DSA-87 PQC-forward). **Added:** *`b.eat.sign(claims, opts)` / `b.eat.verify(eat, opts)`* — `sign` maps EAT claim names (`nonce`, `ueid`, `oemid`, `hwmodel`, `dbgstat`, `eat_profile`, `swname`/`swversion`, `measurements`, `submods`, …) to their RFC 9711 integer labels and accepts the `dbgstat` enum by name (`disabled-since-boot` → 2); standard CWT claims (`iss` / `exp` / …) pass through. `verify` returns `{ claims, raw, alg, protectedHeaders }` with the labels mapped back to friendly names and `dbgstat` decoded to its enum name. Attestation enforcement: `expectedNonce` requires a matching `eat_nonce` (refused `eat/nonce-mismatch`, missing `eat/nonce-missing` — `eat_nonce` may be a single byte string or an array for multiple verifiers), `requireDebugDisabled` refuses a non-disabled `dbgstat` (`eat/debug-not-disabled`), and `expectedProfile` pins `eat_profile`. The signature, algorithm allowlist, and `exp`/`nbf` checks delegate to `b.cwt` / `b.cose`. · *`b.cwt.sign` accepts a `Map`* — `b.cwt.sign` now takes either a plain object (string keys, standard claims mapped by name) or a `Map`, which preserves integer claim keys verbatim — profiles like `b.eat` resolve their claim names to integer labels and pass them through without the keys being stringified. The plain-object path is unchanged.
|
package/README.md
CHANGED
|
@@ -129,6 +129,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
129
129
|
- **COSE signing + encryption** — `b.cose` COSE_Sign1 sign/verify + COSE_Encrypt0 (RFC 9052) over `b.cbor`: classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; single-recipient AEAD (ChaCha20/Poly1305 default, AES-GCM opt-in) with Enc_structure-bound AAD; the signed-statement substrate under SCITT / CWT / C2PA
|
|
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
|
+
- **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
|
|
132
134
|
- **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
|
|
133
135
|
- **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
|
|
134
136
|
### Content-safety gates
|
package/index.js
CHANGED
package/lib/cose.js
CHANGED
|
@@ -63,6 +63,7 @@ var HDR_ALG = 1;
|
|
|
63
63
|
var HDR_CRIT = 2; // header label: crit
|
|
64
64
|
var HDR_CONTENT_TYPE = 3; // header label: content type
|
|
65
65
|
var HDR_KID = 4; // header label: kid
|
|
66
|
+
var HDR_CWT_CLAIMS = 15; // allow:raw-byte-literal — RFC 9597 CWT Claims header label (carries SCITT iss/sub)
|
|
66
67
|
|
|
67
68
|
// COSE algorithm identifiers. ML-DSA-87 is a NON-FINAL requested
|
|
68
69
|
// assignment (draft-ietf-cose-dilithium) — pinned deliberately, re-open
|
|
@@ -83,7 +84,7 @@ var SIGNABLE = ["ML-DSA-87", "ES256", "ES384", "ES512", "EdDSA"];
|
|
|
83
84
|
|
|
84
85
|
// Header labels this verifier understands — a `crit` entry naming any
|
|
85
86
|
// other label is refused (RFC 9052 §3.1 crit-bypass defense).
|
|
86
|
-
var UNDERSTOOD_LABELS = [HDR_ALG, HDR_CRIT, HDR_CONTENT_TYPE, HDR_KID];
|
|
87
|
+
var UNDERSTOOD_LABELS = [HDR_ALG, HDR_CRIT, HDR_CONTENT_TYPE, HDR_KID, HDR_CWT_CLAIMS];
|
|
87
88
|
|
|
88
89
|
function _toKeyObject(key, kind) {
|
|
89
90
|
if (key && typeof key === "object" && typeof key.asymmetricKeyType === "string") return key;
|
|
@@ -138,9 +139,10 @@ function _toBeSigned(protectedBstr, externalAad, payload) {
|
|
|
138
139
|
* alg: string, // "ES256" | "ES384" | "ES512" | "EdDSA" | "ML-DSA-87"
|
|
139
140
|
* privateKey: object, // matching KeyObject or PEM
|
|
140
141
|
* kid?: string, // → unprotected header label 4
|
|
141
|
-
* contentType?: number,
|
|
142
|
+
* contentType?: number|string, // → protected header label 3 (CoAP Content-Format uint or media-type string)
|
|
142
143
|
* externalAad?: Buffer, // default empty — bound into the signature
|
|
143
144
|
* unprotectedHeaders?: object, // extra unprotected map entries (numeric keys)
|
|
145
|
+
* protectedHeaders?: object, // extra INTEGRITY-PROTECTED map entries (numeric keys); label 1 (alg) is reserved
|
|
144
146
|
* }
|
|
145
147
|
*
|
|
146
148
|
* @example
|
|
@@ -150,7 +152,7 @@ function _toBeSigned(protectedBstr, externalAad, payload) {
|
|
|
150
152
|
*/
|
|
151
153
|
async function sign(payload, opts) {
|
|
152
154
|
validateOpts.requireObject(opts, "cose.sign", CoseError);
|
|
153
|
-
validateOpts(opts, ["alg", "privateKey", "kid", "contentType", "externalAad", "unprotectedHeaders"], "cose.sign");
|
|
155
|
+
validateOpts(opts, ["alg", "privateKey", "kid", "contentType", "externalAad", "unprotectedHeaders", "protectedHeaders"], "cose.sign");
|
|
154
156
|
if (SIGNABLE.indexOf(opts.alg) === -1) {
|
|
155
157
|
throw new CoseError("cose/unsignable-alg",
|
|
156
158
|
"cose.sign: alg must be one of " + SIGNABLE.join(" / ") +
|
|
@@ -165,7 +167,31 @@ async function sign(payload, opts) {
|
|
|
165
167
|
|
|
166
168
|
var protMap = new Map();
|
|
167
169
|
protMap.set(HDR_ALG, algId);
|
|
168
|
-
|
|
170
|
+
// Content type (RFC 9052 §3.1): a uint (CoAP Content-Format) or a
|
|
171
|
+
// media-type string (tstr) — a SCITT signed statement declares its
|
|
172
|
+
// payload media type as a string here.
|
|
173
|
+
if (typeof opts.contentType === "number" || typeof opts.contentType === "string") {
|
|
174
|
+
protMap.set(HDR_CONTENT_TYPE, opts.contentType);
|
|
175
|
+
}
|
|
176
|
+
// Extra integrity-protected headers (e.g. CWT_Claims label 15 for a
|
|
177
|
+
// SCITT signed statement). alg (label 1) is managed via opts.alg and
|
|
178
|
+
// cannot be overridden here — a caller that needs a different alg
|
|
179
|
+
// names it in opts.alg.
|
|
180
|
+
if (opts.protectedHeaders && typeof opts.protectedHeaders === "object") {
|
|
181
|
+
var pk = opts.protectedHeaders instanceof Map
|
|
182
|
+
? Array.from(opts.protectedHeaders.keys())
|
|
183
|
+
: Object.keys(opts.protectedHeaders);
|
|
184
|
+
for (var pi = 0; pi < pk.length; pi++) {
|
|
185
|
+
var plabel = Number(pk[pi]);
|
|
186
|
+
if (plabel === HDR_ALG) {
|
|
187
|
+
throw new CoseError("cose/reserved-header",
|
|
188
|
+
"cose.sign: protectedHeaders must not set label 1 (alg) — pass opts.alg instead");
|
|
189
|
+
}
|
|
190
|
+
var pval = opts.protectedHeaders instanceof Map
|
|
191
|
+
? opts.protectedHeaders.get(pk[pi]) : opts.protectedHeaders[pk[pi]];
|
|
192
|
+
protMap.set(plabel, pval);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
169
195
|
var protectedBstr = cbor.encode(protMap);
|
|
170
196
|
|
|
171
197
|
var unprot = new Map();
|
package/lib/scitt.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.scitt
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title SCITT signed statements
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A SCITT (Supply Chain Integrity, Transparency, and Trust) signed
|
|
9
|
+
* statement is a <code>b.cose</code> COSE_Sign1 that makes a signed,
|
|
10
|
+
* attributable claim <em>about an artifact</em> — a signed SBOM, a
|
|
11
|
+
* build attestation, a release approval. The artifact (or a hash /
|
|
12
|
+
* reference to it) is the payload; the issuer and the subject are
|
|
13
|
+
* carried in the integrity-protected <strong>CWT_Claims</strong>
|
|
14
|
+
* header (label 15, RFC 9597): <code>iss</code> (label 1) is who
|
|
15
|
+
* makes the statement, <code>sub</code> (label 2) is the artifact the
|
|
16
|
+
* statement is about. This module builds and verifies that envelope
|
|
17
|
+
* over <code>b.cose</code> + <code>b.cbor</code>.
|
|
18
|
+
*
|
|
19
|
+
* <code>b.scitt.signStatement(payload, opts)</code> produces the
|
|
20
|
+
* COSE_Sign1, placing <code>iss</code> / <code>sub</code> (plus any
|
|
21
|
+
* extra CWT claims) in the protected CWT_Claims header and declaring
|
|
22
|
+
* the payload media type as the COSE content type.
|
|
23
|
+
* <code>b.scitt.verifyStatement(statement, opts)</code> verifies the
|
|
24
|
+
* signature (delegating the mandatory algorithm allowlist to
|
|
25
|
+
* <code>b.cose.verify</code>), then enforces that a CWT_Claims header
|
|
26
|
+
* with both <code>iss</code> and <code>sub</code> is present —
|
|
27
|
+
* refusing a statement that omits the issuer/subject binding — and
|
|
28
|
+
* optionally checks them against expected values.
|
|
29
|
+
*
|
|
30
|
+
* The signing algorithms are exactly <code>b.cose</code>'s: the
|
|
31
|
+
* classical ES256/384/512 + EdDSA (final COSE ids, interoperable
|
|
32
|
+
* today) and ML-DSA-87 (PQC-forward, draft COSE id). Because the
|
|
33
|
+
* identity binding lives in the protected header it is covered by the
|
|
34
|
+
* signature and cannot be substituted without detection.
|
|
35
|
+
*
|
|
36
|
+
* <strong>Scope.</strong> This is the <em>issuer half</em> of SCITT —
|
|
37
|
+
* producing and verifying signed statements, which is buildable today
|
|
38
|
+
* on finalized RFCs (RFC 9052 COSE, RFC 9597 CWT_Claims header, RFC
|
|
39
|
+
* 8392 iss/sub). The <em>transparency receipt</em> (an inclusion proof
|
|
40
|
+
* from an append-only transparency service, COSE Receipts /
|
|
41
|
+
* draft-ietf-cose-merkle-tree-proofs) and the transparency-service
|
|
42
|
+
* registration protocol (draft-ietf-scitt-*) are deferred until those
|
|
43
|
+
* drafts publish — a signed statement produced here is the input a
|
|
44
|
+
* transparency service registers, and the receipt format is the part
|
|
45
|
+
* still in flux. Re-open on COSE-Receipts publication.
|
|
46
|
+
*
|
|
47
|
+
* @card
|
|
48
|
+
* SCITT signed statements (RFC 9052 COSE + RFC 9597 CWT_Claims) — a
|
|
49
|
+
* signed, issuer/subject-bound claim about an artifact (SBOM /
|
|
50
|
+
* attestation / approval). Composes b.cose; transparency receipts
|
|
51
|
+
* deferred to the COSE-Receipts draft.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
var cose = require("./cose");
|
|
55
|
+
var validateOpts = require("./validate-opts");
|
|
56
|
+
var { defineClass } = require("./framework-error");
|
|
57
|
+
|
|
58
|
+
var ScittError = defineClass("ScittError", { alwaysPermanent: true });
|
|
59
|
+
|
|
60
|
+
// CWT_Claims header label (RFC 9597) and the two SCITT-required claim
|
|
61
|
+
// labels inside it (RFC 8392 §3.1.1): iss = who states, sub = about what.
|
|
62
|
+
var HDR_CWT_CLAIMS = 15;
|
|
63
|
+
var CLAIM_ISS = 1;
|
|
64
|
+
var CLAIM_SUB = 2;
|
|
65
|
+
|
|
66
|
+
function _requireNonEmptyString(v, name) {
|
|
67
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
68
|
+
throw new ScittError("scitt/bad-" + name,
|
|
69
|
+
"scitt.signStatement: opts." + name + " is required and must be a non-empty string");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @primitive b.scitt.signStatement
|
|
75
|
+
* @signature b.scitt.signStatement(payload, opts)
|
|
76
|
+
* @since 0.12.37
|
|
77
|
+
* @status experimental
|
|
78
|
+
* @compliance soc2, cra
|
|
79
|
+
* @related b.scitt.verifyStatement, b.cose.sign
|
|
80
|
+
*
|
|
81
|
+
* Produce a SCITT signed statement: a COSE_Sign1 over
|
|
82
|
+
* <code>payload</code> (the artifact bytes, or a hash / reference to
|
|
83
|
+
* it) whose integrity-protected CWT_Claims header (label 15) binds the
|
|
84
|
+
* issuer (<code>iss</code>) and subject (<code>sub</code>). Declare the
|
|
85
|
+
* payload media type via <code>contentType</code> so a consumer knows
|
|
86
|
+
* how to interpret it.
|
|
87
|
+
*
|
|
88
|
+
* @opts
|
|
89
|
+
* {
|
|
90
|
+
* alg: string, // b.cose alg: "ES256" | … | "ML-DSA-87"
|
|
91
|
+
* privateKey: object, // matching KeyObject or PEM
|
|
92
|
+
* issuer: string, // → CWT_Claims iss (label 1) — who makes the statement
|
|
93
|
+
* subject: string, // → CWT_Claims sub (label 2) — the artifact the statement is about
|
|
94
|
+
* contentType?: number|string, // payload media type (e.g. "application/spdx+json")
|
|
95
|
+
* claims?: object, // extra CWT claims by integer label, merged into CWT_Claims
|
|
96
|
+
* kid?: string, // → unprotected header label 4
|
|
97
|
+
* externalAad?: Buffer, // bound into the signature
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* var stmt = await b.scitt.signStatement(sbomBytes, {
|
|
102
|
+
* alg: "ES256", privateKey: issuerKey,
|
|
103
|
+
* issuer: "https://builder.example", subject: "pkg:npm/widget@1.2.3",
|
|
104
|
+
* contentType: "application/spdx+json",
|
|
105
|
+
* });
|
|
106
|
+
*/
|
|
107
|
+
async function signStatement(payload, opts) {
|
|
108
|
+
validateOpts.requireObject(opts, "scitt.signStatement", ScittError);
|
|
109
|
+
validateOpts(opts,
|
|
110
|
+
["alg", "privateKey", "issuer", "subject", "contentType", "claims", "kid", "externalAad"],
|
|
111
|
+
"scitt.signStatement");
|
|
112
|
+
_requireNonEmptyString(opts.issuer, "issuer");
|
|
113
|
+
_requireNonEmptyString(opts.subject, "subject");
|
|
114
|
+
|
|
115
|
+
var cwtClaims = new Map();
|
|
116
|
+
cwtClaims.set(CLAIM_ISS, opts.issuer);
|
|
117
|
+
cwtClaims.set(CLAIM_SUB, opts.subject);
|
|
118
|
+
// Extra CWT claims (e.g. iat = 6, a registration-policy claim) keyed
|
|
119
|
+
// by their integer label. iss / sub are managed via opts.issuer /
|
|
120
|
+
// opts.subject and cannot be overridden here.
|
|
121
|
+
if (opts.claims && typeof opts.claims === "object") {
|
|
122
|
+
var ck = opts.claims instanceof Map ? Array.from(opts.claims.keys()) : Object.keys(opts.claims);
|
|
123
|
+
for (var i = 0; i < ck.length; i++) {
|
|
124
|
+
var label = Number(ck[i]);
|
|
125
|
+
if (!Number.isInteger(label)) {
|
|
126
|
+
throw new ScittError("scitt/bad-claim-label",
|
|
127
|
+
"scitt.signStatement: claims keys must be integer CWT claim labels");
|
|
128
|
+
}
|
|
129
|
+
if (label === CLAIM_ISS || label === CLAIM_SUB) {
|
|
130
|
+
throw new ScittError("scitt/reserved-claim",
|
|
131
|
+
"scitt.signStatement: set iss / sub via opts.issuer / opts.subject, not opts.claims");
|
|
132
|
+
}
|
|
133
|
+
var val = opts.claims instanceof Map ? opts.claims.get(ck[i]) : opts.claims[ck[i]];
|
|
134
|
+
cwtClaims.set(label, val);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var protectedHeaders = {};
|
|
139
|
+
protectedHeaders[HDR_CWT_CLAIMS] = cwtClaims;
|
|
140
|
+
|
|
141
|
+
return cose.sign(payload, {
|
|
142
|
+
alg: opts.alg,
|
|
143
|
+
privateKey: opts.privateKey,
|
|
144
|
+
kid: opts.kid,
|
|
145
|
+
contentType: opts.contentType,
|
|
146
|
+
externalAad: opts.externalAad,
|
|
147
|
+
protectedHeaders: protectedHeaders,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @primitive b.scitt.verifyStatement
|
|
153
|
+
* @signature b.scitt.verifyStatement(statement, opts)
|
|
154
|
+
* @since 0.12.37
|
|
155
|
+
* @status experimental
|
|
156
|
+
* @compliance soc2, cra
|
|
157
|
+
* @related b.scitt.signStatement, b.cose.verify
|
|
158
|
+
*
|
|
159
|
+
* Verify a SCITT signed statement and return its payload + identity
|
|
160
|
+
* binding. The COSE signature is checked through
|
|
161
|
+
* <code>b.cose.verify</code> (the algorithm allowlist is mandatory); a
|
|
162
|
+
* statement that does not carry a CWT_Claims header with both
|
|
163
|
+
* <code>iss</code> and <code>sub</code> is refused — that binding is
|
|
164
|
+
* what makes it a SCITT statement rather than a bare COSE_Sign1.
|
|
165
|
+
* <code>expectedIssuer</code> / <code>expectedSubject</code>, when
|
|
166
|
+
* given, must match.
|
|
167
|
+
*
|
|
168
|
+
* @opts
|
|
169
|
+
* {
|
|
170
|
+
* algorithms: string[], // required — accepted alg names (allowlist)
|
|
171
|
+
* publicKey?: object, // verification key (KeyObject / PEM)
|
|
172
|
+
* keyResolver?: function, // (protectedHeaders, unprotectedHeaders) → key
|
|
173
|
+
* expectedIssuer?: string, // require iss === this
|
|
174
|
+
* expectedSubject?: string, // require sub === this
|
|
175
|
+
* externalAad?: Buffer, // must match what was signed
|
|
176
|
+
* maxBytes?: number, // forwarded to b.cose.verify → b.cbor.decode
|
|
177
|
+
* maxDepth?: number,
|
|
178
|
+
* }
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* var out = await b.scitt.verifyStatement(stmt, {
|
|
182
|
+
* algorithms: ["ES256"], publicKey: issuerPub,
|
|
183
|
+
* expectedSubject: "pkg:npm/widget@1.2.3",
|
|
184
|
+
* });
|
|
185
|
+
* // → { payload: <Buffer>, issuer, subject, cwtClaims: Map, alg, protectedHeaders, unprotectedHeaders }
|
|
186
|
+
*/
|
|
187
|
+
async function verifyStatement(statement, opts) {
|
|
188
|
+
validateOpts.requireObject(opts, "scitt.verifyStatement", ScittError);
|
|
189
|
+
validateOpts(opts,
|
|
190
|
+
["algorithms", "publicKey", "keyResolver", "expectedIssuer", "expectedSubject",
|
|
191
|
+
"externalAad", "maxBytes", "maxDepth"],
|
|
192
|
+
"scitt.verifyStatement");
|
|
193
|
+
|
|
194
|
+
var out = await cose.verify(statement, {
|
|
195
|
+
algorithms: opts.algorithms,
|
|
196
|
+
publicKey: opts.publicKey,
|
|
197
|
+
keyResolver: opts.keyResolver,
|
|
198
|
+
externalAad: opts.externalAad,
|
|
199
|
+
maxBytes: opts.maxBytes,
|
|
200
|
+
maxDepth: opts.maxDepth,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
var cwtClaims = out.protectedHeaders.get(HDR_CWT_CLAIMS);
|
|
204
|
+
if (!(cwtClaims instanceof Map)) {
|
|
205
|
+
throw new ScittError("scitt/missing-cwt-claims",
|
|
206
|
+
"scitt.verifyStatement: no CWT_Claims header (label 15) — not a SCITT signed statement");
|
|
207
|
+
}
|
|
208
|
+
var issuer = cwtClaims.get(CLAIM_ISS);
|
|
209
|
+
var subject = cwtClaims.get(CLAIM_SUB);
|
|
210
|
+
if (issuer === undefined || issuer === null) {
|
|
211
|
+
throw new ScittError("scitt/missing-issuer",
|
|
212
|
+
"scitt.verifyStatement: CWT_Claims has no iss (label 1)");
|
|
213
|
+
}
|
|
214
|
+
if (subject === undefined || subject === null) {
|
|
215
|
+
throw new ScittError("scitt/missing-subject",
|
|
216
|
+
"scitt.verifyStatement: CWT_Claims has no sub (label 2)");
|
|
217
|
+
}
|
|
218
|
+
if (opts.expectedIssuer !== undefined && issuer !== opts.expectedIssuer) {
|
|
219
|
+
throw new ScittError("scitt/issuer-mismatch",
|
|
220
|
+
"scitt.verifyStatement: iss does not match expectedIssuer");
|
|
221
|
+
}
|
|
222
|
+
if (opts.expectedSubject !== undefined && subject !== opts.expectedSubject) {
|
|
223
|
+
throw new ScittError("scitt/subject-mismatch",
|
|
224
|
+
"scitt.verifyStatement: sub does not match expectedSubject");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
payload: out.payload,
|
|
229
|
+
issuer: issuer,
|
|
230
|
+
subject: subject,
|
|
231
|
+
cwtClaims: cwtClaims,
|
|
232
|
+
alg: out.alg,
|
|
233
|
+
protectedHeaders: out.protectedHeaders,
|
|
234
|
+
unprotectedHeaders: out.unprotectedHeaders,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
signStatement: signStatement,
|
|
240
|
+
verifyStatement: verifyStatement,
|
|
241
|
+
CWT_CLAIMS_LABEL: HDR_CWT_CLAIMS,
|
|
242
|
+
ScittError: ScittError,
|
|
243
|
+
};
|
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
|
]
|