Skip to content

blacklight: verified-streaming downloads anchored in Sigstore#1

Merged
andytriboletti merged 3 commits into
mainfrom
sandbox
Jul 4, 2026
Merged

blacklight: verified-streaming downloads anchored in Sigstore#1
andytriboletti merged 3 commits into
mainfrom
sandbox

Conversation

@andytriboletti

@andytriboletti andytriboletti commented Jul 4, 2026

Copy link
Copy Markdown
Member

What this is

blacklight — a download tool where a Sigstore-transparency-logged signature covers a BLAKE3 Merkle root that authorizes chunk-granular, abort-on-first-bad-byte verification during transfer from untrusted networks/mirrors.

  • publish — hashes the file, builds a 16 KiB-chunk-group outboard Merkle tree (.obao, ~0.4% overhead) via bao-tree, writes a small JSON manifest, and keyless-signs the manifest bytes via Sigstore (OIDC → Fulcio → Rekor), emitting an offline-verifiable v0.3 bundle.
  • fetch — verifies the bundle offline (signature, Fulcio cert chain, Rekor inclusion proof, pinned --expect-identity/--expect-issuer) before downloading a single artifact byte, then streams the artifact verifying every 16 KiB group the instant it arrives. First tampered group → abort (exit 3), partial file deleted, byte offset reported.

Proven behavior

  • Byte flipped at offset 16,000,000 of a 32 MiB file is caught at chunk group 976 (byte 15,990,784); curl | sha256sum must read all 32 MiB before its hash can mismatch. Reproduce with bash demo/run_demo.sh 32 16000000.
  • 13 tests (9 unit + 4 integration driving the real binary over local HTTP: tampered artifact, tampered outboard, truncated stream, clean round-trip).
  • fmt clean, clippy clean with -D warnings, --locked build/test.
  • gitleaks: no leaks in committed files.

Contents

  • src/ — the CLI (~1.4k LOC): manifest format, publish, streaming verifier (verify.rs, hand-rolled forward-only fail-fast walk on blake3::hazmat, unit-tested for equivalence with bao-tree's outboard), fetch, Sigstore wrapper.
  • demo/ — tampering MITM proxy + end-to-end attack demo with metrics.
  • paper/PAPER.md — research write-up with verified citations; docs/DESIGN.md — threat model, trust chain, formats; README with mermaid architecture diagrams.
  • .github/workflows/ci.yml — test matrix (ubuntu/macos), offline attack demo, and a signed round-trip job that exercises the full Sigstore path using the workflow's ambient OIDC identity.

Notes for review

  • The signed-roundtrip CI job runs the sign→Rekor→verify path against live Sigstore staging for the first time in CI (it cannot run locally without a browser); if anything in this PR needs iteration, expect it there.
  • Rollback/freshness protection and log monitoring are explicitly out of scope for v1 (documented in DESIGN.md §7 and the paper's limitations).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added publish and fetch for verified streaming downloads with keyless signing and offline integrity checks.
    • Implemented fail-fast tamper detection (distinct exit code on integrity/verification/length mismatches).
    • Added an end-to-end MITM tampering demo and an evil proxy simulator.
  • Bug Fixes
    • Prevents partially written or corrupted output by using atomic “partial output” handling and strict length checks.
  • Documentation
    • Reworked README and added DESIGN and PAPER references; added license files.
  • Tests / CI
    • Added integration tampering tests and a GitHub Actions CI workflow covering cross-platform lint/tests plus demo and signed roundtrip.

A download tool where a transparency-logged signature covers a BLAKE3
Merkle root that authorizes chunk-granular, abort-on-first-bad-byte
verification during transfer from untrusted networks and mirrors.

- publish: builds a 16 KiB-group outboard Merkle tree (bao-tree),
  writes a signed manifest, keyless-signs it via Sigstore (Fulcio +
  Rekor), emitting an offline-verifiable v0.3 bundle
- fetch: verifies the bundle offline (signature, cert chain, Rekor
  inclusion proof, pinned identity/issuer) before downloading, then
  streams the artifact verifying every 16 KiB group as it arrives;
  aborts on the first tampered group (exit 3), deletes the partial
- hand-rolled forward-only verifier on blake3::hazmat, unit-tested
  for round-trip equivalence with bao-tree's outboard format
- attack demo: tampering MITM proxy + metrics vs curl|sha256sum
- research paper (paper/PAPER.md) with verified citations, design
  doc, README with mermaid architecture diagrams
- CI: fmt/clippy/test matrix, offline attack demo, and a signed
  round-trip job using GitHub Actions ambient OIDC against Sigstore
- 13 tests passing (9 unit + 4 integration); gitleaks-scanned clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 529553ac-38a9-4f2b-92cd-7edb91768a80

📥 Commits

Reviewing files that changed from the base of the PR and between 5e66070 and 952f818.

📒 Files selected for processing (1)
  • demo/evil_proxy.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • demo/evil_proxy.py

📝 Walkthrough

Walkthrough

This PR adds the blacklight Rust project: a manifest-based publish/fetch flow, Sigstore signing and offline verification, streamed chunk-group integrity checks, integration tests, a tamper demo, CI jobs, and supporting documentation.

Changes

Blacklight Verified Streaming Downloads

Layer / File(s) Summary
Project scaffolding and licenses
Cargo.toml, .gitignore, LICENSE-APACHE, LICENSE-MIT
Adds the new Rust package metadata, dependencies, /target ignore rule, and dual license text.
Manifest, Sigstore, and streaming plan
src/manifest.rs, src/sigstore.rs, src/verify.rs
Defines the manifest format, keyless Sigstore signing and offline verification, and the chunk-group/outboard verification engine with tests.
Publish, fetch, and CLI wiring
src/publish.rs, src/fetch.rs, src/main.rs
Implements publishing, streamed verified fetching, and the CLI entrypoint that dispatches both subcommands.
Integration tests, demo, and CI
tests/tamper.rs, demo/evil_proxy.py, demo/run_demo.sh, .github/workflows/ci.yml
Adds end-to-end tamper tests, the MITM attack demo scripts, and the GitHub Actions workflow covering test, demo, and signed round-trip jobs.
README, design, and paper
README.md, docs/DESIGN.md, paper/PAPER.md
Adds the project overview, design reference, and paper draft describing the trust chain, threat model, execution flow, evaluation, and limitations.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Publisher
  participant Sigstore
  participant Origin
  participant blacklight as blacklight fetch
  participant Verifier as GroupPlan

  Publisher->>Publisher: build outboard tree + manifest
  Publisher->>Sigstore: sign manifest bytes
  Sigstore-->>Publisher: bundle JSON
  Publisher->>Origin: upload manifest, outboard, bundle

  blacklight->>Origin: load manifest bytes
  blacklight->>Sigstore: verify_manifest
  Sigstore-->>blacklight: verified identity and issuer
  blacklight->>Origin: load outboard bytes
  blacklight->>Verifier: GroupPlan::new(root, size, outboard)
  loop streamed groups
    blacklight->>Origin: GET artifact bytes
    Origin-->>blacklight: 16 KiB group
    blacklight->>Verifier: check_group(index, data)
    Verifier-->>blacklight: match or tamper error
  end
  blacklight->>blacklight: flush, sync, rename output
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely captures the main change: a verified-streaming download tool anchored in Sigstore.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sandbox

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
src/sigstore.rs (1)

54-65: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Ambient-detection errors are silently converted into a browser flow.

The _ => arm catches both Ok(None) and Err(_). A genuine failure in detect_ambient() (e.g. a misconfigured CI OIDC provider) will be discarded and fall through to the interactive browser flow, which hangs in a headless CI runner instead of surfacing the real error. Consider matching Err(e) explicitly and logging it before falling back.

♻️ Surface the ambient error
     let token: IdentityToken = match IdentityToken::detect_ambient().await {
         Ok(Some(t)) => {
             eprintln!("  using ambient CI OIDC identity");
             t
         }
+        Err(e) => {
+            eprintln!("  ambient OIDC detection failed ({e}); opening browser …");
+            get_identity_token(context.config().oidc_url.as_deref())
+                .await
+                .context("could not obtain an OIDC identity token")?
+        }
         _ => {
             eprintln!("  opening browser for OIDC sign-in …");
             get_identity_token(context.config().oidc_url.as_deref())
                 .await
                 .context("could not obtain an OIDC identity token")?
         }
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sigstore.rs` around lines 54 - 65, The ambient OIDC detection in the
`IdentityToken::detect_ambient` match is hiding real failures by treating
`Err(_)` the same as `Ok(None)`. Update the match in this token acquisition flow
to handle `Err(e)` explicitly, log or surface that error before any fallback,
and only open the browser for the true no-ambient case (`Ok(None)`), so the
`get_identity_token` path is not used to mask detection failures.
demo/evil_proxy.py (1)

37-37: 🩺 Stability & Availability | 🔵 Trivial

Consider a request timeout.

urlopen(upstream) has no timeout; a stalled/hanging origin would block the handler thread indefinitely. Low-impact for a demo tool, but a cheap robustness improvement.

♻️ Add a timeout
-            with urllib.request.urlopen(upstream) as resp:
+            with urllib.request.urlopen(upstream, timeout=30) as resp:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demo/evil_proxy.py` at line 37, Add a timeout to the upstream fetch in
evil_proxy.py so a stalled origin cannot block the handler thread indefinitely.
Update the urlopen call inside the request handling path to pass a reasonable
timeout, and keep the behavior otherwise unchanged in the same handler logic
that uses urllib.request.urlopen(upstream).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Line 23: The workflow uses actions/checkout@v4 without disabling credential
persistence, leaving GITHUB_TOKEN available in git config for the rest of each
job. Update every checkout step in the CI workflow to add a with block on
actions/checkout@v4 that sets persist-credentials to false, including the
checkout used by signed-roundtrip and the other two checkout invocations.
- Around line 1-61: Add a restrictive default permissions block for the workflow
so the `test` and `attack-demo` jobs do not inherit broader `GITHUB_TOKEN`
scope. Update the top-level workflow permissions in CI to read-only access, and
ensure the existing jobs (`test`, `attack-demo`) continue to work without any
extra write permissions.

In `@demo/run_demo.sh`:
- Around line 66-74: SCENARIO 2 in run_demo.sh only reports a bug instead of
enforcing the security check. After the "$BL fetch" call and BL_EXIT capture,
make the script fail if the exit code is not 3 or if "$WORK/tampered.out"
exists, so the tampering demo actually asserts the expected integrity violation.
Keep the current set +e/set -e pattern around fetch, but add explicit failure
handling in the scenario block using the existing BL_EXIT and tampered.out
checks.

In `@src/fetch.rs`:
- Around line 251-262: `resolve_artifact_url` can return a local filesystem
path, but `stream_verified_inner` still hands that value to `reqwest::get`,
which only works for URLs. Update the artifact streaming flow to either detect
local paths before the request and read/stream them from disk, or have
`resolve_artifact_url`/`stream_verified_inner` reject non-URL sources with a
clear error message. Use the existing `resolve_artifact_url` and
`stream_verified_inner` symbols to keep the fix localized.
- Around line 136-141: The mirror fetch path currently uses the bare
reqwest::get helper, which can block indefinitely on slow or hostile servers.
Update the fetch logic in the relevant mirror download flow, especially the code
in stream_verified_inner() and load(), to use a shared reqwest::Client instead
of reqwest::get. Configure that client with connect_timeout and read_timeout,
and thread it through the existing fetch helper so all mirror requests inherit
the timeout behavior.

In `@src/manifest.rs`:
- Around line 26-27: The `Manifest::from_bytes` parsing path must enforce the
basename-only contract for `Manifest.name`, since `fetch()` later uses
`PathBuf::from(&manifest.name)` as the default output target. Update
`Manifest::from_bytes` to reject any `name` containing path separators or path
traversal components like `.` and `..`, and ensure the validation keeps `name`
aligned with its documented meaning before the manifest is accepted.

In `@tests/tamper.rs`:
- Around line 67-72: The tampering guard in tamper logic uses `if let` chaining,
which is newer than the stated MSRV. Update the conditional in `tamper.rs` to
use older-compatible nested `if let`/`if` checks around the `tamper` tuple,
`path` comparison, and `off < body.len()` test, or alternatively bump the
documented MSRV to match the language feature if that is the intended direction.

---

Nitpick comments:
In `@demo/evil_proxy.py`:
- Line 37: Add a timeout to the upstream fetch in evil_proxy.py so a stalled
origin cannot block the handler thread indefinitely. Update the urlopen call
inside the request handling path to pass a reasonable timeout, and keep the
behavior otherwise unchanged in the same handler logic that uses
urllib.request.urlopen(upstream).

In `@src/sigstore.rs`:
- Around line 54-65: The ambient OIDC detection in the
`IdentityToken::detect_ambient` match is hiding real failures by treating
`Err(_)` the same as `Ok(None)`. Update the match in this token acquisition flow
to handle `Err(e)` explicitly, log or surface that error before any fallback,
and only open the browser for the true no-ambient case (`Ok(None)`), so the
`get_identity_token` path is not used to mask detection failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5fccce62-01bc-4c83-914b-9eab21da5d4f

📥 Commits

Reviewing files that changed from the base of the PR and between dc40495 and 852daa9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • .github/workflows/ci.yml
  • .gitignore
  • Cargo.toml
  • LICENSE-APACHE
  • LICENSE-MIT
  • README.md
  • demo/evil_proxy.py
  • demo/run_demo.sh
  • docs/DESIGN.md
  • paper/PAPER.md
  • src/fetch.rs
  • src/main.rs
  • src/manifest.rs
  • src/publish.rs
  • src/sigstore.rs
  • src/verify.rs
  • tests/tamper.rs

Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml
Comment thread demo/run_demo.sh Outdated
Comment thread src/fetch.rs Outdated
Comment thread src/fetch.rs
Comment thread src/manifest.rs
Comment thread tests/tamper.rs Outdated
Security / correctness:
- manifest: reject path-traversal / non-basename `name` values before a
  hostile manifest can steer `fetch`'s default output path (adds tests)
- fetch: stream local-path artifacts from disk instead of handing a
  filesystem path to reqwest::get (which only speaks http)
- fetch: use a shared reqwest::Client with connect_timeout + read_timeout
  so a slow/hostile mirror cannot block indefinitely
- sigstore: surface ambient-OIDC detection errors instead of masking them
  as "no identity" and silently falling back to a browser flow

CI hardening:
- add top-level read-only `permissions`; signed-roundtrip keeps id-token
- set persist-credentials: false on all checkout steps

Demo / misc:
- run_demo.sh: SCENARIO 2 now asserts (exit 3 + no output file), not just
  reports
- evil_proxy.py: add a 30s upstream urlopen timeout
- tests: replace the let-chain with a match guard to stay within MSRV

15 tests pass (11 unit + 4 integration); fmt + clippy -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
demo/evil_proxy.py (1)

36-78: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Broad except after headers are already sent can corrupt the response.

send_response/end_headers() run at lines 39-48, before streaming starts. If anything fails during the streaming loop that isn't a BrokenPipeError/ConnectionResetError (e.g. upstream read timeout, an unexpected write error), the outer except at Line 77 calls self.send_error(502, ...), which writes another status line/headers onto a socket that has already sent headers and possibly partial body — producing a malformed response or a secondary exception that masks the real failure. Since this demo is exercised in CI, this can turn a real upstream/network hiccup into a confusing flaky failure instead of a clear log message.

🩹 Proposed fix: track whether headers were sent
     def do_GET(self):
         upstream = ARGS.origin.rstrip("/") + self.path
         tamper = self.path == ARGS.target
+        headers_sent = False
         try:
             with urllib.request.urlopen(upstream, timeout=30) as resp:
                 body_len = resp.headers.get("Content-Length")
                 self.send_response(resp.status)
                 # Force plain streaming: known length, no transfer-encoding games.
                 for k, v in resp.headers.items():
                     if k.lower() in ("transfer-encoding", "connection", "content-length"):
                         continue
                     self.send_header(k, v)
                 if body_len is not None:
                     self.send_header("Content-Length", body_len)
                 self.send_header("Connection", "close")
                 self.end_headers()
+                headers_sent = True
 
                 if tamper:
                     ...
         except Exception as e:  # noqa: BLE001 - demo tool, surface anything
-            self.send_error(502, f"upstream error: {e}")
+            if headers_sent:
+                sys.stderr.write(f"[evil_proxy] upstream error mid-stream: {e}\n")
+            else:
+                self.send_error(502, f"upstream error: {e}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demo/evil_proxy.py` around lines 36 - 78, The streaming path in evil_proxy’s
request handler can emit a second HTTP response after headers have already been
sent. Update the try/except around the urllib.request.urlopen + streaming loop
so that once send_response/end_headers has run, later failures in the body
stream do not call self.send_error; instead, log the error and close the
connection (or only send_error before headers are committed). Use the existing
request-handler logic around self.send_response, end_headers, and the streaming
loop to distinguish pre-header and post-header failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@demo/evil_proxy.py`:
- Around line 36-78: The streaming path in evil_proxy’s request handler can emit
a second HTTP response after headers have already been sent. Update the
try/except around the urllib.request.urlopen + streaming loop so that once
send_response/end_headers has run, later failures in the body stream do not call
self.send_error; instead, log the error and close the connection (or only
send_error before headers are committed). Use the existing request-handler logic
around self.send_response, end_headers, and the streaming loop to distinguish
pre-header and post-header failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 02011038-9b85-4327-923c-4a8d495fa21e

📥 Commits

Reviewing files that changed from the base of the PR and between 852daa9 and 5e66070.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • demo/evil_proxy.py
  • demo/run_demo.sh
  • src/fetch.rs
  • src/manifest.rs
  • src/sigstore.rs
  • tests/tamper.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • demo/run_demo.sh
  • src/manifest.rs
  • src/fetch.rs
  • tests/tamper.rs
  • .github/workflows/ci.yml
  • src/sigstore.rs

The except block wrapped the whole streaming loop, so an upstream read
failure mid-body would call send_error() and emit a second status line
into an already-started 200 response. Track whether headers were sent;
after that point, log the error and drop the connection instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@andytriboletti
andytriboletti merged commit a890899 into main Jul 4, 2026
5 checks passed
@andytriboletti
andytriboletti deleted the sandbox branch July 4, 2026 20:04
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.

1 participant