# 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
     (`<project_cache_dir>/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.
