@blamejs/pki 0.2.2 → 0.2.3
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 +10 -1
- package/README.md +48 -27
- package/index.js +5 -0
- package/lib/framework-error.js +11 -0
- package/lib/oid.js +14 -0
- package/lib/sigstore.js +613 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,16 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## v0.2.
|
|
7
|
+
## v0.2.3 — 2026-07-12
|
|
8
|
+
|
|
9
|
+
Offline Sigstore bundle verification joins the toolkit as pki.sigstore.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.sigstore.verifyBundle(bundle, opts) verifies a Sigstore bundle offline against caller-supplied trust material (opts.fulcioRoots, the Fulcio CA certificates; opts.rekorKeys, the Rekor log public keys, each with an optional validFor window honored against the log time so a rotated-out key is not used; optional opts.identity policy and opts.time). It returns { verified: true, payload, statement, subjects, predicateType, predicate, identity, integratedTime } on success -- payload being the raw verified envelope bytes, never a re-serialization -- and throws a typed sigstore/* error on any leg's failure. The transparency-log entry is bound to both the bundle signature and its leaf certificate, and only the v0.1-v0.3 bundle versions this release verifies are accepted (a newer version is recognized and deferred). pki.sigstore.parseBundle(input) decodes and structurally validates a bundle (object, JSON string, or Buffer) fail-closed. pki.sigstore.pae(payloadType, payloadBytes) builds the DSSE Pre-Authentication Encoding a signature covers. DSSE / Sigstore bundle v0.3 / RFC 9162 / SLSA provenance v1.
|
|
14
|
+
- The OID registry gains the Fulcio (Sigstore) certificate-extension arc 1.3.6.1.4.1.57264.1.* (the OIDC issuer, build-signer and source-repository identity claims), honoring the raw-string-vs-DER-UTF8String encoding split by member. The error taxonomy gains SigstoreError (sigstore/*): a malformed or oversize bundle (sigstore/bad-bundle), an unknown media type (sigstore/bad-bundle-version), an unsupported content arm (sigstore/unsupported-content), a DSSE signature that does not verify under the Fulcio leaf key (sigstore/dsse-verify-failed), an inclusion proof that does not reconstruct the tree root (sigstore/inclusion-proof-mismatch) or is malformed (sigstore/bad-inclusion-proof), a tree root not attested by the Rekor key (sigstore/unsigned-root), a log time not attested by the Rekor SET that signs it (sigstore/unattested-time, so an attacker cannot backdate the ephemeral Fulcio certificate into validity), a Fulcio chain that does not terminate at a caller-supplied trust anchor (sigstore/chain-incomplete) or fails validation as of the log time (sigstore/chain-invalid), an undecodable certificate identity (sigstore/bad-certificate), a malformed transparency-log entry (sigstore/bad-tlog-entry), a log entry that does not bind this signature (sigstore/entry-mismatch), an identity that fails the caller policy (sigstore/identity-mismatch), a payload that is not the expected in-toto statement (sigstore/bad-statement), and a predicateType that does not match a caller-pinned one (sigstore/predicate-mismatch). Fulcio CA anchors and Rekor keys honor their trusted-root validity windows so a rotated-out key or CA is not used, and every anchor sharing a subject DN is tried.
|
|
15
|
+
|
|
16
|
+
## v0.2.2 — 2026-07-11
|
|
8
17
|
|
|
9
18
|
Hybrid Public Key Encryption (RFC 9180) joins the toolkit as pki.hpke.
|
|
10
19
|
|
package/README.md
CHANGED
|
@@ -226,6 +226,7 @@ is callable today; nothing below is a stub.
|
|
|
226
226
|
| `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
227
227
|
| `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
|
|
228
228
|
| `pki.hpke` | Hybrid Public Key Encryption (RFC 9180) — the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS`/`setupR` establish a sender/recipient context (KEM encapsulation + HKDF key schedule); the context's `seal`/`open` AEAD-encrypt with a sequence-counter nonce and `export` derives further secrets; `seal`/`open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) × HKDF-SHA256/SHA512 × AES-GCM/ChaCha20Poly1305/export-only × all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM / X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
|
|
229
|
+
| `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key; the ephemeral Fulcio certificate chain, validated as of the Rekor log time; the RFC 9162 inclusion proof folded to a Rekor-signed tree root; the log entry bound to this exact signature; and the in-toto SLSA subject digest the caller confirms against the published artifact. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
|
|
229
230
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
230
231
|
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError`, each carrying a stable `code` in `domain/reason` form |
|
|
231
232
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
|
@@ -261,34 +262,42 @@ reintroduce the bug class it prevents. Adding a format is a schema declaration
|
|
|
261
262
|
plus a documentation comment block, not new parse logic.
|
|
262
263
|
|
|
263
264
|
```
|
|
264
|
-
┌─ Detect + route
|
|
265
|
-
│
|
|
266
|
-
|
|
265
|
+
┌─ Detect + route ─────────────────────────────────────────────────────────┐
|
|
266
|
+
│ pki.schema.parse — inspect the DER root, route to the matching sibling │
|
|
267
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
267
268
|
│
|
|
268
|
-
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ Format
|
|
269
|
-
│ x509 │ │ crl │ │ csr │ │ pkcs8 │ parsers
|
|
270
|
-
└────────┘ └────────┘ └────────┘ └────────┘ (siblings)
|
|
271
269
|
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
|
272
|
-
│
|
|
270
|
+
│ x509 │ │ crl │ │ csr │ │ pkcs8 │
|
|
273
271
|
└────────┘ └────────┘ └────────┘ └────────┘
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
│
|
|
279
|
-
|
|
280
|
-
|
|
272
|
+
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
|
273
|
+
│ cms │ │ ocsp │ │ tsp │ │ crmf │
|
|
274
|
+
└────────┘ └────────┘ └────────┘ └────────┘
|
|
275
|
+
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
|
|
276
|
+
│ pkcs12 │ │ cmp │ │ smime │ │attrcert│
|
|
277
|
+
└────────┘ └────────┘ └────────┘ └────────┘
|
|
278
|
+
│ routed DER format parsers (siblings)
|
|
279
|
+
┌─ Protocols · trust · supply chain (reached by explicit call) ───────────┐
|
|
280
|
+
│ pki.path RFC 5280 · pki.trust anchors · pki.ct SCTs · pki.hpke RFC 9180 │
|
|
281
|
+
│ pki.shbs HSS/LMS · pki.merkle RFC 9162 · pki.sigstore npm provenance │
|
|
282
|
+
│ pki.jose · pki.acme · pki.est — compose the layers below directly. │
|
|
283
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
284
|
+
│ every module composes ↓
|
|
285
|
+
┌─ Shared structure ───────────────────────────────────────────────────────┐
|
|
286
|
+
│ pki.schema.engine — walk + combinators (positional reads, tag order, │
|
|
287
|
+
│ SET-OF uniqueness, typed errors) · the PKIX sub-schemas · and the │
|
|
288
|
+
│ guard family (guard-*) — one fail-closed choke point per CVE class. │
|
|
289
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
281
290
|
│ built on ↓
|
|
282
|
-
┌─ Foundation
|
|
283
|
-
│
|
|
284
|
-
│
|
|
285
|
-
│
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
┌─ Crypto
|
|
289
|
-
│
|
|
290
|
-
│
|
|
291
|
-
|
|
291
|
+
┌─ Foundation ─────────────────────────────────────────────────────────────┐
|
|
292
|
+
│ pki.asn1 — strict bounded DER codec · pki.cbor — deterministic CBOR · │
|
|
293
|
+
│ pki.oid — two-way PQC-seeded registry · pki.errors — PkiError taxonomy │
|
|
294
|
+
│ · pki.C — version-stable LIMITS + scale constants. │
|
|
295
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
296
|
+
|
|
297
|
+
┌─ Crypto ─────────────────────────────────────────────────────────────────┐
|
|
298
|
+
│ pki.webcrypto ─▶ node:crypto — a W3C SubtleCrypto engine: the │
|
|
299
|
+
│ classical set + post-quantum ML-DSA / SLH-DSA + ML-KEM key generation. │
|
|
300
|
+
└──────────────────────────────────────────────────────────────────────────┘
|
|
292
301
|
```
|
|
293
302
|
|
|
294
303
|
**Foundation.** The strict, bounded DER codec (`pki.asn1`), the two-way OID
|
|
@@ -302,12 +311,24 @@ bounded version reader, and the single coerce → decode → walk parse-entry th
|
|
|
302
311
|
every format's `parse` is bound to. Because input coercion, the PEM size cap, and
|
|
303
312
|
the DER-decode wrapping live here once, a format cannot diverge on a guard.
|
|
304
313
|
|
|
305
|
-
**Format parsers.** `x509`, `crl`, `csr`, `pkcs8`, `cms`, `ocsp`, `tsp`,
|
|
306
|
-
|
|
307
|
-
|
|
314
|
+
**Format parsers.** `x509`, `crl`, `csr`, `pkcs8`, `cms`, `ocsp`, `tsp`, `attrcert`,
|
|
315
|
+
`crmf`, `pkcs12`, `cmp`, `smime`, and `csrattrs` are siblings: each is a schema
|
|
316
|
+
declaration composed from the shared pieces, emitting its own typed
|
|
317
|
+
`domain/reason` error codes. `pki.schema.parse` inspects a decoded root and
|
|
308
318
|
detect-and-routes to the matching sibling; the detectors are mutually exclusive by
|
|
309
319
|
construction, so routing is unambiguous regardless of registration order.
|
|
310
320
|
|
|
321
|
+
**Protocols, trust, and supply chain.** Above the format parsers sit the domain
|
|
322
|
+
modules reached by explicit call rather than DER routing: `pki.path` (RFC 5280
|
|
323
|
+
path validation), `pki.trust` (trust anchors), `pki.ct` (Certificate Transparency
|
|
324
|
+
SCTs), `pki.hpke` (RFC 9180), `pki.shbs` (HSS/LMS stateful hash signatures),
|
|
325
|
+
`pki.merkle` (RFC 9162 transparency proofs), `pki.sigstore` (offline npm-provenance
|
|
326
|
+
verification), and the `jose` / `acme` / `est` enrollment surfaces. Each composes
|
|
327
|
+
the shared structure, foundation, and crypto layers directly. Alongside the schema
|
|
328
|
+
engine, the fail-closed **guard family** (`guard-*`) centralizes each CVE-class
|
|
329
|
+
defense — detached-buffer re-view, resource caps, constant-time compares,
|
|
330
|
+
canonical-DN comparison — as one choke point a format cannot re-inline.
|
|
331
|
+
|
|
311
332
|
**Crypto.** `pki.webcrypto` is a W3C `SubtleCrypto` engine over `node:crypto`,
|
|
312
333
|
carrying the classical suite plus post-quantum ML-DSA / SLH-DSA signatures and
|
|
313
334
|
ML-KEM key generation. Sign/verify resolves algorithms through the same OID
|
package/index.js
CHANGED
|
@@ -39,6 +39,7 @@ var ct = require("./lib/ct");
|
|
|
39
39
|
var merkle = require("./lib/merkle");
|
|
40
40
|
var shbs = require("./lib/shbs");
|
|
41
41
|
var hpke = require("./lib/hpke");
|
|
42
|
+
var sigstore = require("./lib/sigstore");
|
|
42
43
|
var est = require("./lib/est");
|
|
43
44
|
var jose = require("./lib/jose");
|
|
44
45
|
var acme = require("./lib/acme");
|
|
@@ -84,6 +85,10 @@ module.exports = {
|
|
|
84
85
|
// schedule + AEAD context construction behind TLS ECH / MLS / OHTTP. Pure
|
|
85
86
|
// composition over node:crypto; the classical DHKEM suites, all four modes.
|
|
86
87
|
hpke: hpke,
|
|
88
|
+
// `sigstore` verifies a Sigstore bundle (the npm --provenance artifact): a
|
|
89
|
+
// keyless Fulcio signature over a DSSE-wrapped in-toto SLSA attestation with a
|
|
90
|
+
// Rekor inclusion proof -- offline, zero-dep, against caller-pinned trust.
|
|
91
|
+
sigstore: sigstore,
|
|
87
92
|
// `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the
|
|
88
93
|
// transport-agnostic client codecs (base64 transfer, multipart splitter),
|
|
89
94
|
// certs-only + serverkeygen validators over CMS, the enroll-attribute builders,
|
package/lib/framework-error.js
CHANGED
|
@@ -283,6 +283,16 @@ var AcmeError = defineClass("AcmeError", { withCause: true });
|
|
|
283
283
|
var TrustError = defineClass("TrustError", { withCause: true });
|
|
284
284
|
var ShbsError = defineClass("ShbsError", { withCause: true });
|
|
285
285
|
var HpkeError = defineClass("HpkeError", { withCause: true });
|
|
286
|
+
// SigstoreError -- a Sigstore bundle (npm --provenance artifact) that is not a
|
|
287
|
+
// well-formed / verifiable keyless signature: a malformed or oversize JSON
|
|
288
|
+
// bundle, an unknown media type or content arm, a DSSE envelope missing a
|
|
289
|
+
// required field or whose signature does not verify under the Fulcio leaf key, a
|
|
290
|
+
// Rekor inclusion proof that does not reconstruct the signed tree root, an
|
|
291
|
+
// unsigned/attacker-supplied root, an entry that does not bind this signature, a
|
|
292
|
+
// Fulcio identity that fails the caller policy, or a payload that is not the
|
|
293
|
+
// expected in-toto Statement. Carries the underlying leaf fault (an x509/*,
|
|
294
|
+
// merkle/*, or asn1/* error) as `.cause`.
|
|
295
|
+
var SigstoreError = defineClass("SigstoreError", { withCause: true });
|
|
286
296
|
|
|
287
297
|
module.exports = {
|
|
288
298
|
PkiError: PkiError,
|
|
@@ -308,6 +318,7 @@ module.exports = {
|
|
|
308
318
|
CtError: CtError,
|
|
309
319
|
ShbsError: ShbsError,
|
|
310
320
|
HpkeError: HpkeError,
|
|
321
|
+
SigstoreError: SigstoreError,
|
|
311
322
|
MerkleError: MerkleError,
|
|
312
323
|
SmimeError: SmimeError,
|
|
313
324
|
CsrattrsError: CsrattrsError,
|
package/lib/oid.js
CHANGED
|
@@ -131,6 +131,20 @@ var FAMILIES = {
|
|
|
131
131
|
signedCertificateTimestampList: 2, precertificatePoison: 3,
|
|
132
132
|
precertificateSigningCert: 4, ocspSignedCertificateTimestampList: 5 } },
|
|
133
133
|
|
|
134
|
+
// Fulcio (Sigstore) X.509 certificate-extension arc. `.1.1`-`.1.6` are the
|
|
135
|
+
// DEPRECATED members whose values are RAW UTF-8 strings (no DER wrapping);
|
|
136
|
+
// `.1.7` is the OtherName SAN type; `.1.8` onward are DER-encoded ASN.1
|
|
137
|
+
// UTF8String -- the decode MUST honor the raw-vs-DER split by member.
|
|
138
|
+
fulcio: { base: [1, 3, 6, 1, 4, 1, 57264, 1], of: {
|
|
139
|
+
issuerLegacy: 1, githubWorkflowTrigger: 2, githubWorkflowSha: 3,
|
|
140
|
+
githubWorkflowName: 4, githubWorkflowRepository: 5, githubWorkflowRef: 6,
|
|
141
|
+
otherName: 7, issuer: 8, buildSignerURI: 9, buildSignerDigest: 10,
|
|
142
|
+
runnerEnvironment: 11, sourceRepositoryURI: 12, sourceRepositoryDigest: 13,
|
|
143
|
+
sourceRepositoryRef: 14, sourceRepositoryIdentifier: 15,
|
|
144
|
+
sourceRepositoryOwnerURI: 16, sourceRepositoryOwnerIdentifier: 17,
|
|
145
|
+
buildConfigURI: 18, buildConfigDigest: 19, buildTrigger: 20,
|
|
146
|
+
runInvocationURI: 21, sourceRepositoryVisibilityAtSigning: 22 } },
|
|
147
|
+
|
|
134
148
|
// PKCS#1 RSA public-key + RSASSA signature algorithms.
|
|
135
149
|
rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
|
|
136
150
|
rsaEncryption: 1, rsaesOaep: 7, mgf1: 8, rsassaPss: 10,
|
package/lib/sigstore.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.sigstore
|
|
6
|
+
* @nav Supply chain
|
|
7
|
+
* @title Sigstore
|
|
8
|
+
* @intro Zero-dependency verifier for a Sigstore bundle -- the exact artifact
|
|
9
|
+
* `npm publish --provenance` produces and the npm registry serves at its
|
|
10
|
+
* attestations API. A bundle is a keyless (Fulcio) signature over a DSSE-wrapped
|
|
11
|
+
* in-toto SLSA provenance attestation, with a Rekor transparency-log inclusion
|
|
12
|
+
* proof. `verifyBundle` composes five fail-closed legs against caller-supplied
|
|
13
|
+
* trust material (the Fulcio CA roots + Rekor log keys, never trusted from the
|
|
14
|
+
* bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key;
|
|
15
|
+
* the Fulcio certificate chain, validated as of the Rekor log time (the cert is
|
|
16
|
+
* ephemeral, ~10 minutes); the Rekor inclusion proof folded to a Rekor-signed
|
|
17
|
+
* tree root; the log entry binding to this exact signature; and the in-toto
|
|
18
|
+
* subject digest the caller confirms against the published artifact. Verify-only
|
|
19
|
+
* and offline -- every input is in the bundle or a caller argument. Reuses the
|
|
20
|
+
* shipped X.509 parser, RFC 5280 path validator, RFC 9162 Merkle verifier, and
|
|
21
|
+
* native crypto engine; the net-new codecs are the DSSE PAE byte-builder and a
|
|
22
|
+
* fail-closed JSON bundle reader.
|
|
23
|
+
* @spec DSSE, Sigstore bundle v0.3, RFC 9162, SLSA provenance v1
|
|
24
|
+
* @card Verify an npm --provenance Sigstore bundle offline (DSSE + Fulcio + Rekor + SLSA).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
var nodeCrypto = require("crypto");
|
|
28
|
+
var frameworkError = require("./framework-error");
|
|
29
|
+
var constants = require("./constants");
|
|
30
|
+
var asn1 = require("./asn1-der");
|
|
31
|
+
var guard = require("./guard-all");
|
|
32
|
+
var x509 = require("./schema-x509");
|
|
33
|
+
var pathValidate = require("./path-validate");
|
|
34
|
+
var merkle = require("./merkle");
|
|
35
|
+
var oid = require("./oid");
|
|
36
|
+
|
|
37
|
+
var C = constants;
|
|
38
|
+
var SigstoreError = frameworkError.SigstoreError;
|
|
39
|
+
function _err(code, message, cause) { return new SigstoreError(code, message, cause); }
|
|
40
|
+
|
|
41
|
+
var JSON_MAX = C.LIMITS.JSON_MAX_BYTES;
|
|
42
|
+
var SP = Buffer.from(" ", "ascii");
|
|
43
|
+
var DSSEV1 = Buffer.from("DSSEv1", "ascii");
|
|
44
|
+
// The checkpoint signature-line marker is EM DASH (U+2014) + space; built at
|
|
45
|
+
// runtime so the source file stays pure ASCII.
|
|
46
|
+
var NOTE_SIG = new RegExp("^" + String.fromCharCode(0x2014) + " (\\S+) (\\S+)$");
|
|
47
|
+
|
|
48
|
+
// Parse untrusted JSON through the bounded, duplicate-member-rejecting guard.
|
|
49
|
+
function _jsonParse(input, code, label) {
|
|
50
|
+
return guard.json.parse(input, SigstoreError, {
|
|
51
|
+
maxBytes: JSON_MAX, maxDepth: 64,
|
|
52
|
+
badJson: code, tooDeep: code, duplicateMember: code, tooLarge: code, badInput: code, label: label,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- DSSE PAE (secure-systems-lab/dsse protocol.md) --------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @primitive pki.sigstore.pae
|
|
60
|
+
* @signature pki.sigstore.pae(payloadType, payloadBytes) -> Buffer
|
|
61
|
+
* @since 0.2.3
|
|
62
|
+
* @status experimental
|
|
63
|
+
* @spec DSSE
|
|
64
|
+
* @related pki.sigstore.verifyBundle
|
|
65
|
+
*
|
|
66
|
+
* The DSSE Pre-Authentication Encoding: `"DSSEv1" SP LEN(type) SP type SP
|
|
67
|
+
* LEN(body) SP body`, where `LEN` is the ASCII-decimal byte length (no leading
|
|
68
|
+
* zeros) and `type` is the UTF-8 `payloadType`. This is the exact preimage a DSSE
|
|
69
|
+
* signature covers; `LEN` is over the decoded body byte length, never the base64
|
|
70
|
+
* length -- any deviation is a signature-verify bypass.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* var b = pki.sigstore.pae("application/vnd.in-toto+json", Buffer.from("{}"));
|
|
74
|
+
* b.slice(0, 6).toString(); // "DSSEv1"
|
|
75
|
+
*/
|
|
76
|
+
function pae(payloadType, payloadBytes) {
|
|
77
|
+
if (typeof payloadType !== "string") throw new TypeError("pae: payloadType must be a string");
|
|
78
|
+
var type = Buffer.from(payloadType, "utf8");
|
|
79
|
+
var body = Buffer.isBuffer(payloadBytes) ? payloadBytes : Buffer.from(payloadBytes || []);
|
|
80
|
+
return Buffer.concat([
|
|
81
|
+
DSSEV1, SP,
|
|
82
|
+
Buffer.from(String(type.length), "ascii"), SP, type, SP,
|
|
83
|
+
Buffer.from(String(body.length), "ascii"), SP, body,
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- strict base64 decode (fail-closed, canonical) ---------------------------
|
|
88
|
+
|
|
89
|
+
// Sigstore fields are standard (padded) base64; DSSE permits URL-safe. Decode
|
|
90
|
+
// either, but reject a non-canonical re-encoding (an encoding-malleability guard).
|
|
91
|
+
function _b64(s, label) {
|
|
92
|
+
if (typeof s !== "string") throw _err("sigstore/bad-bundle", label + " must be a base64 string");
|
|
93
|
+
var urlSafe = /[-_]/.test(s);
|
|
94
|
+
var enc = urlSafe ? "base64url" : "base64";
|
|
95
|
+
var buf = Buffer.from(s, enc);
|
|
96
|
+
if (buf.toString(enc).replace(/=+$/, "") !== s.replace(/=+$/, "")) {
|
|
97
|
+
throw _err("sigstore/bad-bundle", label + " is not canonical base64");
|
|
98
|
+
}
|
|
99
|
+
return buf;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- JSON bundle reader (fail-closed) ----------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @primitive pki.sigstore.parseBundle
|
|
106
|
+
* @signature pki.sigstore.parseBundle(input) -> bundle
|
|
107
|
+
* @since 0.2.3
|
|
108
|
+
* @status experimental
|
|
109
|
+
* @spec Sigstore bundle v0.3
|
|
110
|
+
* @related pki.sigstore.verifyBundle
|
|
111
|
+
*
|
|
112
|
+
* Decode + structurally validate a Sigstore bundle (a JSON object, string, or
|
|
113
|
+
* Buffer) fail-closed: a non-object, malformed JSON, an oversize input, an
|
|
114
|
+
* unknown `mediaType`, or a missing required member throws a typed
|
|
115
|
+
* `sigstore/bad-bundle` / `sigstore/bad-bundle-version`. Returns the validated
|
|
116
|
+
* bundle object (structure only -- no cryptographic verification).
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* var b = pki.sigstore.parseBundle(bundle);
|
|
120
|
+
* b.mediaType; // "application/vnd.dev.sigstore.bundle.v0.3+json"
|
|
121
|
+
*/
|
|
122
|
+
function parseBundle(input) {
|
|
123
|
+
var obj;
|
|
124
|
+
if (Buffer.isBuffer(input) || typeof input === "string") {
|
|
125
|
+
obj = _jsonParse(input, "sigstore/bad-bundle", "bundle");
|
|
126
|
+
} else if (input && typeof input === "object") {
|
|
127
|
+
obj = input;
|
|
128
|
+
} else {
|
|
129
|
+
throw _err("sigstore/bad-bundle", "bundle must be a JSON object, string, or Buffer");
|
|
130
|
+
}
|
|
131
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) throw _err("sigstore/bad-bundle", "bundle must be a JSON object");
|
|
132
|
+
// Accept only the bundle versions this verifier actually handles (v0.1-v0.3);
|
|
133
|
+
// a newer version is recognized-and-deferred, never accepted by an over-broad
|
|
134
|
+
// match (the inline `.v0.N+json` form and the `+json;version=0.N` form).
|
|
135
|
+
var mt = obj.mediaType;
|
|
136
|
+
if (typeof mt !== "string" || !/^application\/vnd\.dev\.sigstore\.bundle(\.v0\.[123]\+json|\+json;version=0\.[123])$/.test(mt)) {
|
|
137
|
+
throw _err("sigstore/bad-bundle-version", "unsupported or unknown bundle media type: " + (mt === undefined ? "(none)" : mt));
|
|
138
|
+
}
|
|
139
|
+
if (!obj.verificationMaterial || typeof obj.verificationMaterial !== "object") {
|
|
140
|
+
throw _err("sigstore/bad-bundle", "bundle is missing verificationMaterial");
|
|
141
|
+
}
|
|
142
|
+
if (obj.messageSignature && !obj.dsseEnvelope) {
|
|
143
|
+
throw _err("sigstore/unsupported-content", "a message_signature bundle is not supported (only dsse_envelope)");
|
|
144
|
+
}
|
|
145
|
+
if (!obj.dsseEnvelope || typeof obj.dsseEnvelope !== "object") {
|
|
146
|
+
throw _err("sigstore/bad-bundle", "bundle is missing a dsseEnvelope");
|
|
147
|
+
}
|
|
148
|
+
var d = obj.dsseEnvelope;
|
|
149
|
+
if (typeof d.payload !== "string" || typeof d.payloadType !== "string" || !Array.isArray(d.signatures) || !d.signatures.length ||
|
|
150
|
+
!d.signatures[0] || typeof d.signatures[0] !== "object" || typeof d.signatures[0].sig !== "string") {
|
|
151
|
+
throw _err("sigstore/bad-dsse", "the DSSE envelope is missing a required field (payload / payloadType / signatures[].sig)");
|
|
152
|
+
}
|
|
153
|
+
return obj;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- leaf-key + signature primitives (node:crypto, sync) ---------------------
|
|
157
|
+
|
|
158
|
+
// A bundle X509Certificate element is `{ rawBytes: <base64 DER> }`; a null /
|
|
159
|
+
// non-object / non-string element fails closed, never a raw property deref.
|
|
160
|
+
function _certBytes(c, label) {
|
|
161
|
+
if (!c || typeof c !== "object" || typeof c.rawBytes !== "string") throw _err("sigstore/bad-bundle", label + " is not a { rawBytes } certificate");
|
|
162
|
+
return _b64(c.rawBytes, label);
|
|
163
|
+
}
|
|
164
|
+
function _leafCertDer(vm) {
|
|
165
|
+
if (vm.certificate) return _certBytes(vm.certificate, "verificationMaterial.certificate");
|
|
166
|
+
if (vm.x509CertificateChain && Array.isArray(vm.x509CertificateChain.certificates) && vm.x509CertificateChain.certificates.length) {
|
|
167
|
+
return _certBytes(vm.x509CertificateChain.certificates[0], "verificationMaterial.x509CertificateChain[0]");
|
|
168
|
+
}
|
|
169
|
+
throw _err("sigstore/bad-bundle", "bundle has no Fulcio certificate (public_key bundles are not supported)");
|
|
170
|
+
}
|
|
171
|
+
function _chainDers(vm) {
|
|
172
|
+
if (vm.x509CertificateChain && Array.isArray(vm.x509CertificateChain.certificates)) {
|
|
173
|
+
return vm.x509CertificateChain.certificates.map(function (c, i) { return _certBytes(c, "x509CertificateChain[" + i + "]"); });
|
|
174
|
+
}
|
|
175
|
+
return [_leafCertDer(vm)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verify a DER ECDSA / Ed25519 signature over `data` under a node public key.
|
|
179
|
+
function _rawVerify(keyObj, data, derSig) {
|
|
180
|
+
var t = keyObj.asymmetricKeyType;
|
|
181
|
+
if (t === "ed25519" || t === "ed448") return nodeCrypto.verify(null, data, keyObj, derSig);
|
|
182
|
+
var crv = keyObj.asymmetricKeyDetails && keyObj.asymmetricKeyDetails.namedCurve;
|
|
183
|
+
var hash = crv === "secp384r1" ? "sha384" : (crv === "secp521r1" ? "sha512" : "sha256");
|
|
184
|
+
return nodeCrypto.verify(hash, data, { key: keyObj, dsaEncoding: "der" }, derSig);
|
|
185
|
+
}
|
|
186
|
+
function _pubFromSpki(spkiDer, label) {
|
|
187
|
+
try { return nodeCrypto.createPublicKey({ key: spkiDer, format: "der", type: "spki" }); }
|
|
188
|
+
catch (e) { throw _err("sigstore/bad-key", "invalid " + label + " public key", e); }
|
|
189
|
+
}
|
|
190
|
+
// Parse a certificate, re-typing a malformed-DER fault to a sigstore/* error so
|
|
191
|
+
// the caller never sees a raw certificate/* / asn1/* leak from this boundary.
|
|
192
|
+
function _parseCert(der, label) {
|
|
193
|
+
try { return x509.parse(der); }
|
|
194
|
+
catch (e) { throw _err("sigstore/bad-certificate", "invalid " + label, e); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- Rekor leg (RFC 9162 inclusion + Rekor-signed root + entry binding) -------
|
|
198
|
+
|
|
199
|
+
function _sha256(buf) { return nodeCrypto.createHash("sha256").update(buf).digest(); }
|
|
200
|
+
|
|
201
|
+
// Select the caller Rekor key whose logId.keyId matches the entry's logId (full
|
|
202
|
+
// 32-byte id) or a 4-byte checkpoint keyhint. Never trust a key from the bundle.
|
|
203
|
+
// A key carrying a validFor window is used only if `timeMs` (the entry's log
|
|
204
|
+
// time) falls inside it -- a rotated-out key must not verify a later entry.
|
|
205
|
+
// A validFor bound is an epoch-ms number, a Date, or an ISO-8601 string (the form
|
|
206
|
+
// a Sigstore trusted_root carries). Returns null for absent/unparseable.
|
|
207
|
+
function _toMs(x) {
|
|
208
|
+
if (x == null) return null;
|
|
209
|
+
if (typeof x === "number") return Number.isFinite(x) ? x : null;
|
|
210
|
+
if (x instanceof Date) { var d = x.getTime(); return isNaN(d) ? null : d; }
|
|
211
|
+
if (typeof x === "string") { var t = Date.parse(x); return isNaN(t) ? null : t; }
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
function _inWindow(timeMs, vf) {
|
|
215
|
+
if (!vf) return true;
|
|
216
|
+
// A present-but-unparseable bound fails closed (the key/CA is not used) rather
|
|
217
|
+
// than silently disabling the window it configures.
|
|
218
|
+
if (vf.start != null) { var s = _toMs(vf.start); if (s === null || !(timeMs >= s)) return false; }
|
|
219
|
+
if (vf.end != null) { var e = _toMs(vf.end); if (e === null || !(timeMs <= e)) return false; }
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
function _rekorKey(rekorKeys, keyIdBuf, hint, timeMs) {
|
|
223
|
+
for (var i = 0; i < rekorKeys.length; i++) {
|
|
224
|
+
var k = rekorKeys[i];
|
|
225
|
+
var match = (keyIdBuf && k.keyId && k.keyId.equals(keyIdBuf)) ||
|
|
226
|
+
(hint && k.keyId && k.keyId.length >= 4 && k.keyId.subarray(0, 4).equals(hint));
|
|
227
|
+
if (match && _inWindow(timeMs, k.validFor)) return k;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Verify the checkpoint (C2SP signed-note): ECDSA-P256-SHA256 over the note body
|
|
233
|
+
// (bytes up to and including the newline before the blank-line separator).
|
|
234
|
+
function _verifyCheckpoint(envelope, ip, rekorKeys, timeMs) {
|
|
235
|
+
var buf = Buffer.from(envelope, "utf8");
|
|
236
|
+
var sep = buf.indexOf("\n\n");
|
|
237
|
+
if (sep < 0) throw _err("sigstore/bad-checkpoint", "the checkpoint has no note/signature separator");
|
|
238
|
+
var body = buf.subarray(0, sep + 1);
|
|
239
|
+
var lines = body.toString("utf8").split("\n");
|
|
240
|
+
// The note body's third line is the base64 tree root; it MUST equal the proof root.
|
|
241
|
+
if (_b64(lines[2], "checkpoint root").toString("hex") !== _b64(ip.rootHash, "inclusionProof.rootHash").toString("hex")) {
|
|
242
|
+
throw _err("sigstore/inclusion-proof-mismatch", "the checkpoint root does not match the inclusion-proof root");
|
|
243
|
+
}
|
|
244
|
+
var sigBlock = buf.subarray(sep + 2).toString("utf8").split("\n");
|
|
245
|
+
for (var i = 0; i < sigBlock.length; i++) {
|
|
246
|
+
var m = sigBlock[i].match(NOTE_SIG);
|
|
247
|
+
if (!m) continue;
|
|
248
|
+
var blob = _b64(m[2], "checkpoint signature");
|
|
249
|
+
if (blob.length < 5) continue;
|
|
250
|
+
var hint = blob.subarray(0, 4), derSig = blob.subarray(4);
|
|
251
|
+
var k = _rekorKey(rekorKeys, null, hint, timeMs);
|
|
252
|
+
if (!k) continue;
|
|
253
|
+
if (_rawVerify(_pubFromSpki(k.spki, "Rekor log"), body, derSig)) return true;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Verify the SET (inclusionPromise.signedEntryTimestamp): ECDSA over the RFC 8785
|
|
259
|
+
// canonical JSON { body, integratedTime, logID, logIndex } (keys sorted).
|
|
260
|
+
function _verifySet(te, rekorKeys, timeMs) {
|
|
261
|
+
var promise = te.inclusionPromise;
|
|
262
|
+
if (!promise || typeof promise.signedEntryTimestamp !== "string") return false;
|
|
263
|
+
var keyId = te.logId && te.logId.keyId ? _b64(te.logId.keyId, "logId.keyId") : null;
|
|
264
|
+
var k = _rekorKey(rekorKeys, keyId, null, timeMs);
|
|
265
|
+
if (!k) return false;
|
|
266
|
+
var canonical = JSON.stringify({
|
|
267
|
+
body: te.canonicalizedBody,
|
|
268
|
+
integratedTime: Number(te.integratedTime),
|
|
269
|
+
logID: keyId.toString("hex"),
|
|
270
|
+
logIndex: Number(te.logIndex),
|
|
271
|
+
});
|
|
272
|
+
var sig = _b64(promise.signedEntryTimestamp, "signedEntryTimestamp");
|
|
273
|
+
return _rawVerify(_pubFromSpki(k.spki, "Rekor log"), Buffer.from(canonical, "utf8"), sig);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Bind the tlog entry to THIS bundle's signature: the dsse-kind body's embedded
|
|
277
|
+
// signature + payload hash must be the envelope's (RFC 9162 proves the body is
|
|
278
|
+
// logged; this proves the body is OUR envelope, not some other logged entry).
|
|
279
|
+
function _bindEntry(te, envelope, leafDer) {
|
|
280
|
+
var body = _jsonParse(_b64(te.canonicalizedBody, "canonicalizedBody"), "sigstore/bad-tlog-entry", "Rekor entry body");
|
|
281
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw _err("sigstore/bad-tlog-entry", "the Rekor entry body is not a JSON object");
|
|
282
|
+
var spec = body.spec;
|
|
283
|
+
if (body.kind !== "dsse" || !spec || typeof spec !== "object" || !Array.isArray(spec.signatures) || !spec.signatures.length ||
|
|
284
|
+
!spec.signatures[0] || typeof spec.signatures[0] !== "object" || typeof spec.signatures[0].signature !== "string") {
|
|
285
|
+
throw _err("sigstore/unsupported-content", "unsupported or malformed Rekor entry: " + body.kind);
|
|
286
|
+
}
|
|
287
|
+
var envSig = _b64(envelope.signatures[0].sig, "dsseEnvelope.signatures[0].sig");
|
|
288
|
+
var bodySig = _b64(spec.signatures[0].signature, "Rekor entry signature");
|
|
289
|
+
if (!envSig.equals(bodySig)) throw _err("sigstore/entry-mismatch", "the Rekor entry signature does not match the bundle signature");
|
|
290
|
+
var payload = _b64(envelope.payload, "dsseEnvelope.payload");
|
|
291
|
+
// payloadHash is a required binding field: bind it to the envelope payload
|
|
292
|
+
// rather than skipping the check when it is absent.
|
|
293
|
+
if (!spec.payloadHash || typeof spec.payloadHash.value !== "string") throw _err("sigstore/bad-tlog-entry", "the Rekor dsse entry is missing its payloadHash");
|
|
294
|
+
if (spec.payloadHash.value !== _sha256(payload).toString("hex")) {
|
|
295
|
+
throw _err("sigstore/entry-mismatch", "the Rekor entry payloadHash does not match the bundle payload");
|
|
296
|
+
}
|
|
297
|
+
// The dsse entry's verifier certificate is REQUIRED and MUST be the bundle's
|
|
298
|
+
// leaf cert -- otherwise a valid inclusion proof for an entry signed by a
|
|
299
|
+
// DIFFERENT Fulcio cert could be paired with this envelope (the signature match
|
|
300
|
+
// alone leaves cert substitution).
|
|
301
|
+
var vf = spec.signatures[0].verifier;
|
|
302
|
+
if (typeof vf !== "string") throw _err("sigstore/bad-tlog-entry", "the Rekor dsse entry is missing its verifier certificate");
|
|
303
|
+
var vDer;
|
|
304
|
+
try { vDer = x509.pemDecode(_b64(vf, "Rekor entry verifier").toString("utf8"), "CERTIFICATE"); }
|
|
305
|
+
catch (e) { throw _err("sigstore/bad-tlog-entry", "the Rekor entry verifier is not a valid certificate", e); }
|
|
306
|
+
if (!vDer.equals(leafDer)) throw _err("sigstore/entry-mismatch", "the Rekor entry verifier certificate does not match the bundle leaf certificate");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _verifyRekor(te, envelope, rekorKeys, leafDer) {
|
|
310
|
+
var ip = te.inclusionProof;
|
|
311
|
+
if (!ip || typeof ip.rootHash !== "string" || !Array.isArray(ip.hashes)) {
|
|
312
|
+
throw _err("sigstore/bad-inclusion-proof", "the transparency-log entry has no inclusion proof");
|
|
313
|
+
}
|
|
314
|
+
// The entry's CLAIMED log time selects which caller Rekor key is in its validity
|
|
315
|
+
// window; the SET (required below) re-confirms it by signing it.
|
|
316
|
+
var claimedSec = Number(te.integratedTime);
|
|
317
|
+
var claimedMs = Number.isFinite(claimedSec) ? C.TIME.seconds(claimedSec) : NaN;
|
|
318
|
+
// Bind the entry to this signature FIRST (a proof for another entry is not evidence).
|
|
319
|
+
_bindEntry(te, envelope, leafDer);
|
|
320
|
+
// Fold the audit path to the tree root (RFC 9162), reusing pki.merkle.
|
|
321
|
+
var included;
|
|
322
|
+
try {
|
|
323
|
+
included = merkle.verifyInclusion({
|
|
324
|
+
leafIndex: BigInt(ip.logIndex),
|
|
325
|
+
treeSize: BigInt(ip.treeSize),
|
|
326
|
+
leafHash: merkle.leafHash(_b64(te.canonicalizedBody, "canonicalizedBody")),
|
|
327
|
+
proof: ip.hashes.map(function (h, i) { return _b64(h, "inclusionProof.hashes[" + i + "]"); }),
|
|
328
|
+
rootHash: _b64(ip.rootHash, "inclusionProof.rootHash"),
|
|
329
|
+
});
|
|
330
|
+
} catch (e) {
|
|
331
|
+
throw _err("sigstore/bad-inclusion-proof", "the inclusion proof is malformed: " + e.message, e);
|
|
332
|
+
}
|
|
333
|
+
if (!included) throw _err("sigstore/inclusion-proof-mismatch", "the inclusion proof does not reconstruct the tree root");
|
|
334
|
+
// The inclusion proof above only reconstructs the attacker-supplied
|
|
335
|
+
// inclusionProof.rootHash; that root is trust ONLY once the Rekor-signed
|
|
336
|
+
// checkpoint (which signs the tree root) verifies over it -- so the checkpoint
|
|
337
|
+
// is REQUIRED, or the inclusion check is theater against an unsigned root.
|
|
338
|
+
var checkpointOk = ip.checkpoint && typeof ip.checkpoint.envelope === "string" && _verifyCheckpoint(ip.checkpoint.envelope, ip, rekorKeys, claimedMs);
|
|
339
|
+
if (!checkpointOk) throw _err("sigstore/unsigned-root", "the Rekor checkpoint (the signed tree root) did not verify under the caller Rekor key -- the inclusion-proof root is not attested");
|
|
340
|
+
// The integratedTime dates the ephemeral (~10-min) Fulcio cert, so it too must
|
|
341
|
+
// be Rekor-attested: ONLY the SET signs it (the checkpoint signs the root, not
|
|
342
|
+
// the time). Without a verified SET the time is attacker-controlled and could
|
|
343
|
+
// date an expired cert into validity -- require it (the RFC 3161 timestamp
|
|
344
|
+
// source is the deferred alternative).
|
|
345
|
+
var setOk = _verifySet(te, rekorKeys, claimedMs);
|
|
346
|
+
if (!setOk) throw _err("sigstore/unattested-time", "the Rekor SET (the signed source of integratedTime) did not verify -- the log time is not attested and cannot date the Fulcio certificate");
|
|
347
|
+
var t = Number(te.integratedTime);
|
|
348
|
+
if (!Number.isFinite(t) || t < 0) throw _err("sigstore/bad-tlog-entry", "the transparency-log entry has a malformed integratedTime");
|
|
349
|
+
return t;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- Fulcio chain leg (RFC 5280 path validation, as-of the log time) ----------
|
|
353
|
+
|
|
354
|
+
function _dn(cert, which) { return cert[which] && cert[which].dn; }
|
|
355
|
+
|
|
356
|
+
// Normalize a caller Fulcio root: a raw DER Buffer, or { der|rawBytes, validFor }
|
|
357
|
+
// (the shape carried by a Sigstore trusted_root certificateAuthorities entry).
|
|
358
|
+
function _normRoots(fulcioRoots) {
|
|
359
|
+
return fulcioRoots.map(function (r, i) {
|
|
360
|
+
var der = Buffer.isBuffer(r) ? r : (r && (r.der || r.rawBytes));
|
|
361
|
+
if (!Buffer.isBuffer(der)) throw _err("sigstore/bad-input", "fulcioRoots[" + i + "] must be a DER Buffer or { der, validFor }");
|
|
362
|
+
return { cert: _parseCert(der, "Fulcio CA root [" + i + "]"), validFor: (r && r.validFor) || null };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Build the ordered path body [intermediates..., leaf] from the leaf up to (not
|
|
367
|
+
// including) a cert issued by `anchor`, drawing intermediates from `intBySubject`
|
|
368
|
+
// (caller certs AND bundle links -- all get cryptographically re-checked by
|
|
369
|
+
// path.validate). `anchor` itself is never placed in the body. Returns null if no
|
|
370
|
+
// such path exists. The terminal trust anchor is always a caller cert; a bundle
|
|
371
|
+
// cert can appear only here, as a non-terminal path step.
|
|
372
|
+
function _pathToAnchor(leaf, anchor, intBySubject) {
|
|
373
|
+
var anchorSubj = _dn(anchor, "subject");
|
|
374
|
+
var path = [leaf], cur = leaf, seen = {}, guardN = 0;
|
|
375
|
+
while (guardN++ < 16) {
|
|
376
|
+
var issuerDn = _dn(cur, "issuer");
|
|
377
|
+
if (issuerDn === anchorSubj) return path;
|
|
378
|
+
var cands = intBySubject[issuerDn] || [];
|
|
379
|
+
var next = null;
|
|
380
|
+
for (var j = 0; j < cands.length; j++) {
|
|
381
|
+
if (cands[j] === anchor || seen[_dn(cands[j], "subject")]) continue;
|
|
382
|
+
next = cands[j]; break;
|
|
383
|
+
}
|
|
384
|
+
if (!next) return null;
|
|
385
|
+
seen[_dn(next, "subject")] = 1;
|
|
386
|
+
path.unshift(next);
|
|
387
|
+
cur = next;
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function _verifyChain(leaf, chainDers, fulcioRoots, timeMs) {
|
|
393
|
+
var roots = _normRoots(fulcioRoots);
|
|
394
|
+
var bundleLinks = chainDers.slice(1).map(function (d, i) { return _parseCert(d, "bundle intermediate [" + i + "]"); });
|
|
395
|
+
// Path-body intermediates: caller certs (trusted) + bundle intermediates (path
|
|
396
|
+
// steps only). The terminal anchor is chosen ONLY from the caller roots below.
|
|
397
|
+
var intBySubject = {};
|
|
398
|
+
function addInt(c) { (intBySubject[_dn(c, "subject")] = intBySubject[_dn(c, "subject")] || []).push(c); }
|
|
399
|
+
roots.forEach(function (r) { addInt(r.cert); });
|
|
400
|
+
bundleLinks.forEach(addInt);
|
|
401
|
+
// Try each caller anchor within its validity window (the trusted_root carries
|
|
402
|
+
// several CA rotations, some sharing a subject DN); accept the first that both
|
|
403
|
+
// yields a path and cryptographically validates as of the log time.
|
|
404
|
+
var candidates = roots.filter(function (r) { return _inWindow(timeMs, r.validFor); });
|
|
405
|
+
var lastErr = _err("sigstore/chain-incomplete", "no caller-supplied Fulcio anchor (within its validity window) issues the chain");
|
|
406
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
407
|
+
var anchor = candidates[i].cert;
|
|
408
|
+
var path = _pathToAnchor(leaf, anchor, intBySubject);
|
|
409
|
+
if (!path) continue;
|
|
410
|
+
var spki = anchor.subjectPublicKeyInfo;
|
|
411
|
+
try {
|
|
412
|
+
var res = await pathValidate.validate(path, {
|
|
413
|
+
time: new Date(timeMs),
|
|
414
|
+
historicalMode: true,
|
|
415
|
+
trustAnchor: { name: anchor.subject, publicKey: spki.bytes, algorithm: spki.algorithm.oid, parameters: spki.algorithm.parameters },
|
|
416
|
+
requiredEku: ["codeSigning"],
|
|
417
|
+
});
|
|
418
|
+
if (res.valid) return;
|
|
419
|
+
lastErr = _err("sigstore/chain-invalid", "the Fulcio certificate chain is not valid as of the log time");
|
|
420
|
+
} catch (e) {
|
|
421
|
+
lastErr = _err("sigstore/chain-invalid", "the Fulcio certificate chain failed validation: " + e.message, e);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
throw lastErr;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---- Fulcio identity leg -----------------------------------------------------
|
|
428
|
+
|
|
429
|
+
// Decode the identity the Fulcio cert commits to: the SAN plus the Fulcio
|
|
430
|
+
// extension arc (Issuer .1.8, SourceRepositoryURI .1.12, etc). `.1.8`+ values are
|
|
431
|
+
// DER UTF8String; the legacy `.1.1`-`.1.6` are raw strings (never DER).
|
|
432
|
+
// Derive the OIDs from the registry (never a dotted-decimal literal in source):
|
|
433
|
+
// the SAN, and the Fulcio arc base (the parent of the `.7` otherName member).
|
|
434
|
+
var SAN_OID = oid.byName("subjectAltName");
|
|
435
|
+
var FULCIO_PREFIX = oid.byName("otherName").split(".").slice(0, -1).join(".") + ".";
|
|
436
|
+
function _identity(leaf) {
|
|
437
|
+
var out = { san: null, extensions: {} };
|
|
438
|
+
// The identity is security-relevant: an undecodable SAN / Fulcio member is a
|
|
439
|
+
// malformed certificate and fails closed with a named reason (never a silent
|
|
440
|
+
// drop that would hide an identity claim a caller policy relies on).
|
|
441
|
+
try {
|
|
442
|
+
(leaf.extensions || []).forEach(function (ext) {
|
|
443
|
+
if (ext.oid === SAN_OID) { out.san = _sanValue(ext); return; }
|
|
444
|
+
if (ext.oid.indexOf(FULCIO_PREFIX) === 0) {
|
|
445
|
+
out.extensions[ext.name || ext.oid] = _fulcioExtValue(ext, Number(ext.oid.slice(FULCIO_PREFIX.length)));
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
} catch (e) {
|
|
449
|
+
if (e instanceof SigstoreError) throw e;
|
|
450
|
+
throw _err("sigstore/bad-certificate", "the Fulcio certificate identity could not be decoded", e);
|
|
451
|
+
}
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
// The SAN GeneralNames the OIDC identity is carried in: a context-tagged
|
|
455
|
+
// [1] rfc822Name / [2] dNSName / [6] uniformResourceIdentifier. Return the URI
|
|
456
|
+
// (a machine identity) preferentially, else the first name, with its type.
|
|
457
|
+
var GN_TYPE = { 1: "rfc822Name", 2: "dNSName", 6: "uri" };
|
|
458
|
+
function _sanValue(ext) {
|
|
459
|
+
var seq = asn1.decode(ext.value);
|
|
460
|
+
var names = [];
|
|
461
|
+
for (var i = 0; i < (seq.children || []).length; i++) {
|
|
462
|
+
var n = seq.children[i];
|
|
463
|
+
if (n.tagClass !== "context") continue;
|
|
464
|
+
var val = null;
|
|
465
|
+
if (!n.constructed && GN_TYPE[n.tagNumber]) {
|
|
466
|
+
// A primitive IMPLICIT IA5String name: rfc822Name / dNSName / URI.
|
|
467
|
+
val = { type: GN_TYPE[n.tagNumber], value: asn1.read.octetStringImplicit(n, n.tagNumber).toString("utf8") };
|
|
468
|
+
} else if (n.constructed && n.tagNumber === 0 && (n.children || []).length >= 2) {
|
|
469
|
+
// otherName ::= SEQUENCE { type-id OID, [0] EXPLICIT value } -- Fulcio carries
|
|
470
|
+
// machine identities here (type-id .1.7, value a UTF8String).
|
|
471
|
+
var inner = (n.children[1].children || [])[0] || n.children[1];
|
|
472
|
+
var v;
|
|
473
|
+
try { v = asn1.read.string(inner); } catch (_e) { v = Buffer.from(inner.content || []).toString("utf8"); }
|
|
474
|
+
val = { type: "otherName", oid: asn1.read.oid(n.children[0]), value: v };
|
|
475
|
+
}
|
|
476
|
+
if (val) names.push(val);
|
|
477
|
+
}
|
|
478
|
+
// A Fulcio certificate binds exactly one identity; more than one SAN lets a
|
|
479
|
+
// mis-issued cert pair the expected identity with a smuggled extra one, so a
|
|
480
|
+
// caller policy that matches the wrong entry cannot be relied on -- reject.
|
|
481
|
+
if (names.length > 1) throw _err("sigstore/bad-certificate", "the Fulcio certificate carries multiple SAN identities");
|
|
482
|
+
return names[0] || null;
|
|
483
|
+
}
|
|
484
|
+
function _fulcioExtValue(ext, leafArc) {
|
|
485
|
+
if (!Buffer.isBuffer(ext.value)) throw _err("sigstore/bad-certificate", "Fulcio extension " + ext.oid + " has no value");
|
|
486
|
+
// The raw-vs-DER split by member (Fulcio oid-info): .1-.6 legacy are raw UTF-8
|
|
487
|
+
// strings; .8+ are DER UTF8String -- the arc is open-ended past .22. An
|
|
488
|
+
// undecodable member throws (caught by _identity) rather than dropping silently.
|
|
489
|
+
if (leafArc >= 1 && leafArc <= 6) return ext.value.toString("utf8");
|
|
490
|
+
return asn1.read.string(asn1.decode(ext.value));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function _checkIdentity(id, policy) {
|
|
494
|
+
if (!policy) return;
|
|
495
|
+
var sanValue = id.san && id.san.value;
|
|
496
|
+
if (policy.san && sanValue !== policy.san) throw _err("sigstore/identity-mismatch", "the certificate SAN " + JSON.stringify(sanValue) + " does not match the expected identity");
|
|
497
|
+
// The OIDC issuer is carried by the current Issuer V2 (.1.8) or, on older certs,
|
|
498
|
+
// only by the deprecated raw-string issuer (.1.1); match against either.
|
|
499
|
+
if (policy.issuer && policy.issuer !== id.extensions.issuer && policy.issuer !== id.extensions.issuerLegacy) throw _err("sigstore/identity-mismatch", "the certificate OIDC issuer does not match the expected issuer");
|
|
500
|
+
if (policy.sourceRepositoryURI && id.extensions.sourceRepositoryURI !== policy.sourceRepositoryURI) throw _err("sigstore/identity-mismatch", "the certificate source-repository URI does not match");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---- in-toto Statement leg ---------------------------------------------------
|
|
504
|
+
|
|
505
|
+
function _statement(payload, payloadType, expectedPredicate) {
|
|
506
|
+
if (payloadType !== "application/vnd.in-toto+json") {
|
|
507
|
+
throw _err("sigstore/bad-statement", "unsupported DSSE payloadType: " + payloadType);
|
|
508
|
+
}
|
|
509
|
+
var st = _jsonParse(payload, "sigstore/bad-statement", "in-toto statement");
|
|
510
|
+
if (!st || typeof st !== "object" || st._type !== "https://in-toto.io/Statement/v1") throw _err("sigstore/bad-statement", "the payload is not an in-toto Statement v1");
|
|
511
|
+
if (!Array.isArray(st.subject) || !st.subject.length) throw _err("sigstore/bad-statement", "the in-toto statement has no subject");
|
|
512
|
+
// The verdict carries predicateType for the caller's own gate; when the caller
|
|
513
|
+
// pins one (opts.predicateType), a mismatch fails closed so a non-SLSA
|
|
514
|
+
// attestation (e.g. an SBOM) is not mistaken for the expected provenance.
|
|
515
|
+
if (expectedPredicate && st.predicateType !== expectedPredicate) {
|
|
516
|
+
throw _err("sigstore/predicate-mismatch", "the statement predicateType " + JSON.stringify(st.predicateType) + " does not match the expected " + JSON.stringify(expectedPredicate));
|
|
517
|
+
}
|
|
518
|
+
return st;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- orchestrator ------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @primitive pki.sigstore.verifyBundle
|
|
525
|
+
* @signature pki.sigstore.verifyBundle(bundle, opts) -> Promise<result>
|
|
526
|
+
* @since 0.2.3
|
|
527
|
+
* @status experimental
|
|
528
|
+
* @spec DSSE, Sigstore bundle v0.3, RFC 9162, SLSA provenance v1
|
|
529
|
+
* @related pki.sigstore.parseBundle, pki.sigstore.pae
|
|
530
|
+
*
|
|
531
|
+
* Verify a Sigstore bundle (an npm `--provenance` artifact) offline against
|
|
532
|
+
* caller-supplied trust material, composing five fail-closed legs: the DSSE
|
|
533
|
+
* signature over its PAE under the Fulcio leaf key; the Fulcio chain validated as
|
|
534
|
+
* of the Rekor log time; the Rekor inclusion proof folded to a Rekor-signed root;
|
|
535
|
+
* the log entry bound to this exact signature; and the in-toto SLSA statement.
|
|
536
|
+
* Any leg failing throws a typed `sigstore/*` error. On success returns
|
|
537
|
+
* `{ verified: true, payload, statement, subjects, predicateType, predicate,
|
|
538
|
+
* identity, integratedTime }` -- `payload` is the RAW verified envelope bytes
|
|
539
|
+
* (never a re-serialization), and the caller confirms a `subjects[].digest`
|
|
540
|
+
* matches the published artifact.
|
|
541
|
+
*
|
|
542
|
+
* @opts
|
|
543
|
+
* fulcioRoots: Array, // the Fulcio CA anchors: a DER Buffer or { der, validFor } each
|
|
544
|
+
* rekorKeys: Array, // [{ keyId, spki, validFor? }] the Rekor log public keys
|
|
545
|
+
* identity: object, // optional policy: { san, issuer, sourceRepositoryURI }
|
|
546
|
+
* predicateType: string, // optional: require this in-toto predicateType (e.g. the SLSA URI)
|
|
547
|
+
* time: Date, // optional check-date override (default: the Rekor integratedTime)
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* var out = await pki.sigstore.verifyBundle(bundle, sigstoreTrust);
|
|
551
|
+
* out.verified; // true
|
|
552
|
+
* out.subjects[0].digest; // { sha512: "..." } -- confirm against your tarball
|
|
553
|
+
*/
|
|
554
|
+
async function verifyBundle(bundle, opts) {
|
|
555
|
+
if (bundle === null || typeof bundle !== "object" && typeof bundle !== "string" && !Buffer.isBuffer(bundle)) {
|
|
556
|
+
throw new TypeError("verifyBundle: bundle must be an object, JSON string, or Buffer");
|
|
557
|
+
}
|
|
558
|
+
opts = opts || {};
|
|
559
|
+
var b = parseBundle(bundle);
|
|
560
|
+
var vm = b.verificationMaterial;
|
|
561
|
+
var env = b.dsseEnvelope;
|
|
562
|
+
var rekorKeys = opts.rekorKeys || [];
|
|
563
|
+
var fulcioRoots = opts.fulcioRoots || [];
|
|
564
|
+
|
|
565
|
+
// Leg 1 -- DSSE signature over PAE under the Fulcio leaf public key.
|
|
566
|
+
var leafDer = _leafCertDer(vm);
|
|
567
|
+
var leaf = _parseCert(leafDer, "Fulcio leaf certificate");
|
|
568
|
+
var payload = _b64(env.payload, "dsseEnvelope.payload");
|
|
569
|
+
var preimage = pae(env.payloadType, payload);
|
|
570
|
+
var derSig = _b64(env.signatures[0].sig, "dsseEnvelope.signatures[0].sig");
|
|
571
|
+
var leafKey = _pubFromSpki(leaf.subjectPublicKeyInfo.bytes, "Fulcio leaf");
|
|
572
|
+
if (!_rawVerify(leafKey, preimage, derSig)) throw _err("sigstore/dsse-verify-failed", "the DSSE signature does not verify under the Fulcio leaf key");
|
|
573
|
+
|
|
574
|
+
// Leg 2 -- Rekor inclusion + Rekor-signed root + entry binding (yields the
|
|
575
|
+
// trusted time). Try every transparency-log entry; accept the first that fully
|
|
576
|
+
// verifies (and binds this signature), rejecting only if none do.
|
|
577
|
+
var tlogs = Array.isArray(vm.tlogEntries) ? vm.tlogEntries : [];
|
|
578
|
+
if (!tlogs.length) throw _err("sigstore/bad-bundle", "a keyless bundle requires at least one transparency-log entry");
|
|
579
|
+
var integratedTime = null, lastErr = null;
|
|
580
|
+
for (var ti = 0; ti < tlogs.length; ti++) {
|
|
581
|
+
if (!tlogs[ti] || typeof tlogs[ti] !== "object") { lastErr = _err("sigstore/bad-bundle", "a transparency-log entry is not an object"); continue; }
|
|
582
|
+
try { integratedTime = _verifyRekor(tlogs[ti], env, rekorKeys, leafDer); break; }
|
|
583
|
+
catch (e) { lastErr = e; }
|
|
584
|
+
}
|
|
585
|
+
if (integratedTime === null) throw lastErr;
|
|
586
|
+
|
|
587
|
+
// Leg 3 -- Fulcio chain, validated as of the (trusted) log time; then identity.
|
|
588
|
+
var checkTime = (opts.time instanceof Date) ? opts.time.getTime() : C.TIME.seconds(integratedTime);
|
|
589
|
+
await _verifyChain(leaf, _chainDers(vm), fulcioRoots, checkTime);
|
|
590
|
+
var identity = _identity(leaf);
|
|
591
|
+
_checkIdentity(identity, opts.identity);
|
|
592
|
+
|
|
593
|
+
// Leg 4 -- the in-toto SLSA statement + subject binding (with an optional
|
|
594
|
+
// caller-pinned predicateType).
|
|
595
|
+
var st = _statement(payload, env.payloadType, opts.predicateType);
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
verified: true,
|
|
599
|
+
payload: payload,
|
|
600
|
+
statement: st,
|
|
601
|
+
subjects: st.subject,
|
|
602
|
+
predicateType: st.predicateType,
|
|
603
|
+
predicate: st.predicate,
|
|
604
|
+
identity: identity,
|
|
605
|
+
integratedTime: integratedTime,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
module.exports = {
|
|
610
|
+
pae: pae,
|
|
611
|
+
parseBundle: parseBundle,
|
|
612
|
+
verifyBundle: verifyBundle,
|
|
613
|
+
};
|
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:ac694fe2-c3bb-413d-a4dc-e5ebffa0a8e3",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-12T08:08:32.351Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.2.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.3",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.3",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.2.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.2.3",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.2.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.3",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|