# Variant RCA Report — CVE-2026-48816 / GHSA-xgjw-pm74-86q4

## Summary

This is a **bypass** of the `@sigstore/verify` 3.1.1 fix (commit `f074710`,
"reject integratedTime w/o inclusionPromise (#1659)") for CVE-2026-48816. The
fix makes `getTLogTimestamp()` return `undefined` when `entry.inclusionPromise`
is absent, and stops counting such entries toward `timestampThreshold`. It
relies on `verifyTLogs()` → `verifyTLogInclusion()` → `verifyTLogSET()` to
cryptographically bind `integratedTime` whenever an `inclusionPromise` *is*
present. That binding only runs for entries in `entity.tlogEntries`; a
`SignedEntity` supplied to the public `Verifier.verify(SignedEntity)` API in
which the timestamp-providing tlog entry is **not** present in `tlogEntries`
(decoupled) carries a **forged** `inclusionPromise` + attacker-chosen
`integratedTime`. The fix's `!entry.inclusionPromise` **presence-check** passes
(because a forged `inclusionPromise` is present), the SET is **never validated**
(`verifyTLogs` iterates the empty `tlogEntries`), and `verify()` **succeeds** —
accepting an **expired** certificate at the attacker-chosen time. This reproduces
the original CVE's expired-cert-accepted impact **on the fixed version**. The
construction is identical in shape to the original CVE PoC's "Part B"
(`tlogEntries: []`, sole `transparency-log` timestamp). Through the standard
`toSignedEntity(bundle)` path the entries are coupled and a forged SET is
rejected by `verifyTLogSET` (negative control below), so the fix is effective for
the normal bundle path — but the fix's own logic does not enforce that coupling.

## Fix Coverage / Assumptions

**Invariant the fix relies on:** *"If `entry.inclusionPromise` is present, then
`entry.integratedTime` is cryptographically bound."* The fix itself does not
verify the SET; it delegates authentication to `verifyTLogSET()` inside
`verifyTLogs()`. `verifyTLogSET()` (packages/verify/src/tlog/set.ts) canonicalizes
`{body, integratedTime, logIndex, logID}` and verifies the SET signature against a
trusted tlog public key — so a real SET does bind `integratedTime`, a tampered
`integratedTime` invalidates a real SET, and a forged SET fails verification.

**Code path(s) the fix explicitly covers:**
- `getTLogTimestamp()` (packages/verify/src/timestamp/index.ts:44) — returns
  `undefined` when `!entry.inclusionPromise` (presence-check at :48).
- `verifyTimestamps()` (packages/verify/src/verifier.ts:87) — only pushes a
  `transparency-log` timestamp when `getTLogTimestamp` returns a defined result,
  and compares `timestamps.length` against `timestampThreshold` (:117).

**What the fix does NOT cover:**
- It checks `inclusionPromise` **presence**, not **validity**, in
  `getTLogTimestamp()`.
- `Verifier.verify()` (packages/verify/src/verifier.ts:73) runs
  `verifyTimestamps()` + `verifySigningKey()` (certificate validity at the tlog
  timestamp) **before** `verifyTLogs()` (:74–76). Nothing requires that a
  timestamp-providing tlog entry also be inclusion-verified before its timestamp
  is trusted.
- `verifyTLogs()` (:165) iterates **only** `entity.tlogEntries` — never
  `entity.timestamps`. So the SET of an entry that appears in `timestamps` but
  not in `tlogEntries` is never validated.
- The coupling between `timestamps` and `tlogEntries` is a property of the
  `toSignedEntity()` helper (packages/verify/src/bundle/index.ts:28), not of the
  `Verifier` contract. `SignedEntity` is an exported public type and
  `Verifier.verify(SignedEntity)` is the public method; nothing enforces that a
  caller used `toSignedEntity()`.

## Variant / Alternate Trigger

**Entry point:** the public `Verifier.verify(entity: SignedEntity)` API in
`@sigstore/verify` (packages/verify/src/verifier.ts:73), where `entity` is a
`SignedEntity` constructed with:

- `tlogEntries: []` (empty — so `verifyTLogs()` is a no-op),
- `timestamps: [{ $case: 'transparency-log', tlogEntry: E }]` where `E` has
  `integratedTime = <attacker-chosen>` and `inclusionPromise = { signedEntryTimestamp: <forged bytes> }`,
- a real cert-signed signature + an (now-expired) leaf certificate (for the
  cert-path demonstration), or a public-key hint (for the threshold demonstration).

**Exact code path:**

1. `Verifier.verify()` (:73) → `verifyTimestamps(entity)` (:74, :87).
2. `getTLogTimestamp(E)` (packages/verify/src/timestamp/index.ts:44): the fix's
   `if (!entry.inclusionPromise) return undefined;` (:48) **passes** because
   `E.inclusionPromise` is the forged object → returns
   `{ timestamp: new Date(Number(E.integratedTime)*1000) }` (:55). The
   attacker-chosen `integratedTime` is now a "trusted" timestamp.
3. `verifyTimestamps` counts it (`timestamps.length` ≥ `timestampThreshold`).
4. `verifySigningKey()` (:75) → `verifyCertificate(leaf, [attackerTime], …)`
   (packages/verify/src/key/index.ts) checks the certificate chain + SCTs **at
   the attacker-chosen time**. The leaf cert is expired *now* but valid *at
   `attackerTime`* (chosen inside its `notBefore..notAfter`), so it is accepted.
5. `verifyTLogs(entity)` (:76, :165) iterates `entity.tlogEntries` (empty) →
   **no-op** → `verifyTLogInclusion()`/`verifyTLogSET()`
   (packages/verify/src/tlog/set.ts:33) **never run** → the forged SET is never
   validated.
6. `verifySignature()` (:77) succeeds (real signature).
7. `verify()` **returns success** on the FIXED version → expired cert accepted.

**Why this is materially distinct from the original CVE trigger:** the original
CVE used an **inclusionProof-only** entry (no `inclusionPromise` at all) and was
exploited through the coupled `toSignedEntity(bundle)` path. The fix gated
`getTLogTimestamp` on `inclusionPromise` presence to close that. The variant
**defeats that gate** by supplying a **forged** `inclusionPromise` whose SET is
never validated (decoupled `SignedEntity`), reaching the same
unauthenticated-`integratedTime` → trusted-timestamp → expired-cert sink. It is a
different entry shape, a different fix-assumption exploited (presence ≠
validity + decoupling), and the same sink.

**Tested variants (all encoded in `bundle/vuln_variant/variant_harness.ts`):**
- **Part 1 — BYPASS, cert path (headline):** decoupled `SignedEntity` + forged
  `inclusionPromise` + attacker `integratedTime` → `verify()` succeeds, expired
  cert accepted. Reproduced on **both** vulnerable and **fixed** versions.
- **Part 0 — BYPASS, public-key path:** same decoupling, public-key path →
  `timestampThreshold` satisfied on attacker-chosen unvalidated time. Reproduced
  on both versions.
- **Part 2 — NEGATIVE CONTROL, bundle path (coupled):** the same forged
  `inclusionPromise` but through `toSignedEntity(bundle)` (entry in both
  `timestamps` and `tlogEntries`) → `verifyTLogSET` rejects the forged SET with
  `TLOG_INCLUSION_PROMISE_ERROR` on **both** versions. Proves the fix's coupling
  protection holds for the standard bundle path and that `verifyTLogSET` always
  validated the SET (pre- and post-fix).
- **Part 3 — ORIGINAL VECTOR baseline, bundle path (coupled):** inclusionProof-only
  entry + attacker `integratedTime` → `[ORIG_OK]` on vulnerable, `[ORIG_REJECT]`
  (`TIMESTAMP_ERROR`) on fixed. Confirms the fix closes the original vector and
  sanity-checks the version under test.

## Impact

- **Package/component affected:** `@sigstore/verify` (npm), part of the
  `sigstore/sigstore-js` monorepo. Bypass confirmed on the **fixed** version
  `3.1.1` (commit `f074710`); also present on vulnerable `3.1.0` (`7845532`).
- **Affected versions (as tested):** `3.1.0` (`7845532`) and `3.1.1`
  (`f074710a91ea9260a9ac2142345634579843a3cd`).
- **Risk level / consequences:** Medium (same CWE-345 class as the parent
  advisory, CVSS 6.5). Integrity impact: a consumer that passes an
  attacker-controlled `SignedEntity` to `Verifier.verify()` and relies on
  tlog-derived timestamps for certificate-validity / `timestampThreshold` can be
  tricked into accepting a bundle whose signing certificate is expired (or
  otherwise time-invalid) via an unauthenticated `integratedTime` carried by a
  forged, never-validated `inclusionPromise`. **Realism caveat:** through the
  standard `toSignedEntity(bundle)` path (the documented bundle attack surface)
  the fix is effective; the bypass requires a `SignedEntity` in which the
  timestamp's tlog entry is not in `tlogEntries`, which the standard helper does
  not produce but the public `Verifier.verify(SignedEntity)` API permits.

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** Integrity bypass — an
  attacker-supplied bundle can influence time-based verification (certificate
  validity windows and `timestampThreshold`) via an unauthenticated
  `integratedTime`.
- **Reproduced impact from this variant run:** `Verifier.verify()` **succeeds**
  on the FIXED version for a real cert-signed bundle whose leaf certificate is
  **expired now** (`notBefore=2025-12-14T02:04:39Z`,
  `notAfter=2025-12-14T02:14:39Z`, `now=2026-07-02…`) because the forged
  `inclusionPromise`'s unauthenticated `integratedTime` (`1765677909` =
  `notBefore+30s`, inside the cert validity window) is trusted as the signing
  time. The public-key path likewise satisfies `timestampThreshold:1` on the
  attacker-chosen, unvalidated time.
- **Parity:** `full` — the same expired-cert-accepted integrity impact as the
  parent, reproduced on the patched code path.
- **Not demonstrated:** No code-execution / memory-safety primitive (this is a
  verification-logic integrity bypass, not a memory bug); no end-to-end network
  service exploitation (the entry point is the library API).

## Root Cause

The same underlying bug — **an unauthenticated `integratedTime` treated as a
trusted timestamp** — can still be reached because the fix authenticates
`integratedTime` only indirectly, via `verifyTLogSET()` inside `verifyTLogs()`,
and `verifyTLogs()` iterates `entity.tlogEntries` rather than
`entity.timestamps`. `getTLogTimestamp()` itself performs only a presence-check
on `inclusionPromise`. Therefore any `SignedEntity` where a timestamp-providing
tlog entry is **not** in `tlogEntries` evades SET validation entirely; a forged
`inclusionPromise` satisfies the presence-check and the attacker-chosen
`integratedTime` is trusted — driving `verifySigningKey`/`verifyCertificate` to
accept an expired certificate. The fix is correct for the
`toSignedEntity(bundle)` coupling but incomplete as a property of the
`Verifier.verify(SignedEntity)` contract.

Fix commit: `f074710a91ea9260a9ac2142345634579843a3cd`
(https://github.com/sigstore/sigstore-js commit f074710,
"reject integratedTime w/o inclusionPromise (#1659)").

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh` (self-contained;
   harness at `bundle/vuln_variant/variant_harness.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). Records `git rev-parse` for both
     (`source_identity.json`).
   - For each commit: `git checkout`, clean built artifacts, `npm ci` (if
     needed), `npm run build`, copy the variant harness into
     `packages/verify/src/__tests__/cve_variant.test.ts`, and run it via
     `npx jest --selectProjects verify --testPathPatterns cve_variant.test.ts`.
   - Restores the cache repo checkout to its original state (`f074710`) on exit
     (trap) and removes the harness copy, so the repo is left clean.
   - Evaluates markers and writes `runtime_manifest.json`,
     `validation_verdict.json`, `source_identity.json`,
     `root_cause_equivalence.json`. Exits 0 if the bypass reproduced on the
     fixed version, else 1.
3. **Expected evidence of reproduction:**
   - `bundle/logs/vuln_variant_fixed.log` contains `[BYPASS_OK]` (Part 1 cert
     path: expired cert accepted) and `[BYPASS_OK]` (Part 0 public-key path),
     plus `[NC_REJECT]` (Part 2 coupled bundle path rejects forged SET) and
     `[ORIG_REJECT]` (Part 3 original vector closed).
   - `bundle/logs/vuln_variant_vuln.log` contains `[BYPASS_OK]` x2, `[NC_REJECT]`,
     and `[ORIG_OK]` (original CVE open).
   - Script exits 0.

## Evidence

- **Marker logs:**
  - `bundle/logs/vuln_variant_fixed.log`
  - `bundle/logs/vuln_variant_vuln.log`
- **Raw jest output:**
  - `bundle/logs/vuln_variant_fixed_jest.log` (4 tests passed)
  - `bundle/logs/vuln_variant_vuln_jest.log` (4 tests passed)
- **Build logs:** `bundle/logs/vuln_fixed_build.log`,
  `bundle/logs/vuln_vuln_build.log` (and `*_npmci.log` if installed).
- **Harness:** `bundle/vuln_variant/variant_harness.ts`
- **Manifests/verdict:** `bundle/vuln_variant/runtime_manifest.json`,
  `bundle/vuln_variant/validation_verdict.json`,
  `bundle/vuln_variant/source_identity.json`,
  `bundle/vuln_variant/root_cause_equivalence.json`,
  `bundle/vuln_variant/variant_manifest.json`.

Key excerpts — **fixed** run (`bundle/logs/vuln_variant_fixed.log`):

```
[CALLSITE_BYPASS]: getTLogTimestamp() returned a trusted timestamp (2025-12-14T02:05:09.000Z) for an entry whose inclusionPromise is FORGED (presence-check passes, SET not yet validated); integratedTime=1765677909
[BYPASS_OK]: Verifier.verify() (cert path) SUCCEEDED on a DECOUPLED SignedEntity (tlogEntries empty) whose sole timestamp is a tlog entry carrying a FORGED inclusionPromise + attacker integratedTime=1765677909; cert is EXPIRED now (notBefore=2025-12-14T02:04:39.000Z, notAfter=2025-12-14T02:14:39.000Z, now=2026-07-02T18:16:12.815Z); fix presence-check passed, SET never validated -> original CVE impact (expired-cert-accepted) reproduced on this version
[BYPASS_OK]: Verifier.verify() (public-key path) SUCCEEDED on a DECOUPLED SignedEntity whose sole timestamp is a tlog entry with a FORGED inclusionPromise + attacker integratedTime=1763174679; timestampThreshold satisfied & key validity accepted on attacker-chosen, unvalidated time
[NC_REJECT]: bundle-path (coupled) verify rejected a FORGED inclusionPromise with code=TLOG_INCLUSION_PROMISE_ERROR: inclusion promise could not be verified -> verifyTLogSET validated the SET and rejected (coupling protection holds on this version)
[ORIG_REJECT]: original vector (inclusionProof-only) verify rejected with code=TIMESTAMP_ERROR: expected 1 timestamps, got 0 -> fix closes the original inclusionProof-only vector on this version
```

Marker counts: fixed `BYPASS_OK=2 NC_REJECT=1 ORIG_REJECT=1`; vuln
`BYPASS_OK=2 NC_REJECT=1 ORIG_OK=1`.

**Environment:** Node v24.18.0, npm 11.16.0, jest 30 with `@swc/jest`. Repo
built with `tsc --build` (`npm run build`). Fixed commit
`f074710a91ea9260a9ac2142345634579843a3cd` (`@sigstore/verify` 3.1.1); vulnerable
commit `7845532f9d17f6f765363dbee82b01bd159fb52b` (`@sigstore/verify` 3.1.0).
Both resolved via `git rev-parse` from the cloned repo in the durable project
cache (`bundle/vuln_variant/source_identity.json`).

## Recommendations / Next Steps

The fix should not rely on the `toSignedEntity()` coupling to authenticate
`integratedTime`. One or more of:

1. **Authenticate at the timestamp source.** Verify the SET inside/alongside
   `getTLogTimestamp()` (or in `verifyTimestamps()`) against the trusted tlog
   material before trusting `integratedTime` — make the timestamp decision
   independent of `verifyTLogs()`. This directly closes the decoupled bypass.
2. **Enforce coupling in the verifier.** Require that every `transparency-log`
   timestamp's `tlogEntry` is also a member of `entity.tlogEntries` and has had
   its inclusion verified, before its timestamp is used for certificate-validity
   / threshold decisions.
3. **Do not gate on presence alone.** Treat `inclusionPromise` as a timestamp
   source only after its SET has been verified, not merely because the field is
   non-null.

Note: simply **reordering** `verifyTLogs()` before `verifyTimestamps()` is
**not sufficient** — with `tlogEntries: []` and `tlogThreshold: 0`,
`verifyTLogs()` is still a no-op, so the decoupled bypass would remain. Option 1
or 2 is required.

**Testing recommendations:** add a regression test that feeds a decoupled
`SignedEntity` (timestamp tlog entry absent from `tlogEntries`) with a forged
`inclusionPromise` + attacker `integratedTime` and asserts `verify()` rejects
(TIMESTAMP_ERROR / TLOG error) rather than accepting an expired certificate.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` was run three times consecutively;
  all runs exited 0 with identical markers. The cache repo checkout is restored
  to `f074710` on every exit (trap), and the harness copy is removed, so the repo
  is left in the same state the repro stage left it (no untracked files).
- **Threat-model scope:** `sigstore-js` has no in-repo `SECURITY.md`;
  `README.md` references sigstore's security process. The CVE class is CWE-345
  (Insufficient Verification of Data Authenticity), so trusting an unauthenticated
  value in the verifier is explicitly in-scope. This bypass is the same class and
  sink, not a documented limitation.
- **Realism caveat (explicit):** The bypass requires a `SignedEntity` not
  produced by the standard `toSignedEntity(bundle)` coupling. Through the normal
  bundle attack surface (the advisory's "attacker supplying a malicious bundle"),
  the fix is effective (Part 2 negative control). The bypass is reachable via the
  public `Verifier.verify(SignedEntity)` API and matches the construction the
  original CVE PoC Part B used. `same_surface_confidence` is set to `medium` and
  `exploitability_confidence` to `medium` to reflect this caveat;
  `same_root_cause_confidence` is `high` (identical sink and impact).
- **Negative control confirms fix coverage:** Part 2 shows the coupled bundle
  path rejects a forged `inclusionPromise` on **both** versions
  (`verifyTLogSET` always validated the SET), so the fix does not regress the
  bundle path and the bypass is specifically about the decoupled construction.
