{"repro_id":"REPRO-2026-00210","version":8,"title":"sigstore-js Insufficient Verification of Data Authenticity","repro_type":"security","status":"published","severity":"medium","description":"CVE-2026-48816 / GHSA-xgjw-pm74-86q4: Insufficient Verification of Data Authenticity in @sigstore/verify (npm). The verifier derives a transparency-log timestamp from tlogEntries[].integratedTime for bundle v0.2 and uses it for certificate validity checks and timestampThreshold. In inclusionProof-only entries (no signed inclusionPromise/set), integratedTime is not cryptographically bound, allowing an attacker who supplies a malicious bundle to influence time-based verification decisions.","root_cause":"# RCA Report — CVE-2026-48816 / GHSA-xgjw-pm74-86q4\n\n## Summary\n\n`@sigstore/verify` (the sigstore-js verification library) derives a\ntransparency-log timestamp directly from `tlogEntries[].integratedTime` and\nuses that timestamp both to check the Fulcio signing certificate's validity\nwindow and to satisfy the `timestampThreshold`. For a bundle whose tlog entry\nis **inclusionProof-only** (it carries an `inclusionProof` but **no signed\n`inclusionPromise`/SET**), `integratedTime` is **not cryptographically bound**:\nthe inclusion-proof path (`verifyCheckpoint` + `verifyMerkleInclusion`) proves\nMerkle-tree inclusion against a signed checkpoint but never binds the\n`integratedTime` value, whereas only the signed inclusionPromise/SET path\n(`verifyTLogSET`) signs over `integratedTime`. As a result, an attacker who can\nsupply an untrusted bundle can choose `integratedTime` freely and thereby\ninfluence time-based verification decisions — in particular making an\n**expired** certificate appear to have been valid at signing time.\n\n## Impact\n\n- **Package/component affected:** `@sigstore/verify` (npm), part of the\n  `sigstore/sigstore-js` monorepo. Affected source files:\n  - `packages/verify/src/bundle/index.ts` — `toSignedEntity` adds a\n    `transparency-log` timestamp for every tlog entry where\n    `integratedTime != '0'`, regardless of whether an `inclusionPromise` is\n    present.\n  - `packages/verify/src/timestamp/index.ts` — `getTLogTimestamp` converts\n    `entry.integratedTime` into a `Date` with no check that the value is\n    cryptographically bound.\n  - `packages/verify/src/verifier.ts` — `verifyTimestamps` counts every\n    transparency-log timestamp toward `timestampThreshold`, and\n    `verify()` runs timestamp (and therefore certificate-validity) checks\n    **before** `verifyTLogs` (inclusion proof) — so the unauthenticated time is\n    consumed before any inclusion check that could constrain it.\n  - `packages/verify/src/tlog/index.ts` + `packages/verify/src/tlog/set.ts` —\n    only the `inclusionPromise`/SET path (`verifyTLogSET`) signs over\n    `integratedTime`; the `inclusionProof` path does not.\n- **Affected versions:** `@sigstore/verify` 3.1.0 (vulnerable). Fixed in 3.1.1.\n  - Vulnerable commit (anchored to the fix's parent): `7845532`\n    (`f074710^`, \"OID certificate extension verification (#1658)\", still\n    shipping `@sigstore/verify` 3.1.0).\n  - Fixed commit: `f074710` (\"reject integratedTime w/o inclusionPromise\n    (#1659)\"), released as 3.1.1 via `c1dc7d4` (\"Version Packages (#1607)\").\n- **Risk level / consequences:** Medium (advisory CVSS 6.5,\n  CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N; CWE-345 Insufficient\n  Verification of Data Authenticity). Integrity impact: a consumer that accepts\n  attacker-provided bundle inputs and relies on tlog-derived timestamps for\n  certificate-validity checks can be tricked into accepting a bundle whose\n  signing certificate is expired (or otherwise time-invalid) by an\n  unauthenticated timestamp value chosen by the attacker.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Integrity bypass — an attacker-supplied\n  bundle can influence time-based verification (certificate validity windows and\n  `timestampThreshold`) via an unauthenticated `integratedTime` in an\n  inclusionProof-only tlog entry.\n- **Reproduced impact from this run:**\n  - The real `getTLogTimestamp()` callsite accepts an inclusionProof-only\n    entry's `integratedTime` as a trusted timestamp (vulnerable) and returns\n    `undefined` for it (fixed).\n  - The real `Verifier.verify()` **succeeds** on a real cert-signed bundle\n    mutated to be inclusionProof-only with an attacker-chosen `integratedTime`,\n    accepting a certificate that is **expired now**\n    (`notAfter = 2025-12-14T02:14:39Z`, `now = 2026-07-02…`) because the\n    unauthenticated `integratedTime` is set inside the cert's validity window\n    (`notBefore = 2025-12-14T02:04:39Z`). The fixed build rejects the identical\n    bundle with `TIMESTAMP_ERROR`.\n- **Parity:** `full` — the disclosed trust gap (unauthenticated `integratedTime`\n  used for certificate validity / `timestampThreshold`) is demonstrated through\n  the real `@sigstore/verify` verification path with a concrete\n  expired-certificate-accepted outcome and a fixed-version negative control.\n- **Not demonstrated:** No code execution / memory corruption is claimed or\n  demonstrated; the impact is a verification/authenticity bypass (CWE-345).\n\n## Root Cause\n\n`toSignedEntity` (bundle/index.ts) turns every tlog entry with\n`integratedTime != '0'` into a `transparency-log` timestamp. `verifyTimestamps`\n(verifier.ts) then calls `getTLogTimestamp(entry)` (timestamp/index.ts) which,\nin the vulnerable code, unconditionally returns:\n\n```ts\nreturn {\n  type: 'transparency-log',\n  logID: entry.logId.keyId,\n  timestamp: new Date(Number(entry.integratedTime) * 1000),\n};\n```\n\nwith **no check that `entry.inclusionPromise` exists**. That result is counted\ntoward `timestampThreshold` and is returned to `verifySigningKey`, which calls\n`verifyCertificate(cert, timestamps, trustMaterial)` →\n`verifyCertificateChain(timestamp, leaf, CAs)` — i.e. the certificate's\nvalidity window is checked **at the attacker-chosen `integratedTime`**, not at\nthe current time and not at a cryptographically-bound time. Critically,\n`verify()` performs `verifyTimestamps` (and thus the certificate-validity\ndecision) **before** `verifyTLogs` (the inclusion-proof check), so the\nunauthenticated time is consumed before any check that could constrain it.\n\nOnly the `inclusionPromise`/SET path (`verifyTLogSET` in tlog/set.ts) signs\nover `integratedTime` (`body`, `integratedTime`, `logIndex`, `logID`). The\n`inclusionProof` path (`verifyCheckpoint` + `verifyMerkleInclusion`) verifies\nMerkle inclusion against a signed checkpoint but does **not** bind\n`integratedTime`. Therefore an inclusionProof-only entry can pass inclusion\nverification while leaving `integratedTime` fully attacker-controlled.\n\n**Fix commit:** `f074710` (\"reject integratedTime w/o inclusionPromise (#1659)\").\n\n```diff\n export function getTLogTimestamp(\n   entry: TransparencyLogEntry\n-): TimestampVerificationResult {\n+): TimestampVerificationResult | undefined {\n+  // Only entries with an inclusion promise provide a verifiable timestamp\n+  if (!entry.inclusionPromise) {\n+    return undefined;\n+  }\n+\n   return {\n     type: 'transparency-log',\n     logID: entry.logId.keyId,\n```\n\nand in `verifier.ts`, `verifyTimestamps` now only pushes the result when it is\ndefined and compares `timestamps.length` against `timestampThreshold` (so an\ninclusionProof-only entry no longer counts toward the threshold nor feeds\ncertificate validity).\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained; harness at\n   `bundle/repro/harness_test.ts`).\n2. **What it does:**\n   - Reuses/clones `sigstore/sigstore-js` into the durable project cache\n     (`<project_cache_dir>/repo`).\n   - Resolves the fixed commit `f074710` and its parent `7845532` (vulnerable,\n     `@sigstore/verify` 3.1.0).\n   - For each commit: `git checkout`, `npm ci` (if needed), `npm run build`\n     (builds the workspace so jest can resolve `@sigstore/bundle`/`core`/`jest`\n     via their `dist`), copies the harness into\n     `packages/verify/src/__tests__/cve_repro.test.ts`, and runs it via\n     `npx jest --selectProjects verify --testPathPatterns cve_repro.test.ts`.\n   - The harness (run at both commits) exercises the **real** `@sigstore/verify`\n     source and emits behavior markers to `$REPRO_LOG`:\n     - **Part A** — callsite: `getTLogTimestamp()` on an inclusionProof-only\n       entry with an attacker-chosen `integratedTime`.\n     - **Part B** — `Verifier.verify()` (public-key path) where the **sole**\n       timestamp is the inclusionProof-only entry's `integratedTime`\n       (`timestampThreshold:1`, `tlogThreshold:0`).\n     - **Part C** — `Verifier.verify()` (certificate path) on the **real**\n       cert-signed fixture `V3.MESSAGE_SIGNATURE.TLOG_HASHEDREKORDV002`,\n       mutated so the inclusionProof-only entry's `integratedTime` is a non-zero\n       value inside the (expired) certificate's validity window and the\n       RFC3161 timestamp is removed — making the unauthenticated\n       `integratedTime` the only timestamp source.\n   - Evaluates markers and writes `bundle/repro/runtime_manifest.json` and\n     `bundle/repro/validation_verdict.json`.\n3. **Expected evidence of reproduction:**\n   - `bundle/logs/canonical.log` (vulnerable) contains `[CALLSITE_HIT]` and\n     `[PROOF_MARKER]`, including the certificate-path line showing an expired\n     cert accepted via unauthenticated `integratedTime`.\n   - `bundle/logs/control.log` (fixed) contains `[NC_MARKER]` only\n     (`getTLogTimestamp` returns `undefined`; `Verifier.verify()` throws\n     `TIMESTAMP_ERROR`), with **no** `[PROOF_MARKER]`.\n   - Script exits 0.\n\n## Evidence\n\n- **Marker logs:**\n  - `bundle/logs/canonical.log`\n  - `bundle/logs/control.log`\n- **Raw jest output:**\n  - `bundle/logs/canonical_jest.log`\n  - `bundle/logs/control_jest.log`\n- **Build logs:** `bundle/logs/canonical_build.log`, `bundle/logs/control_build.log`\n- **Harness:** `bundle/repro/harness_test.ts`\n- **Manifest/verdict:** `bundle/repro/runtime_manifest.json`,\n  `bundle/repro/validation_verdict.json`\n\nKey excerpts (vulnerable canonical run, `bundle/logs/canonical.log`):\n\n```\n[CALLSITE_HIT]\n[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\n[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\n[CALLSITE_HIT]\n[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\n```\n\nKey excerpts (fixed control run, `bundle/logs/control.log`):\n\n```\n[NC_MARKER]: getTLogTimestamp() returned undefined for inclusionProof-only entry (no inclusionPromise) -> untrusted integratedTime rejected\n[NC_MARKER]: Verifier.verify() (public-key path) rejected with TIMESTAMP_ERROR -> inclusionProof-only integratedTime no longer counts toward timestampThreshold\n[NC_MARKER]: Verifier.verify() (certificate path) rejected with TIMESTAMP_ERROR -> inclusionProof-only integratedTime not counted as a trusted timestamp; no signed timestamp present\n```\n\n- **Environment:** Node v24.18.0, npm 11.16.0, jest 30 with `@swc/jest` (swc via\n  bundled wasm binding). Repo built with `tsc --build tsconfig.build.json`.\n  Vulnerable commit `7845532` (`@sigstore/verify` 3.1.0); fixed commit\n  `f074710` (`@sigstore/verify` 3.1.1). Marker counts:\n  canonical `CALLSITE_HIT=2 PROOF_MARKER=3`; control `NC_MARKER=3 PROOF_MARKER=0`.\n\n## Recommendations / Next Steps\n\n- **Upgrade:** Consumers of `@sigstore/verify` should upgrade to **>= 3.1.1**\n  (fix commit `f074710`).\n- **Defense in depth:** When accepting attacker-provided bundles, do not treat\n  `integratedTime` from inclusionProof-only entries as a trusted timestamp.\n  Require a signed `inclusionPromise`/SET or an RFC3161 timestamp for any\n  time-based decision (certificate validity, `timestampThreshold`).\n- **Testing recommendations:** Add regression tests that (a) feed an\n  inclusionProof-only entry with a non-zero `integratedTime` and assert\n  `getTLogTimestamp` returns `undefined`, and (b) assert a bundle whose only\n  timestamp is such an entry is rejected with `TIMESTAMP_ERROR`. The upstream\n  fix already added the `getTLogTimestamp` \"no inclusion promise -> undefined\"\n  test; the certificate-validity impact path demonstrated here is a useful\n  additional regression case.\n\n## Additional Notes\n\n- **Idempotency:** `reproduction_steps.sh` was run twice consecutively; both\n  runs exited 0 with identical marker counts (`canonical: CALLSITE_HIT=2,\n  PROOF_MARKER=3`; `control: NC_MARKER=3, PROOF_MARKER=0`). The script cleans\n  `packages/verify/dist` and `*.tsbuildinfo` per commit and re-copies the\n  harness, so runs are deterministic.\n- **Surface alignment:** The ticket's `claimed_surface` is `library_api` with\n  `required_entrypoint_kind=function_call`. The proof exercises the real\n  `@sigstore/verify` library functions (`getTLogTimestamp`, `toSignedEntity`,\n  `Verifier.verify`, `toTrustMaterial`) through a jest harness, including a\n  real cert-signed bundle fixture — matching the claimed surface.\n- **Scope/limitations:** The reproduction demonstrates the verification\n  authenticity/integrity bypass (an expired certificate accepted as valid via\n  an unauthenticated timestamp). It does **not** demonstrate code execution or\n  memory corruption (none is claimed). Part C uses a real sigstore bundle\n  fixture whose certificate is expired relative to the run time; the attacker\n  influence is the `integratedTime` value, which is the exact unauthenticated\n  field identified by the advisory.\n","cve_id":"CVE-2026-48816","cwe_id":"CWE-345 (Insufficient Verification of Data Authenticity)","source_url":"https://www.cve.org/CVERecord?id=CVE-2026-48816","package":{"name":"sigstore/sigstore-js","ecosystem":"github","fixed_version":"3.1.1"},"reproduced_at":"2026-07-02T19:50:52.050058+00:00","duration_secs":1134.0,"tool_calls":158,"handoffs":2,"total_cost_usd":2.5237709900000005,"agent_costs":{"hypothesis_generator":0.0121412,"judge":0.0202788,"repro":1.3740706800000002,"support":0.12774597,"vuln_variant":0.98953434},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0121412},"judge":{"gpt-5.4-mini":0.0202788},"repro":{"accounts/fireworks/routers/glm-5p2-fast":1.3740706800000002},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.12774597},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":0.98953434}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T19:50:52.865974+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":9773,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":13375,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15625,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":18065,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":996,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1410,"category":"other"},{"path":"bundle/repro/harness_test.ts","filename":"harness_test.ts","size":10495,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":976,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1078,"category":"other"},{"path":"bundle/logs/canonical_build.log","filename":"canonical_build.log","size":68,"category":"log"},{"path":"bundle/logs/canonical_jest.log","filename":"canonical_jest.log","size":1748,"category":"log"},{"path":"bundle/logs/control_build.log","filename":"control_build.log","size":68,"category":"log"},{"path":"bundle/logs/control_jest.log","filename":"control_jest.log","size":1353,"category":"log"},{"path":"bundle/logs/canonical.log","filename":"canonical.log","size":875,"category":"log"},{"path":"bundle/logs/control.log","filename":"control.log","size":482,"category":"log"},{"path":"bundle/logs/vuln_build.log","filename":"vuln_build.log","size":68,"category":"log"},{"path":"bundle/logs/vuln_variant_vuln_jest.log","filename":"vuln_variant_vuln_jest.log","size":2468,"category":"log"},{"path":"bundle/logs/fixed_build.log","filename":"fixed_build.log","size":68,"category":"log"},{"path":"bundle/logs/vuln_variant_fixed_jest.log","filename":"vuln_variant_fixed_jest.log","size":2427,"category":"log"},{"path":"bundle/logs/vuln_variant_idempotent_run.log","filename":"vuln_variant_idempotent_run.log","size":4542,"category":"log"},{"path":"bundle/logs/vuln_variant_rerun.log","filename":"vuln_variant_rerun.log","size":4542,"category":"log"},{"path":"bundle/logs/vuln_variant_vuln.log","filename":"vuln_variant_vuln.log","size":1436,"category":"log"},{"path":"bundle/logs/vuln_variant_fixed.log","filename":"vuln_variant_fixed.log","size":1396,"category":"log"},{"path":"bundle/logs/vuln_variant/summary.txt","filename":"summary.txt","size":744,"category":"other"},{"path":"bundle/vuln_variant/variant_harness.ts","filename":"variant_harness.ts","size":16342,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1774,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1953,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":914,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1248,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":10342,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4953,"category":"other"}]}