Skip to content

release: v0.9.1 — close two #135-class keyless verify bypasses + STPA-Sec report#136

Merged
avrabe merged 2 commits into
mainfrom
fix/keyless-verify-binds-artifact
May 25, 2026
Merged

release: v0.9.1 — close two #135-class keyless verify bypasses + STPA-Sec report#136
avrabe merged 2 commits into
mainfrom
fix/keyless-verify-binds-artifact

Conversation

@avrabe

@avrabe avrabe commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #135. This branch was started as the artifact-binding fix for the original bug (one byte flipped in a signed module returned exit 0 from wsc verify --keyless). Once that landed, an STPA-Sec pass on the keyless verify subsystem found a second #135-class bypass at the next layer up — the embedded Rekor entry was never cross-referenced against the bundle, so any unrelated public Rekor entry would pass.

Both fixes ship in this release. The STPA report is also included so the maintainer has a durable reference for the three other open UCAs that didn't land here.

Two security fixes (both #135-class)

Fix 1 — Artifact binding (initial commit on this branch)

KeylessVerifier::verify verified the Fulcio cert chain + Rekor SET, then accepted the module without recomputing its hash or ECDSA-verifying the signature against the leaf cert. New KeylessSignature::verify_artifact_binding strips the signature section, recomputes SHA-256, and verify_digests the signature under the leaf cert's SPKI. Wired as step 4 of verify().

Fix 2 — Rekor body binding / UCA-2 (second commit on this branch)

After fix 1, the verifier proved (cert+sig+module) were mutually consistent and (Rekor entry was logged by Rekor), but never decoded the entry's body to check that the logged (hash, signature, public key) triple actually referenced this bundle. An attacker holding any legitimate Fulcio cert could sign a malicious module and embed any unrelated public Rekor entry to pass verification:

  1. Attacker obtains a legitimate Fulcio cert for their own OIDC identity (any signed-in user can).
  2. Signs malicious module M' with their ephemeral key.
  3. Constructs a KeylessSignature with their genuine sig + cert + SHA256(M'), embedding any unrelated public Rekor entry.
  4. wsc verify --keyless returns exit 0.

The original #135 attack class re-emerges one layer up.

New KeylessSignature::verify_rekor_body_binds_to_bundle decodes the hashedrekord/0.0.1 body and asserts three equalities: artifact hash, signature content, and leaf-cert DER. Wired as step 5 of verify().

Tests

17 new tamper-rejection unit tests in src/lib/src/signature/keyless/format.rs, all passing. Coverage matrix:

Fix 1 (artifact binding) Fix 2 (Rekor body binding)
accept genuine signed module accept consistent body
reject byte-flipped module reject artifact-hash mismatch
reject corrupted signature blob reject signature-content mismatch
reject substituted module_hash field reject public-key mismatch
reject substituted leaf cert reject unsupported kind
reject module without signature section reject unsupported apiVersion
reject empty cert chain reject non-sha256 hash algorithm
reject garbage / non-base64 body
reject non-JSON body
accept uppercase hex-hash (Rekor sometimes returns these)

Full cargo test -p wsc: 620 tests pass (605 lib + 5 + 5 + 5), 0 failures.

Error variant design note

Both checks return WSError::VerificationFailed with a log::error! identifying the specific mismatch. The error variant is deliberately not discriminated (no HashMismatch / SignatureInvalid / BodyHashMismatch variants) — that would give an attacker an oracle to discover which leg of the check rejected their forgery while iterating. Operators get the detail via the log line.

STPA-Sec report (new)

docs/security/stpa-keyless-2026-05-25.md — five UCAs identified, all independently verified against HEAD. Each UCA has a code citation, STPA-4 questions, loss scenario, suggested fix, and a verification trail (grep commands + file:line that confirmed the gap):

UCA Severity Status Issue
UCA-1: Rekor inclusion proof never run on verify path High open (file as follow-up)
UCA-2: Rekor body never cross-checked with bundle Critical closed by this PR
UCA-3: Cert validity bound to attacker-pickable integrated_time High (conditional) open (file as follow-up)
UCA-4: Cache hit skips SET re-verify with no bundle-equality check Medium (conditional) open (file as follow-up)
UCA-5: Audit log records hash from silently-discarded serialize error Low open (file as follow-up)

Plus 4 speculative items the STPA explicitly couldn't confirm (multi-SAN selection, serialize determinism, two-signature-section asymmetry, Module::Clone completeness), 1 out-of-scope flag (sct.rs SCT verifier stub never invoked — Fulcio SCTs entirely unchecked, separate subsystem), and 7 properties explicitly cleared (skip-Rekor sentinel, chain depth bound, empty cert chain handling, negative-time rejection, etc.).

Version + release

This PR bumps the workspace version 0.9.0 → 0.9.1 (patch release for security fixes). CHANGELOG [0.9.1] — 2026-05-25 entry written. Once this merges, tag v0.9.1 will trigger the release workflow.

Downstream impact

pulseengine/synth #140 case 3 currently xfails wsc verify --keyless on a byte-flipped module. After this release ships, that assertion can flip back to "tampered file rejected" with no synth-side changes. (Note: synth would need a separate test for the UCA-2 attack — synth #140 case 3 only exercises Fix 1's behaviour.)

Disclosure

Both UCAs are verification-bypasses in the load-bearing keyless path. Affected versions: all releases that included KeylessVerifier::verify (back through v0.9.0 and earlier). Recommend GHSA + RUSTSEC advisory + yank of affected versions; flagging here rather than asserting.

🤖 Generated with Claude Code

KeylessVerifier::verify only checked that the embedded Fulcio cert
chain was valid and that a Rekor SET was present. It never recomputed
the module hash and never ECDSA-verified the signature blob against
the leaf cert's public key, so any tampered module — including the
issue's byte-flip repro and the broader case of splicing a signature
section from one artifact onto another — returned exit 0.

Adds KeylessSignature::verify_artifact_binding(module):
  1. detach_signature → serialize the stripped module → SHA256 →
     compare to the stored module_hash (reject on mismatch).
  2. Parse the leaf cert's SPKI as a P-256 VerifyingKey → ECDSA-verify
     the signature blob against the recomputed digest (reject on
     mismatch).

Wires it into verify() as step 4, between Rekor SET verification and
identity claim checks. Both failure modes return VerificationFailed
with a log::error! describing which check rejected; the error variant
does not distinguish to avoid leaking which leg a tampered artifact
tripped.

Seven new tamper-rejection unit tests in format.rs build a real
ECDSA P-256 keypair (via rcgen → p256), a real self-signed cert, and
a real signed module, then exercise: genuine accept, byte-flipped
module, corrupted signature, substituted module_hash, substituted
cert, missing signature section, empty cert chain. All pass.

Downstream synth #140 case 3 (the original repro) can flip from
xfail back to a positive assertion once a release containing this
fix ships.
@codecov

codecov Bot commented May 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.75286% with 71 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/lib/src/signature/keyless/format.rs 84.91% 65 Missing ⚠️
src/lib/src/signature/keyless/signer.rs 0.00% 6 Missing ⚠️

📢 Thoughts on this report? Let us know!

This second security commit on the issue #135 branch closes the
UCA-2 verification-bypass identified by STPA-Sec analysis after PR #136
landed the artifact-binding fix.

UCA-2: KeylessVerifier::verify proved the Rekor entry was logged
by Rekor (verify_set) and that the bundle's cert+sig+module were
mutually consistent (verify_artifact_binding from PR #136), but never
decoded the entry's body to check that the logged (hash, signature,
public key) triple actually referenced *this* bundle. Concrete attack:

  1. Attacker obtains a legitimate Fulcio cert for their own OIDC
     identity (any signed-in user can do this).
  2. Signs malicious module M' with their ephemeral key.
  3. Constructs a KeylessSignature with their genuine sig + cert +
     SHA256(M'), but embeds ANY unrelated public Rekor entry — e.g.
     the entry for someone else's hello-world module from 2024.
  4. wsc verify --keyless returns exit 0.

This re-emerges the #135 attack class one layer up.

The fix adds KeylessSignature::verify_rekor_body_binds_to_bundle in
format.rs, called from KeylessVerifier::verify as step 5 (after
artifact binding, before identity claims). It decodes the
hashedrekord/0.0.1 body and asserts three equalities:

  1. body.spec.data.hash.value == hex(self.module_hash) (sha256 algo)
  2. base64_decode(body.spec.signature.content) == self.signature
  3. Leaf cert DER from body.spec.signature.publicKey.content
     byte-equals self.cert_chain[0]'s DER (PEM-parsed for whitespace
     normalisation).

All failures emit WSError::VerificationFailed with a log::error!
identifying the mismatch. The error variant deliberately does not
discriminate — leaks no oracle to a forging attacker.

Ten new unit tests in format.rs cover the happy path plus tampering
each body field (hash, signature, public key), unsupported kind /
apiVersion / hash-algorithm, garbage / non-JSON body, and accept
genuine uppercase-hex hashes.

Also delivered with this release:

- docs/security/stpa-keyless-2026-05-25.md: full STPA-Sec analysis
  enumerating five UCAs against the keyless verify subsystem. UCA-2
  is closed by this commit; UCA-1 (Rekor inclusion proof never run
  on verify path), UCA-3 (cert validity bound to attacker-pickable
  integrated_time), UCA-4 (cache hit skips SET re-verify without
  bundle-equality check), and UCA-5 (audit log records hash from
  silently-discarded serialize error) remain open as follow-up
  issues. Each UCA includes a code citation, the STPA-4 questions,
  loss scenario, suggested fix, and a verification trail.

- Version bumped 0.9.0 -> 0.9.1 across workspace + internal pins.
  CHANGELOG entry under [0.9.1] documents both fixes and the new
  STPA report.

Full cargo test -p wsc: 605 + 5 + 5 + 5 = 620 tests pass, 0 failures.
@avrabe avrabe changed the title fix(keyless): bind verify to the artifact (issue #135) release: v0.9.1 — close two #135-class keyless verify bypasses + STPA-Sec report May 25, 2026
@avrabe avrabe merged commit bcd6d30 into main May 25, 2026
23 checks passed
@avrabe avrabe deleted the fix/keyless-verify-binds-artifact branch May 25, 2026 07:03
avrabe added a commit that referenced this pull request May 29, 2026
…sh error (v0.9.2)

Continues the STPA-Sec keyless-verify hardening from v0.9.1 (#136),
closing two of the three clear-cut follow-ups from
docs/security/stpa-keyless-2026-05-25.md.

UCA-4 (#139): bind the proof-cache key to the full Rekor entry content
via CacheKey::from_entry (every field, length-prefixed) instead of just
(module_hash, uuid) — both attacker-supplied in the bundle. A cache hit
can now only occur for a byte-identical entry that already passed SET
verification, removing the defence-in-depth loss where a mutated entry
rode a stale cache slot past the SET re-check on a cache hit.

UCA-5 (#140): propagate the module serialize error in the audit-hash
computation (? instead of .ok()), so a serialize failure aborts verify
instead of recording the SHA-256 of zero bytes as a false artifact
identity in the audit log and proof-cache key.

UCA-1 (#137) was scoped in but DESCOPED: wiring the existing Merkle
inclusion-proof verifier into verify() revealed it recomputes the wrong
root for fresh production Rekor entries on the log2025-* shards (Rekor v2
/ tiled-log migration) — the SET verifies but the computed root != the
proof's root_hash. Enabling it fail-closed would reject all legitimate
keyless signatures, so it stays unwired until the verifier is fixed.
Reclassified on #137. UCA-3 (#138) remains deferred (clock-policy).

Four new unit tests (3 cache-key binding + 1 fail-closed inclusion-proof
building block); 609 library tests pass. Clean-room verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wsc verify --keyless accepts tampered WASM (signed-payload byte flip not detected)

1 participant