# REPRO-2026-00210: sigstore-js Insufficient Verification of Data Authenticity ## Summary Status: published Severity: medium Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00210 CVE: CVE-2026-48816 ## Package Name: sigstore/sigstore-js Ecosystem: github Affected: Unknown Fixed: 3.1.1 ## Root Cause # RCA Report — CVE-2026-48816 / GHSA-xgjw-pm74-86q4 ## Summary `@sigstore/verify` (the sigstore-js verification library) derives a transparency-log timestamp directly from `tlogEntries[].integratedTime` and uses that timestamp both to check the Fulcio signing certificate's validity window and to satisfy the `timestampThreshold`. For a bundle whose tlog entry is **inclusionProof-only** (it carries an `inclusionProof` but **no signed `inclusionPromise`/SET**), `integratedTime` is **not cryptographically bound**: the inclusion-proof path (`verifyCheckpoint` + `verifyMerkleInclusion`) proves Merkle-tree inclusion against a signed checkpoint but never binds the `integratedTime` value, whereas only the signed inclusionPromise/SET path (`verifyTLogSET`) signs over `integratedTime`. As a result, an attacker who can supply an untrusted bundle can choose `integratedTime` freely and thereby influence time-based verification decisions — in particular making an **expired** certificate appear to have been valid at signing time. ## Impact - **Package/component affected:** `@sigstore/verify` (npm), part of the `sigstore/sigstore-js` monorepo. Affected source files: - `packages/verify/src/bundle/index.ts` — `toSignedEntity` adds a `transparency-log` timestamp for every tlog entry where `integratedTime != '0'`, regardless of whether an `inclusionPromise` is present. - `packages/verify/src/timestamp/index.ts` — `getTLogTimestamp` converts `entry.integratedTime` into a `Date` with no check that the value is cryptographically bound. - `packages/verify/src/verifier.ts` — `verifyTimestamps` counts every transparency-log timestamp toward `timestampThreshold`, and `verify()` runs timestamp (and therefore certificate-validity) checks **before** `verifyTLogs` (inclusion proof) — so the unauthenticated time is consumed before any inclusion check that could constrain it. - `packages/verify/src/tlog/index.ts` + `packages/verify/src/tlog/set.ts` — only the `inclusionPromise`/SET path (`verifyTLogSET`) signs over `integratedTime`; the `inclusionProof` path does not. - **Affected versions:** `@sigstore/verify` 3.1.0 (vulnerable). Fixed in 3.1.1. - Vulnerable commit (anchored to the fix's parent): `7845532` (`f074710^`, "OID certificate extension verification (#1658)", still shipping `@sigstore/verify` 3.1.0). - Fixed commit: `f074710` ("reject integratedTime w/o inclusionPromise (#1659)"), released as 3.1.1 via `c1dc7d4` ("Version Packages (#1607)"). - **Risk level / consequences:** Medium (advisory CVSS 6.5, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N; CWE-345 Insufficient Verification of Data Authenticity). Integrity impact: a consumer that accepts attacker-provided bundle inputs and relies on tlog-derived timestamps for certificate-validity checks can be tricked into accepting a bundle whose signing certificate is expired (or otherwise time-invalid) by an unauthenticated timestamp value chosen by the attacker. ## Impact Parity - **Disclosed/claimed maximum impact:** Integrity bypass — an attacker-supplied bundle can influence time-based verification (certificate validity windows and `timestampThreshold`) via an unauthenticated `integratedTime` in an inclusionProof-only tlog entry. - **Reproduced impact from this run:** - The real `getTLogTimestamp()` callsite accepts an inclusionProof-only entry's `integratedTime` as a trusted timestamp (vulnerable) and returns `undefined` for it (fixed). - The real `Verifier.verify()` **succeeds** on a real cert-signed bundle mutated to be inclusionProof-only with an attacker-chosen `integratedTime`, accepting a certificate that is **expired now** (`notAfter = 2025-12-14T02:14:39Z`, `now = 2026-07-02…`) because the unauthenticated `integratedTime` is set inside the cert's validity window (`notBefore = 2025-12-14T02:04:39Z`). The fixed build rejects the identical bundle with `TIMESTAMP_ERROR`. - **Parity:** `full` — the disclosed trust gap (unauthenticated `integratedTime` used for certificate validity / `timestampThreshold`) is demonstrated through the real `@sigstore/verify` verification path with a concrete expired-certificate-accepted outcome and a fixed-version negative control. - **Not demonstrated:** No code execution / memory corruption is claimed or demonstrated; the impact is a verification/authenticity bypass (CWE-345). ## Root Cause `toSignedEntity` (bundle/index.ts) turns every tlog entry with `integratedTime != '0'` into a `transparency-log` timestamp. `verifyTimestamps` (verifier.ts) then calls `getTLogTimestamp(entry)` (timestamp/index.ts) which, in the vulnerable code, unconditionally returns: ```ts return { type: 'transparency-log', logID: entry.logId.keyId, timestamp: new Date(Number(entry.integratedTime) * 1000), }; ``` with **no check that `entry.inclusionPromise` exists**. That result is counted toward `timestampThreshold` and is returned to `verifySigningKey`, which calls `verifyCertificate(cert, timestamps, trustMaterial)` → `verifyCertificateChain(timestamp, leaf, CAs)` — i.e. the certificate's validity window is checked **at the attacker-chosen `integratedTime`**, not at the current time and not at a cryptographically-bound time. Critically, `verify()` performs `verifyTimestamps` (and thus the certificate-validity decision) **before** `verifyTLogs` (the inclusion-proof check), so the unauthenticated time is consumed before any check that could constrain it. Only the `inclusionPromise`/SET path (`verifyTLogSET` in tlog/set.ts) signs over `integratedTime` (`body`, `integratedTime`, `logIndex`, `logID`). The `inclusionProof` path (`verifyCheckpoint` + `verifyMerkleInclusion`) verifies Merkle inclusion against a signed checkpoint but does **not** bind `integratedTime`. Therefore an inclusionProof-only entry can pass inclusion verification while leaving `integratedTime` fully attacker-controlled. **Fix commit:** `f074710` ("reject integratedTime w/o inclusionPromise (#1659)"). ```diff export function getTLogTimestamp( entry: TransparencyLogEntry -): TimestampVerificationResult { +): TimestampVerificationResult | undefined { + // Only entries with an inclusion promise provide a verifiable timestamp + if (!entry.inclusionPromise) { + return undefined; + } + return { type: 'transparency-log', logID: entry.logId.keyId, ``` and in `verifier.ts`, `verifyTimestamps` now only pushes the result when it is defined and compares `timestamps.length` against `timestampThreshold` (so an inclusionProof-only entry no longer counts toward the threshold nor feeds certificate validity). ## Reproduction Steps 1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained; harness at `bundle/repro/harness_test.ts`). 2. **What it does:** - Reuses/clones `sigstore/sigstore-js` into the durable project cache (`/repo`). - Resolves the fixed commit `f074710` and its parent `7845532` (vulnerable, `@sigstore/verify` 3.1.0). - For each commit: `git checkout`, `npm ci` (if needed), `npm run build` (builds the workspace so jest can resolve `@sigstore/bundle`/`core`/`jest` via their `dist`), copies the harness into `packages/verify/src/__tests__/cve_repro.test.ts`, and runs it via `npx jest --selectProjects verify --testPathPatterns cve_repro.test.ts`. - The harness (run at both commits) exercises the **real** `@sigstore/verify` source and emits behavior markers to `$REPRO_LOG`: - **Part A** — callsite: `getTLogTimestamp()` on an inclusionProof-only entry with an attacker-chosen `integratedTime`. - **Part B** — `Verifier.verify()` (public-key path) where the **sole** timestamp is the inclusionProof-only entry's `integratedTime` (`timestampThreshold:1`, `tlogThreshold:0`). - **Part C** — `Verifier.verify()` (certificate path) on the **real** cert-signed fixture `V3.MESSAGE_SIGNATURE.TLOG_HASHEDREKORDV002`, mutated so the inclusionProof-only entry's `integratedTime` is a non-zero value inside the (expired) certificate's validity window and the RFC3161 timestamp is removed — making the unauthenticated `integratedTime` the only timestamp source. - Evaluates markers and writes `bundle/repro/runtime_manifest.json` and `bundle/repro/validation_verdict.json`. 3. **Expected evidence of reproduction:** - `bundle/logs/canonical.log` (vulnerable) contains `[CALLSITE_HIT]` and `[PROOF_MARKER]`, including the certificate-path line showing an expired cert accepted via unauthenticated `integratedTime`. - `bundle/logs/control.log` (fixed) contains `[NC_MARKER]` only (`getTLogTimestamp` returns `undefined`; `Verifier.verify()` throws `TIMESTAMP_ERROR`), with **no** `[PROOF_MARKER]`. - Script exits 0. ## Evidence - **Marker logs:** - `bundle/logs/canonical.log` - `bundle/logs/control.log` - **Raw jest output:** - `bundle/logs/canonical_jest.log` - `bundle/logs/control_jest.log` - **Build logs:** `bundle/logs/canonical_build.log`, `bundle/logs/control_build.log` - **Harness:** `bundle/repro/harness_test.ts` - **Manifest/verdict:** `bundle/repro/runtime_manifest.json`, `bundle/repro/validation_verdict.json` Key excerpts (vulnerable canonical run, `bundle/logs/canonical.log`): ``` [CALLSITE_HIT] [PROOF_MARKER]: getTLogTimestamp() accepted UNAUTHENTICATED tlog integratedTime=1763174679 as a trusted timestamp (2025-11-15T02:44:39.000Z); entry has NO inclusionPromise (inclusionProof-only) -> integratedTime not cryptographically bound [PROOF_MARKER]: Verifier.verify() (public-key path) SUCCEEDED; sole timestamp was UNAUTHENTICATED tlog integratedTime=1763174679 from inclusionProof-only entry -> timestampThreshold satisfied & key validity accepted on attacker-chosen time [CALLSITE_HIT] [PROOF_MARKER]: Verifier.verify() (certificate path) SUCCEEDED for a cert that is EXPIRED now (notBefore=2025-12-14T02:04:39.000Z, notAfter=2025-12-14T02:14:39.000Z, now=2026-07-02T18:09:39.516Z) using UNAUTHENTICATED tlog integratedTime=1765677909 from an inclusionProof-only entry -> certificate validity window satisfied by attacker-chosen, unauthenticated time ``` Key excerpts (fixed control run, `bundle/logs/control.log`): ``` [NC_MARKER]: getTLogTimestamp() returned undefined for inclusionProof-only entry (no inclusionPromise) -> untrusted integratedTime rejected [NC_MARKER]: Verifier.verify() (public-key path) rejected with TIMESTAMP_ERROR -> inclusionProof-only integratedTime no longer counts toward timestampThreshold [NC_MARKER]: Verifier.verify() (certificate path) rejected with TIMESTAMP_ERROR -> inclusionProof-only integratedTime not counted as a trusted timestamp; no signed timestamp present ``` - **Environment:** Node v24.18.0, npm 11.16.0, jest 30 with `@swc/jest` (swc via bundled wasm binding). Repo built with `tsc --build tsconfig.build.json`. Vulnerable commit `7845532` (`@sigstore/verify` 3.1.0); fixed commit `f074710` (`@sigstore/verify` 3.1.1). Marker counts: canonical `CALLSITE_HIT=2 PROOF_MARKER=3`; control `NC_MARKER=3 PROOF_MARKER=0`. ## Recommendations / Next Steps - **Upgrade:** Consumers of `@sigstore/verify` should upgrade to **>= 3.1.1** (fix commit `f074710`). - **Defense in depth:** When accepting attacker-provided bundles, do not treat `integratedTime` from inclusionProof-only entries as a trusted timestamp. Require a signed `inclusionPromise`/SET or an RFC3161 timestamp for any time-based decision (certificate validity, `timestampThreshold`). - **Testing recommendations:** Add regression tests that (a) feed an inclusionProof-only entry with a non-zero `integratedTime` and assert `getTLogTimestamp` returns `undefined`, and (b) assert a bundle whose only timestamp is such an entry is rejected with `TIMESTAMP_ERROR`. The upstream fix already added the `getTLogTimestamp` "no inclusion promise -> undefined" test; the certificate-validity impact path demonstrated here is a useful additional regression case. ## Additional Notes - **Idempotency:** `reproduction_steps.sh` was run twice consecutively; both runs exited 0 with identical marker counts (`canonical: CALLSITE_HIT=2, PROOF_MARKER=3`; `control: NC_MARKER=3, PROOF_MARKER=0`). The script cleans `packages/verify/dist` and `*.tsbuildinfo` per commit and re-copies the harness, so runs are deterministic. - **Surface alignment:** The ticket's `claimed_surface` is `library_api` with `required_entrypoint_kind=function_call`. The proof exercises the real `@sigstore/verify` library functions (`getTLogTimestamp`, `toSignedEntity`, `Verifier.verify`, `toTrustMaterial`) through a jest harness, including a real cert-signed bundle fixture — matching the claimed surface. - **Scope/limitations:** The reproduction demonstrates the verification authenticity/integrity bypass (an expired certificate accepted as valid via an unauthenticated timestamp). It does **not** demonstrate code execution or memory corruption (none is claimed). Part C uses a real sigstore bundle fixture whose certificate is expired relative to the run time; the attacker influence is the `integratedTime` value, which is the exact unauthenticated field identified by the advisory. ## Reproduction Details Reproduced: 2026-07-02T19:50:52.050Z Duration: 1134 seconds Tool calls: 158 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00210 pruva-verify CVE-2026-48816 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00210&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00210/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-48816 - Source: https://www.cve.org/CVERecord?id=CVE-2026-48816 ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 9773 bytes) - bundle/repro/rca_report.md (analysis, 13375 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 15625 bytes) - bundle/vuln_variant/rca_report.md (analysis, 18065 bytes) - bundle/ticket.md (ticket, 996 bytes) - bundle/ticket.json (other, 1410 bytes) - bundle/repro/harness_test.ts (other, 10495 bytes) - bundle/repro/validation_verdict.json (other, 976 bytes) - bundle/repro/runtime_manifest.json (other, 1078 bytes) - bundle/logs/canonical_build.log (log, 68 bytes) - bundle/logs/canonical_jest.log (log, 1748 bytes) - bundle/logs/control_build.log (log, 68 bytes) - bundle/logs/control_jest.log (log, 1353 bytes) - bundle/logs/canonical.log (log, 875 bytes) - bundle/logs/control.log (log, 482 bytes) - bundle/logs/vuln_build.log (log, 68 bytes) - bundle/logs/vuln_variant_vuln_jest.log (log, 2468 bytes) - bundle/logs/fixed_build.log (log, 68 bytes) - bundle/logs/vuln_variant_fixed_jest.log (log, 2427 bytes) - bundle/logs/vuln_variant_idempotent_run.log (log, 4542 bytes) - bundle/logs/vuln_variant_rerun.log (log, 4542 bytes) - bundle/logs/vuln_variant_vuln.log (log, 1436 bytes) - bundle/logs/vuln_variant_fixed.log (log, 1396 bytes) - bundle/logs/vuln_variant/summary.txt (other, 744 bytes) - bundle/vuln_variant/variant_harness.ts (other, 16342 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 1774 bytes) - bundle/vuln_variant/validation_verdict.json (other, 1953 bytes) - bundle/vuln_variant/source_identity.json (other, 914 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 1248 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 10342 bytes) - bundle/vuln_variant/variant_manifest.json (other, 4953 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00210 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00210/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00210 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev