# Patch Analysis — CVE-2026-48816 / GHSA-xgjw-pm74-86q4

**Target:** `sigstore/sigstore-js` — `@sigstore/verify`
**Fix commit:** `f074710a91ea9260a9ac2142345634579843a3cd` ("reject integratedTime w/o
inclusionPromise (#1659)"), released as `@sigstore/verify` **3.1.1**.
**Vulnerable commit (fix parent):** `7845532f9d17f6f765363dbee82b01bd159fb52b`
(`@sigstore/verify` 3.1.0).

## 1. What the fix changes

The fix touches two files in `packages/verify/src`.

### `timestamp/index.ts` — `getTLogTimestamp`

Before (vulnerable):

```ts
export function getTLogTimestamp(entry: TransparencyLogEntry): TimestampVerificationResult {
  return {
    type: 'transparency-log',
    logID: entry.logId.keyId,
    timestamp: new Date(Number(entry.integratedTime) * 1000),
  };
}
```

After (fixed):

```ts
export function getTLogTimestamp(
  entry: TransparencyLogEntry
): 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,
    timestamp: new Date(Number(entry.integratedTime) * 1000),
  };
}
```

The function now returns `undefined` whenever `entry.inclusionPromise` is absent,
instead of unconditionally turning `entry.integratedTime` into a `Date`.

### `verifier.ts` — `verifyTimestamps`

Before: a `timestampCount` was incremented for **every** `transparency-log`
timestamp (and every `timestamp-authority` timestamp) regardless of
`getTLogTimestamp`'s result, and that count was compared against
`timestampThreshold`. The mapped `timestamps` array was built but not used for
the threshold check.

After: the loop collects results into a `timestamps` array. For the
`transparency-log` case it calls `getTLogTimestamp` and **only pushes the result
when it is defined**; the threshold check now uses `timestamps.length`:

```ts
case 'transparency-log': {
  const result = getTLogTimestamp(timestamp.tlogEntry);
  /* istanbul ignore else */
  if (result) {
    timestamps.push(result);
  }
  break;
}
...
if (timestamps.length < this.options.timestampThreshold) {
  throw new VerificationError({ code: 'TIMESTAMP_ERROR', ... });
}
```

So an inclusionProof-only entry (no `inclusionPromise`) no longer contributes a
trusted timestamp and no longer counts toward `timestampThreshold`.

## 2. What the fix assumes

The fix's stated intent (code comment) is: *"Only entries with an inclusion
promise provide a verifiable timestamp."* Concretely it assumes:

1. **Presence ⇒ authenticity.** If `entry.inclusionPromise` is present
   (truthy), then `entry.integratedTime` is cryptographically bound. The
   mechanism it relies on for that binding is **not** in `getTLogTimestamp` —
   it is `verifyTLogs()` → `verifyTLogInclusion()` → `verifyTLogSET()` (in
   `tlog/set.ts`), which canonicalizes
   `{body, integratedTime, logIndex, logID}` and verifies the SET signature
   against a trusted tlog public key. The SET *does* cover `integratedTime`, so
   a real SET binds it; a tampered `integratedTime` invalidates a real SET, and
   a forged SET fails signature verification.
2. **Coupling between the timestamp source and the inclusion-verification
   list.** The fix assumes every tlog entry that appears as a
   `transparency-log` **timestamp** also appears in `entity.tlogEntries`, so
   that `verifyTLogs()` will run `verifyTLogInclusion()` on it and validate the
   SET. This coupling is provided by `toSignedEntity()` in `bundle/index.ts`,
   which builds `timestamps` **from** `tlogEntries` and returns the same
   `tlogEntries` array.
3. **Ordering is harmless.** `Verifier.verify()` runs `verifyTimestamps()` and
   `verifySigningKey()` (certificate-validity at the tlog timestamp) **before**
   `verifyTLogs()`. The fix assumes that consuming the (now presence-gated)
   timestamp before the SET is validated is safe, because `verifyTLogs()` will
   reject a forged SET later and fail the whole `verify()`.

## 3. Code paths / inputs the fix does NOT cover

The fix only changes `getTLogTimestamp` and `verifyTimestamps`. It does **not**
change:

- `bundle/index.ts` `toSignedEntity()` (still adds a `transparency-log`
  timestamp for any tlog entry with `integratedTime !== '0'`).
- `verifier.ts` `verify()` ordering (timestamps/cert-validity still run before
  `verifyTLogs`).
- `verifier.ts` `verifyTLogs()` (still iterates `entity.tlogEntries` only — it
  never looks at `entity.timestamps`).
- `tlog/index.ts` `verifyTLogInclusion()` / `tlog/set.ts` `verifyTLogSET()`
  (unchanged; still the only place the SET is validated).

Because the fix's authenticity guarantee is delegated to `verifyTLogSET()` inside
`verifyTLogs()`, and `verifyTLogs()` only iterates `entity.tlogEntries`, the
guarantee **does not hold** when a tlog entry is used as a timestamp but is **not
present in `tlogEntries`**. The public `Verifier.verify(entity: SignedEntity)`
API accepts any `SignedEntity` (the `SignedEntity` type is exported from
`@sigstore/verify`); nothing in `Verifier` enforces that a timestamp-providing
tlog entry is also inclusion-verified. `toSignedEntity()` happens to couple them,
but that coupling is a property of one helper, not of the verifier contract.

Concretely, a `SignedEntity` constructed with `tlogEntries: []` and a
`transparency-log` timestamp whose `tlogEntry` carries a **forged**
`inclusionPromise` (`signedEntryTimestamp` = arbitrary bytes) plus an
attacker-chosen `integratedTime`:

- `getTLogTimestamp()` sees `entry.inclusionPromise` present ⇒ **passes the
  fix's `!entry.inclusionPromise` presence-check** ⇒ returns the attacker-chosen
  `integratedTime` as a trusted timestamp.
- `verifyTimestamps()` counts it toward `timestampThreshold`.
- `verifySigningKey()` / `verifyCertificate()` check certificate validity **at
  the attacker-chosen time** (so an **expired** certificate is accepted).
- `verifyTLogs()` iterates `tlogEntries` (empty) ⇒ **no-op** ⇒ `verifyTLogSET()`
  is **never called** ⇒ the forged SET is **never validated**.
- `verifySignature()` succeeds (real signature).
- `verify()` **succeeds** on the **fixed** version — the original CVE's
  "expired certificate accepted" impact is reproduced on the patched code path.

This is a **bypass of the fix**: the fix's presence-check is satisfied by a
forged `inclusionPromise`, and the SET authentication the fix relies on never
runs because the entry is decoupled from `tlogEntries`. This construction is
reachable via the public `Verifier.verify(SignedEntity)` API and is identical in
shape to the original CVE PoC's "Part B" (`tlogEntries: []`, sole
`transparency-log` timestamp).

## 4. Behavior before vs. after the fix

| Scenario | Vulnerable (3.1.0) | Fixed (3.1.1) |
|---|---|---|
| InclusionProof-only entry, attacker `integratedTime`, **bundle path** (`toSignedEntity`, coupled) | `verify()` **succeeds** (expired cert accepted) — original CVE | `verify()` **rejects** `TIMESTAMP_ERROR` (`getTLogTimestamp` → `undefined`) |
| Entry with **forged** `inclusionPromise`, attacker `integratedTime`, **bundle path** (coupled) | `verify()` **rejects** `TLOG_INCLUSION_PROMISE_ERROR` (`verifyTLogSET` invalidates forged SET) | `verify()` **rejects** `TLOG_INCLUSION_PROMISE_ERROR` (same — `verifyTLogSET` always validated SET) |
| Entry with **forged** `inclusionPromise`, attacker `integratedTime`, **decoupled** `SignedEntity` (`tlogEntries: []`) | `verify()` **succeeds** (expired cert accepted) | `verify()` **succeeds** (expired cert accepted) — **BYPASS** |

The third row is the variant/bypass: it reproduces on the fixed version.

## 5. Is the fix complete?

**Partially.** The fix correctly closes the **original** inclusionProof-only
vector through the standard `toSignedEntity(bundle)` → `Verifier.verify()` path
(row 1, fixed column). It does **not** close the unauthenticated-`integratedTime`
sink for `SignedEntity` inputs in which a timestamp-providing tlog entry is not
present in `tlogEntries` (row 3). The fix authenticates `integratedTime` only by
relying on `verifyTLogSET()` in `verifyTLogs()`, but `Verifier.verify()` does not
require that every timestamp-providing tlog entry be inclusion-verified, nor does
it verify the SET inside/alongside `getTLogTimestamp()`.

### Recommended complete fix

One or more of:

1. **Authenticate at the timestamp source.** Have `getTLogTimestamp()` (or
   `verifyTimestamps()`) actually verify the SET against the trusted tlog
   material before trusting `integratedTime`, instead of gating on mere
   presence. This makes the timestamp decision independent of `verifyTLogs()`.
2. **Enforce coupling in the verifier.** Require that every `transparency-log`
   timestamp's `tlogEntry` is also a member of `entity.tlogEntries` and that
   `verifyTLogs()` has validated its inclusion, before its timestamp is used for
   certificate-validity / threshold decisions.
3. **Reorder checks.** Run `verifyTLogs()` (which validates the SET and thus
   binds `integratedTime`) **before** `verifyTimestamps()` /
   `verifySigningKey()`, so an unauthenticated/forged timestamp is rejected
   before it can drive a certificate-validity decision. (This alone would make
   the decoupled case fail because... note: with `tlogEntries: []` and
   `tlogThreshold:0`, `verifyTLogs()` is still a no-op, so reordering alone does
   NOT close the decoupled bypass — options 1 or 2 are required.)

## 6. Threat-model scope (target's stated position)

`sigstore-js` has no in-repo `SECURITY.md`; `README.md` points to sigstore's
security process
(https://github.com/sigstore/.github/blob/main/SECURITY.md). The advisory class
for this CVE is **CWE-345 Insufficient Verification of Data Authenticity** — i.e.
the project explicitly treats "trusting an unauthenticated value in a
verifier" as a security vulnerability in scope. The bypass here is the same
class (an unauthenticated `integratedTime` treated as a trusted timestamp) and
the same sink, so it is within the target's stated security scope — it is not a
documented limitation and not "behavior by design." The realism caveat is that
the bypass requires a `SignedEntity` not produced by the standard
`toSignedEntity(bundle)` coupling; through the normal bundle path the fix is
effective (row 2 negative control).
