diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d523125 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Least privilege by default: read-only token for all jobs. The one job that +# needs more (signed-roundtrip, which mints an OIDC id-token) overrides this. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + # Build, test, and lint on each supported OS. + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@v2 + + - name: Format check + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Build + run: cargo build --release --locked + + - name: Test + run: cargo test --locked + + # The full offline attack demo: publish (unsigned), serve, tamper mid-stream, + # confirm blacklight aborts. No Sigstore/OIDC needed here, so it runs on PRs. + attack-demo: + name: attack demo (verified streaming) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run the tampering-proxy demo + run: bash demo/run_demo.sh 32 16000000 + + # Exercises the piece that cannot be driven locally without a browser: the + # full SIGNED round-trip. GitHub Actions has an ambient OIDC identity, so + # `blacklight publish` signs keyless against Sigstore (no browser), and + # `blacklight fetch` then verifies the Rekor-anchored bundle and streams the + # artifact with the signer identity pinned to this workflow. + signed-roundtrip: + name: signed round-trip (Sigstore staging) + runs-on: ubuntu-latest + # Only when we have an ambient identity: pushes/dispatch on this repo, not + # fork PRs (which cannot mint id-tokens). + if: github.event_name != 'pull_request' + permissions: + contents: read + id-token: write # required for keyless Sigstore signing + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --release --locked + + - name: Publish + sign against Sigstore staging (ambient OIDC) + run: | + set -euo pipefail + head -c 4000000 /dev/urandom > artifact.bin + ./target/release/blacklight publish artifact.bin --out dist + echo "--- produced sidecars ---" + ls -l dist/ + test -f dist/artifact.bin.blacklight.json.sigstore.json \ + || { echo "no Sigstore bundle was produced"; exit 1; } + + - name: Serve the published artifact locally + run: | + cp artifact.bin dist/artifact.bin + ( cd dist && python3 -m http.server 8080 --bind 127.0.0.1 &>/tmp/serve.log & ) + # Wait for the server to accept connections. + for i in $(seq 1 20); do + curl -sf -o /dev/null "http://127.0.0.1:8080/artifact.bin.blacklight.json" && break + sleep 0.5 + done + + - name: Fetch + verify the signed bundle, pinning this workflow's identity + run: | + set -euo pipefail + # In GitHub Actions the Fulcio cert's SAN is the workflow ref. + IDENTITY="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/.github/workflows/ci.yml@${GITHUB_REF}" + ISSUER="https://token.actions.githubusercontent.com" + echo "expecting identity: $IDENTITY" + echo "expecting issuer: $ISSUER" + ./target/release/blacklight fetch \ + "http://127.0.0.1:8080/artifact.bin.blacklight.json" \ + --expect-identity "$IDENTITY" \ + --expect-issuer "$ISSUER" \ + -o fetched.bin + cmp artifact.bin fetched.bin + echo "signed round-trip verified end-to-end." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8c933e9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3182 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "ambient-id" +version = "0.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1daa54020e05aa0b163ee10434fff35a0f18d28a1cafa142bd1290e1abe630e" +dependencies = [ + "astral-reqwest-middleware", + "reqwest", + "secrecy", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "astral-reqwest-middleware" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98e1c6be25cfbf1bb4fea1a9da51bc05d3259a9062df4e53f54e5607895e33c9" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror", + "tower-service", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "bao-tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b" +dependencies = [ + "blake3", + "bytes", + "futures-lite", + "genawaiter", + "iroh-io", + "positioned-io", + "range-collections", + "self_cell", + "serde", + "smallvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "binary-merge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blacklight" +version = "0.1.0" +dependencies = [ + "anyhow", + "bao-tree", + "blake3", + "clap", + "futures-util", + "reqwest", + "serde", + "serde_json", + "sigstore-oidc", + "sigstore-sign", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "thiserror", + "tokio", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", + "genawaiter-proc-macro", + "proc-macro-hack", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "genawaiter-proc-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738" +dependencies = [ + "proc-macro-error", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inplace-vec-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307" +dependencies = [ + "smallvec", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iroh-io" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a" +dependencies = [ + "bytes", + "futures-lite", + "pin-project", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "positioned-io" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-collections" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0" +dependencies = [ + "binary-merge", + "inplace-vec-builder", + "ref-cast", + "smallvec", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18b51b097b7e9d898efad51e1031c855ce4008b4866d0c38ac8b0fac5745a84c" +dependencies = [ + "base64", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror", +] + +[[package]] +name = "sigstore-crypto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1210ea5dd0dd07b1466449a6c5c79000f8ca10ac30b4e8f64031c3bed4c5c662" +dependencies = [ + "aws-lc-rs", + "base64", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.9.5", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-fulcio" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1804a3030159d6c165f7af4fb753a780d4d114b4d16320c0ea2acc1b5af8fea" +dependencies = [ + "base64", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-oidc", + "sigstore-types", + "thiserror", + "url", +] + +[[package]] +name = "sigstore-merkle" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae98f8a270885497dbe84e6dae10fa29066df0388f62980923728e38d844716" +dependencies = [ + "base64", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror", +] + +[[package]] +name = "sigstore-oidc" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e65508b946df747b804f770bb83243ecfcfd61150adc9b27c5dca59d74a117" +dependencies = [ + "ambient-id", + "base64", + "open", + "rand", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sigstore-rekor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1c153f4f89f7570d0f625f660cadcb1b2fba72583f378d2d2b308c752ac45f" +dependencies = [ + "base64", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror", + "url", +] + +[[package]] +name = "sigstore-sign" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ec818202811f9440abf6f7714d187b0248268a5472ea481d3c1d33f51f1fd5" +dependencies = [ + "base64", + "serde_json", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-fulcio", + "sigstore-oidc", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror", + "x509-cert", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded6afc37295a10dba6569e033dd15f85f04f00980bfcf2c64009bc8ed279985" +dependencies = [ + "base64", + "directories", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-tuf", + "sigstore-types", + "thiserror", + "tokio", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88505b71600a791e52bed3777ed9cfeefd4b03dee6575d38c5542ae80e2bbdc" +dependencies = [ + "aws-lc-rs", + "base64", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "jiff", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc7cced094bd306ef561312cfffa3ea05ac7eea4a62cfa81680c07a10addbc0" +dependencies = [ + "globset", + "hex", + "jiff", + "reqwest", + "serde", + "serde_json", + "sha2", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "sigstore-types" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f034e86c3b8d91b32608c83f4a18939eed32c6ca08d17d2f52b88be365aac6" +dependencies = [ + "base64", + "hex", + "pem", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "sigstore-verify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4720f300d093ecac6dcd75d4498fd1b2d9133600e679837bb9d944a4229eda7" +dependencies = [ + "base64", + "cms", + "const-oid", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror", + "tls_codec", + "tracing", + "x509-cert", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2c2c0c6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "blacklight" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "Verified-streaming downloads anchored in the Sigstore transparency log — tamper is caught mid-transfer, at the first bad byte, by a publisher you can name." +repository = "https://github.com/greenrobotllc/blacklight" +readme = "README.md" +keywords = ["sigstore", "transparency-log", "blake3", "integrity", "supply-chain"] +categories = ["command-line-utilities", "cryptography"] + +[dependencies] +anyhow = "1.0.103" +bao-tree = "0.16.0" +blake3 = "1.8.5" +clap = { version = "4.6.1", features = ["derive"] } +futures-util = "0.3.32" +reqwest = { version = "0.13.4", default-features = false, features = ["stream", "http2", "rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sigstore-oidc = "0.10.0" +sigstore-sign = "0.10.0" +sigstore-trust-root = "0.10.0" +sigstore-types = "0.10.0" +sigstore-verify = "0.10.0" +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "fs", "io-util"] } diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..3fe73c3 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Andy Triboletti + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..a5c61cf --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andy Triboletti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 509bf31..815dd1e 100644 --- a/README.md +++ b/README.md @@ -1 +1,263 @@ -# blacklight \ No newline at end of file +# blacklight + +**Verified-streaming downloads anchored in the Sigstore transparency log — +tamper is caught mid-transfer, at the first bad byte, by a publisher you can +name.** + +## Why + +The folk instinct is right: *"check the hash and a hacker can't tamper with your +download."* The usual ways of doing it are not. + +- **MD5 is collision-broken.** An attacker can build a malicious file that + matches a published MD5. Even SHA-256 doesn't save you if the checksum is + delivered the wrong way (next point). +- **An in-band checksum is replaceable by the same MITM.** If the `SHA256SUMS` + file sits next to the artifact on the same mirror, whoever can rewrite the + artifact can rewrite the checksum too. Both ride the same untrusted channel; + there's no independent anchor. +- **TLS blinds the network layer instead of securing the artifact.** TLS + authenticates *the server you reached* and encrypts the hop — but a malicious + or compromised CDN edge or mirror serves tampered bytes over perfectly valid + TLS, and TLS gives you no way to check what actually arrived. + +So integrity has to be **end-to-end** — verified at *your* machine against an +out-of-band anchor — and the signature over the reference hash has to be +**publicly auditable**, so you can name who published it and detect a +compromised key after the fact. + +blacklight does exactly that: a Sigstore-transparency-logged signature covers a +BLAKE3 Merkle root, and every 16 KiB chunk of the download is checked against +that signed root **as it streams in**. + +## How it works + +- **Verified streaming.** The file's BLAKE3 Merkle tree is precomputed into a + tiny `.obao` sidecar (~0.4% overhead). On download, each 16 KiB chunk group is + hashed and checked against the signed root the instant it arrives — the bytes + from the untrusted mirror are never trusted, only verified. +- **Transparency-logged signature.** The root lives in a small JSON manifest, + keyless-signed via Sigstore (OIDC → Fulcio short-lived cert → **Rekor public + transparency log**). The client verifies the signature, cert chain, and Rekor + inclusion proof **offline** against an embedded trusted root, and enforces the + exact signer identity + issuer you demand *before downloading a single byte*. +- **Abort on the first bad byte.** On the first tampered chunk group, blacklight + aborts the transfer (exit code 3), deletes the partial file, and reports the + byte offset — having read at most one 16 KiB group past the tampering. A naive + `curl | sha256sum` must download the *entire* file before its hash can + mismatch. + +## Architecture + +### The big picture + +The publisher signs once; the four resulting files can be hosted anywhere — +including hosts you assume are hostile — because the downloader verifies +everything locally: + +```mermaid +flowchart LR + subgraph PUB["Publisher (has an OIDC identity)"] + A["artifact"] --> P["blacklight publish"] + P --> OB[".obao\noutboard Merkle tree\n(~0.4% of file size)"] + P --> M["manifest\n.blacklight.json\n{name, size, blake3_root}"] + P --> B["Sigstore bundle\n.sigstore.json\n(sig + cert + log proof)"] + end + + subgraph SIG["Sigstore infrastructure"] + F["Fulcio CA\n(short-lived cert\nfor OIDC identity)"] + R["Rekor\npublic transparency log"] + end + P -. "sign manifest keyless" .-> F + F -. "signature logged" .-> R + R -. "inclusion proof\nstapled into bundle" .-> B + + subgraph HOST["Any host / mirror / CDN — UNTRUSTED"] + H1["artifact"] + H2[".obao"] + H3["manifest"] + H4["bundle"] + end + A --> H1 + OB --> H2 + M --> H3 + B --> H4 + + subgraph CLI["Downloader"] + C["blacklight fetch\nverifies EVERYTHING locally;\ntrusts no byte from the host"] + end + H1 --> C + H2 --> C + H3 --> C + H4 --> C +``` + +### The trust chain + +Every link is verified before the next is trusted, from a human-meaningful +identity all the way down to each 16 KiB group of bytes: + +```mermaid +flowchart TD + I["OIDC identity\n(e.g. you@example.com,\nor a CI workflow)"] + C["Fulcio short-lived certificate\nbinds identity to an ephemeral key"] + S["Signature over the exact\nmanifest bytes"] + R["Rekor inclusion proof\nsignature is in the public,\nappend-only transparency log"] + RT["blake3_root\nthe file's BLAKE3 Merkle root,\ninside the signed manifest"] + OBV["Outboard tree (.obao)\nchecked to hash up to the\nsigned root before streaming"] + L["Per-group leaf hashes\none per 16 KiB chunk group"] + G["Each streamed 16 KiB group\nverified the instant it arrives\nfirst mismatch = abort, exit 3"] + + I -->|"issuer + SAN pinned by\n--expect-identity / --expect-issuer"| C + C --> S + S --> R + R --> RT + RT --> OBV + OBV --> L + L --> G +``` + +### What `fetch` actually does + +```mermaid +sequenceDiagram + autonumber + participant BL as blacklight fetch + participant M as Mirror / CDN (untrusted) + participant D as Local disk + + BL->>M: GET manifest + bundle + M-->>BL: manifest bytes + Sigstore bundle + Note over BL: Verify bundle OFFLINE:
signature over manifest bytes,
Fulcio cert chain, Rekor inclusion
proof, pinned identity + issuer.
Any failure → stop before
downloading a single artifact byte. + + BL->>M: GET .obao (outboard tree) + M-->>BL: outboard bytes + Note over BL: Check the outboard hashes up
to the signed blake3_root.
Tampered tree dies here. + + BL->>M: GET artifact (streamed) + loop every 16 KiB chunk group + M-->>BL: group bytes + Note over BL: hash group (BLAKE3 chaining value),
compare against the verified tree + BL->>D: append verified group to temp file + end + + alt a group does not match (tampering) + Note over BL,M: ABORT transfer immediately —
at most one 16 KiB group read past
the tampered byte + BL->>D: delete temp file + Note over BL: exit code 3, report byte offset + else all groups match + length equals signed size + BL->>D: atomic rename into place + Note over BL: exit 0 — every byte verified + end +``` + +## Install + +```sh +cargo build --release +# binary at ./target/release/blacklight +``` + +Rust edition 2024 (MSRV ~1.85+). + +## Quickstart + +### Publish + +Hash a file, build its outboard tree, write the manifest, and keyless-sign it: + +```sh +# Signs via Sigstore STAGING by default (add --production for the public-good +# instance). Signing is keyless: ambient CI OIDC if present, else a browser. +blacklight publish demo.bin --url https://mirror.example.org/demo.bin +``` + +This produces four files to host — the artifact plus three sidecars: + +```text +demo.bin # the artifact (unchanged) +demo.bin.obao # outboard BLAKE3 Merkle tree +demo.bin.blacklight.json # signed manifest {root, size, geometry} +demo.bin.blacklight.json.sigstore.json # Sigstore v0.3 bundle +``` + +For local testing without signing: `blacklight publish demo.bin --unsigned` +(emits only the `.obao` + manifest). + +### Fetch + +Download and verify, pinning *who* must have signed the root: + +```sh +blacklight fetch https://mirror.example.org/demo.bin.blacklight.json \ + --expect-identity you@example.com \ + --expect-issuer https://accounts.google.com \ + -o demo.bin +``` + +blacklight verifies the bundle offline, enforces the identity + issuer policy +**before** touching the artifact, then streams and verifies every group. Use +`--production` to verify against the Sigstore production trust root, and +`--allow-unsigned` to skip signature verification entirely (**dangerous** — the +download is still integrity-checked against the manifest root, but nothing proves +*who* published that root). + +## See it catch an attack + +```sh +bash demo/run_demo.sh +``` + +The demo publishes a file, serves it from an honest origin, and fetches it twice: +once directly (succeeds, byte-identical) and once through a tampering MITM proxy +that flips one byte mid-stream. blacklight aborts at the first bad 16 KiB group, +leaving no output file — and it's contrasted against `curl | sha256sum`, which +must read the whole file first. Sample metrics (32 MiB file, byte flipped at +offset 16,000,000): + +```text +================== DETECTION METRICS ================== +tampered byte offset 16000000 +total artifact size (bytes) 33554432 +blacklight: bytes consumed before 16007168 + detection (one 16 KiB group) +curl+sha256: bytes consumed before 33554432 + detection (whole file) +data blacklight avoided reading 2.1x less +``` + +The tampered byte at offset 16,000,000 lands in **chunk group 976** (byte offset +15,990,784); blacklight aborts there, having verified only ~16 MB, while +`curl | sha256sum` reads all 32 MB before its hash mismatches. Measured outboard +overhead: **0.39%**. The test suite (`cargo test`) is 13 tests — 9 unit plus 4 +integration that drive the real binary over a local HTTP server, including +tampered-artifact, tampered-outboard, and truncated-stream attacks. + +## What it does NOT do yet + +- **No rollback/freshness protection.** A validly signed *older* version replays + cleanly — that's TUF's domain. +- **No active log monitoring.** A compromised signing identity is *detectable* + (every signature is in Rekor) but blacklight doesn't watch the log for you. +- **Keyless signing only.** sigstore-rust 0.10 has no self-managed-key path. +- **No outboard redundancy.** A withheld `.obao` fails the fetch closed. + +## Design & background + +- [`docs/DESIGN.md`](docs/DESIGN.md) — full threat model, the end-to-end trust + chain, on-disk/on-wire formats, and the `fetch` state machine. +- [`paper/PAPER.md`](paper/PAPER.md) — the write-up and motivation. + +## Honesty note + +Every cryptographic primitive here pre-exists and is battle-tested: BLAKE3 for +hashing and tree math, `bao-tree` (n0-computer, the engine behind iroh-blobs) for +the outboard tree, and Sigstore (Fulcio + Rekor, via sigstore-rust) for keyless +transparency-logged signatures. **blacklight's contribution is the +composition** — wiring a transparency-logged signature over a BLAKE3 root into a +forward-only, fail-fast, abort-on-first-bad-byte streaming verifier. + +## License + +Dual-licensed under either [MIT](LICENSE-MIT) or +[Apache-2.0](LICENSE-APACHE), at your option. diff --git a/demo/evil_proxy.py b/demo/evil_proxy.py new file mode 100755 index 0000000..5b57d0f --- /dev/null +++ b/demo/evil_proxy.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""A deliberately malicious HTTP proxy — the man-in-the-middle for the demo. + +It forwards GET requests to an upstream origin but, for the target artifact, +flips one byte at a configurable offset as the bytes stream through. This is +exactly the tampering-in-transit that blacklight is designed to catch: the +origin is honest, the network is not. + +Everything else (the manifest, the .obao tree, the Sigstore bundle) is passed +through untouched, modelling an attacker who can rewrite payload bytes but +cannot forge the publisher's signature over the manifest. + +Standard library only. Python 3.8+. + + python3 evil_proxy.py --listen 8081 --origin http://127.0.0.1:8080 \\ + --target /demo.bin --offset 5000000 +""" + +import argparse +import http.server +import sys +import urllib.request + +ARGS = None + + +class MitmHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *a): # keep the demo output readable + sys.stderr.write("[evil_proxy] " + (fmt % a) + "\n") + + def do_GET(self): + upstream = ARGS.origin.rstrip("/") + self.path + tamper = self.path == ARGS.target + # Once the status line + headers are on the wire we can no longer send an + # HTTP error response — doing so would emit a second status line into the + # same response. After that point, failures are logged and the + # connection is simply dropped. + 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: + sys.stderr.write( + f"[evil_proxy] TAMPERING with {self.path}: " + f"flipping byte at offset {ARGS.offset}\n" + ) + + pos = 0 + while True: + chunk = resp.read(64 * 1024) + if not chunk: + break + if tamper and pos <= ARGS.offset < pos + len(chunk): + i = ARGS.offset - pos + chunk = bytearray(chunk) + chunk[i] ^= 0xFF # flip every bit of one byte + chunk = bytes(chunk) + try: + self.wfile.write(chunk) + except (BrokenPipeError, ConnectionResetError): + # The client aborted mid-stream — for blacklight under + # attack, this is the whole point. + sys.stderr.write( + f"[evil_proxy] client hung up after ~{pos + len(chunk)} bytes " + "(verified-streaming client aborting early)\n" + ) + return + pos += len(chunk) + except Exception as e: # noqa: BLE001 - demo tool, surface anything + if headers_sent: + # A 200 response is already in flight; we can't turn it into an + # error. Log and let the connection close. + sys.stderr.write(f"[evil_proxy] upstream error mid-stream: {e}\n") + else: + self.send_error(502, f"upstream error: {e}") + + +def main(): + global ARGS + p = argparse.ArgumentParser(description="Tampering MITM proxy for the blacklight demo") + p.add_argument("--listen", type=int, default=8081) + p.add_argument("--origin", default="http://127.0.0.1:8080") + p.add_argument("--target", default="/demo.bin", + help="path to tamper with; everything else passes through") + p.add_argument("--offset", type=int, default=5_000_000, + help="byte offset within the target to corrupt") + ARGS = p.parse_args() + + srv = http.server.ThreadingHTTPServer(("127.0.0.1", ARGS.listen), MitmHandler) + sys.stderr.write( + f"[evil_proxy] listening on :{ARGS.listen}, forwarding to {ARGS.origin}, " + f"tampering {ARGS.target}@{ARGS.offset}\n" + ) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/demo/run_demo.sh b/demo/run_demo.sh new file mode 100755 index 0000000..84da4b6 --- /dev/null +++ b/demo/run_demo.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# End-to-end attack demo for blacklight. +# +# Publishes a file, serves it from an honest origin, then fetches it two ways: +# 1. directly from the honest origin -> succeeds, bytes match +# 2. through a tampering MITM proxy -> blacklight aborts mid-stream at the +# first bad 16 KiB chunk group, leaving no output file +# +# and contrasts blacklight with the naive `curl | sha256sum` baseline, which +# must download the entire file before it can notice the tampering. +# +# Usage: demo/run_demo.sh [SIZE_MB] [TAMPER_OFFSET] +set -euo pipefail + +SIZE_MB="${1:-64}" +OFFSET="${2:-$(( SIZE_MB * 1024 * 1024 / 2 ))}" # default: tamper near the middle +GROUP=16384 + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BL="$ROOT/target/release/blacklight" +WORK="$(mktemp -d)" +ORIGIN_PORT=8080 +PROXY_PORT=8081 + +cleanup() { [ -n "${ORIGIN_PID:-}" ] && kill "$ORIGIN_PID" 2>/dev/null || true + [ -n "${PROXY_PID:-}" ] && kill "$PROXY_PID" 2>/dev/null || true + rm -rf "$WORK"; } +trap cleanup EXIT + +echo "==> building release binary" +( cd "$ROOT" && cargo build --release --quiet ) + +echo "==> creating ${SIZE_MB} MiB test artifact" +python3 - "$WORK/demo.bin" "$SIZE_MB" <<'PY' +import sys +path, mb = sys.argv[1], int(sys.argv[2]) +with open(path, "wb") as f: + buf = bytes((i * 7 + 3) % 256 for i in range(1024 * 1024)) + for _ in range(mb): + f.write(buf) +PY + +echo "==> blacklight publish (unsigned; signing is exercised separately against Sigstore)" +"$BL" publish "$WORK/demo.bin" --unsigned + +echo "==> starting honest origin :$ORIGIN_PORT and tampering proxy :$PROXY_PORT (flip @ $OFFSET)" +( cd "$WORK" && python3 -m http.server "$ORIGIN_PORT" --bind 127.0.0.1 >/dev/null 2>&1 ) & +ORIGIN_PID=$! +python3 "$ROOT/demo/evil_proxy.py" --listen "$PROXY_PORT" \ + --origin "http://127.0.0.1:$ORIGIN_PORT" --target /demo.bin --offset "$OFFSET" \ + >"$WORK/proxy.log" 2>&1 & +PROXY_PID=$! +sleep 1 + +echo +echo "################ SCENARIO 1: honest origin ################" +if "$BL" fetch "http://127.0.0.1:$ORIGIN_PORT/demo.bin.blacklight.json" \ + --allow-unsigned -o "$WORK/clean.out"; then + cmp -s "$WORK/clean.out" "$WORK/demo.bin" \ + && echo ">> clean download verified and byte-identical to the original." +else + echo ">> UNEXPECTED: clean download failed"; exit 1 +fi + +echo +echo "################ SCENARIO 2: tampering MITM proxy ################" +set +e +"$BL" fetch "http://127.0.0.1:$PROXY_PORT/demo.bin.blacklight.json" \ + --allow-unsigned -o "$WORK/tampered.out" +BL_EXIT=$? +set -e +echo ">> blacklight exit code: $BL_EXIT (3 = integrity violation)" +# This is the security assertion, not just a report: the tampered fetch MUST +# fail with the integrity exit code and MUST NOT leave an output file behind. +if [ "$BL_EXIT" -ne 3 ]; then + echo ">> FAIL: expected exit code 3 (integrity violation), got $BL_EXIT"; exit 1 +fi +if [ -f "$WORK/tampered.out" ]; then + echo ">> FAIL: a partial/tampered output file was left behind"; exit 1 +fi +echo ">> no output file was written — the tampered bytes never reached disk as 'good'." + +echo +echo "################ BASELINE: curl | sha256sum ################" +GOOD_SHA=$(sha256sum "$WORK/demo.bin" | cut -d' ' -f1) +BYTES_BEFORE_DETECT=$(( (OFFSET / GROUP + 1) * GROUP )) +BASELINE_BYTES=$(( SIZE_MB * 1024 * 1024 )) +curl -s "http://127.0.0.1:$PROXY_PORT/demo.bin" -o "$WORK/curl.out" +BAD_SHA=$(sha256sum "$WORK/curl.out" | cut -d' ' -f1) +[ "$GOOD_SHA" != "$BAD_SHA" ] && echo ">> curl completed the FULL download; only then does the hash mismatch." + +echo +echo "================== DETECTION METRICS ==================" +printf '%-38s %15s\n' "tampered byte offset" "$OFFSET" +printf '%-38s %15s\n' "total artifact size (bytes)" "$BASELINE_BYTES" +printf '%-38s %15s\n' "blacklight: bytes consumed before" "$BYTES_BEFORE_DETECT" +printf '%-38s %15s\n' " detection (one 16 KiB group)" "" +printf '%-38s %15s\n' "curl+sha256: bytes consumed before" "$BASELINE_BYTES" +printf '%-38s %15s\n' " detection (whole file)" "" +RATIO=$(python3 -c "print(f'{$BASELINE_BYTES/$BYTES_BEFORE_DETECT:.1f}x')") +printf '%-38s %15s\n' "data blacklight avoided reading" "$RATIO less" +echo +echo "(Note: bytes *consumed and verified* by the client. Actual bytes on the" +echo " wire can be higher due to TCP/HTTP read-ahead buffering — see proxy.log —" +echo " but no unverified byte is ever accepted or written as good output.)" +grep 'client hung up' "$WORK/proxy.log" || true diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..282a662 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,419 @@ +# blacklight — Design + +Verified-streaming downloads anchored in the Sigstore transparency log. + +blacklight downloads a file from an **untrusted** network and mirror, verifying +every 16 KiB chunk group against a signed BLAKE3 Merkle root *as the bytes +arrive*, and aborting the transfer on the first tampered byte. The root is +authorized by a keyless Sigstore signature that is recorded in the public Rekor +transparency log, so the identity that published the root is provable and +publicly auditable. + +This document is the engineering reference: goals, threat model, the end-to-end +trust chain, the on-disk and on-wire formats, the `fetch` state machine, the +end-to-end argument for why weaker schemes fail, and the limitations. + +Everything here is grounded in the actual code. Key modules: + +| Module | Responsibility | +| --- | --- | +| [`src/main.rs`](../src/main.rs) | CLI (`clap`), subcommand dispatch, exit-code mapping | +| [`src/publish.rs`](../src/publish.rs) | hash file, build outboard, write manifest, sign | +| [`src/fetch.rs`](../src/fetch.rs) | verify bundle, fetch outboard, stream + verify, finalize | +| [`src/verify.rs`](../src/verify.rs) | forward-only fail-fast BLAKE3 group verifier (`GroupPlan`) | +| [`src/manifest.rs`](../src/manifest.rs) | the signed manifest document | +| [`src/sigstore.rs`](../src/sigstore.rs) | keyless signing + offline verification wrappers | + +--- + +## 1. Goals and non-goals + +### Goals + +- **End-to-end integrity.** The client accepts artifact bytes only if they hash + up to a root that a named publisher signed. No hop in the delivery path (WiFi, + ISP, CDN, mirror) is trusted with byte integrity. +- **Fail fast, mid-stream.** Detect tampering at the first bad 16 KiB group and + abort the transfer, rather than after downloading the whole file. At most one + chunk group (16 KiB) is fetched past the tampered byte. +- **Publicly auditable provenance.** Every signature is a keyless Sigstore + signature recorded in Rekor, so *who* signed a given root is a matter of + public record, and a compromised signing identity is detectable after the fact. +- **Offline verification.** `fetch` verifies the signature, the Fulcio + certificate chain, and the Rekor inclusion proof against an *embedded* trusted + root, with no network round-trip to Sigstore at fetch time. +- **Policy before bytes.** The required signer identity and OIDC issuer are + enforced *before any artifact byte is downloaded*. +- **Small overhead.** The outboard Merkle tree adds ~0.4% (measured 0.39% on the + demo artifact) to what the publisher hosts. + +### Non-goals (v1) + +- **Rollback / freshness.** An attacker who serves a *stale but validly signed* + older version is not stopped. Freshness is TUF's domain; blacklight has no + version/timestamp metadata that a client can require to be current. +- **Availability of the outboard.** If the `.obao` sidecar is missing or + withheld, `fetch` fails closed (cannot build a plan) but blacklight does not + provide redundancy or a fallback. +- **Active transparency-log monitoring.** blacklight verifies a Rekor *inclusion + proof* offline; it does not run a witness or continuously monitor the log for + rogue entries under a given identity. That is deferred to `rekor-monitor` / + witness infrastructure. +- **Self-managed signing keys.** sigstore-rust 0.10 exposes no self-managed-key + path. Signing is **keyless only** (OIDC → Fulcio short-lived cert). +- **Confidentiality.** blacklight is an integrity/provenance tool. It does not + encrypt artifacts. + +--- + +## 2. Threat model + +The trust boundary: **the publisher's Sigstore identity is trusted; everything +between the publisher and the client is not.** The WiFi link, the network, the +ISP, the CDN, and the mirror can all read and rewrite any bytes in transit. What +they cannot do is forge a Sigstore signature that verifies against the embedded +trusted root under the identity + issuer the client demands. + +| Attacker capability | blacklight's defense | +| --- | --- | +| Flip / rewrite bytes of the **artifact** in transit (MITM, malicious CDN/mirror) | Each 16 KiB group is checked against the signed root the instant it completes ([`GroupPlan::check_group`](../src/verify.rs)); first mismatch → `TamperError`, exit 3, partial file deleted, byte offset reported | +| **Truncate** the artifact stream (drop the tail) | Exact length check: `total != plan.size()` and `group_index != plan.group_count()` are `TamperError` ([`stream_verified_inner`](../src/fetch.rs)) | +| **Pad / append** bytes to the artifact | Same length check fails; and any group past the signed count has no expected CV (`VerifyError::LongStream`) | +| Tamper with the **`.obao` outboard** tree | The outboard is reconstructed top-down and its computed root compared to the signed root in `GroupPlan::new`; a mismatch is `OutboardRootMismatch` / `BadOutboard`, caught *before* streaming any artifact byte | +| Tamper with the **manifest** JSON (change root, size, name, URLs) | The Sigstore bundle signs the exact manifest bytes; `sigstore::verify_manifest` rejects any change before the manifest is even parsed ([`fetch.rs`](../src/fetch.rs) verifies bytes, *then* `Manifest::from_bytes`) | +| Add extra JSON fields to the manifest to smuggle data | `#[serde(deny_unknown_fields)]` on `Manifest` rejects unknown keys | +| Serve a **different file** signed by someone else | Client passes `--expect-identity` + `--expect-issuer`; `VerificationPolicy::require_identity/require_issuer` enforces an exact SAN + issuer match | +| Present a **valid signature by the wrong issuer** (e.g. attacker's own Google account) | Issuer is pinned by `--expect-issuer`; a signature from an unexpected OIDC issuer fails the policy | +| Strip the signature and serve an **unsigned** manifest | Without `--allow-unsigned`, a missing/invalid bundle is a hard failure ("refusing to download"). `--allow-unsigned` is opt-in and loudly warned | +| Forge a **Rekor inclusion proof** | The inclusion proof is checked against Rekor's key material in the embedded trusted root; a forged proof does not verify | +| **Compromise the publisher's signing identity** and sign a malicious root | *Detectable, not prevented.* Every signature is in the public Rekor log, so a rogue signature under the publisher's identity is discoverable by log monitoring (out of scope for v1, see §7) | +| Serve a **stale but validly signed** older version | **Out of scope (v1).** No freshness metadata; see non-goals | +| Downgrade TLS / present a bad TLS cert | Irrelevant to integrity — blacklight does not rely on TLS for artifact integrity; the artifact URL may even be plain `http://`. TLS is orthogonal (see §6) | + +A network read error (connection reset, timeout) is explicitly **not** +tampering: it is surfaced as an ordinary error (exit 1), kept distinct from an +integrity violation (exit 3) so scripts and tests can tell them apart +([`stream_verified_inner`](../src/fetch.rs) wraps stream errors with `network +error reading …`). + +--- + +## 3. Trust chain, end to end + +The chain runs from a human/CI OIDC identity all the way down to a single +streamed 16 KiB group. Each link is verified; no link is assumed. + +``` +OIDC identity (email or workload URI, at a named issuer) + │ proven at signing time by the OIDC token + ▼ +Fulcio short-lived certificate (SAN = identity, extension = issuer) + │ chains to a CA in the embedded trusted root + ▼ +Signature over the exact manifest bytes + │ covers {v, name, size, blake3_root, chunk_group_log, urls} + ▼ +Rekor inclusion proof (this signature is in the public transparency log) + │ verified offline against Rekor key material in the trusted root + ▼ +blake3_root (the signed 32-byte BLAKE3 Merkle root of the file) + │ bound because it is a field of the signed manifest + ▼ +per-16-KiB-group leaf chaining values (reconstructed from the .obao, + │ and checked to hash back up to blake3_root in GroupPlan::new) + ▼ +each streamed 16 KiB group (its recomputed CV must equal the expected leaf CV) +``` + +Concretely: + +1. **Identity → certificate.** At publish time, `sigstore::sign_manifest` + obtains an OIDC identity token — ambient CI credentials via + `IdentityToken::detect_ambient()` if present, otherwise an interactive + browser flow via `get_identity_token`. Fulcio issues a short-lived + certificate whose Subject Alternative Name is the identity and which records + the OIDC issuer. + +2. **Certificate → signature → log.** The `SigningContext` signer signs the + manifest bytes; the signature and a Rekor inclusion proof are stapled into a + **Sigstore v0.3 bundle** (`bundle.to_json_pretty()`). + +3. **Signature → root (verification).** At fetch time, `sigstore::verify_manifest` + loads the *embedded* trusted root + (`SIGSTORE_STAGING_TRUSTED_ROOT` / `SIGSTORE_PRODUCTION_TRUSTED_ROOT`), + parses the bundle, and runs `sigstore_verify::verify` over the raw manifest + bytes with a `VerificationPolicy` that requires the exact identity + issuer. + This checks the signature, the Fulcio cert chain, and the Rekor inclusion + proof **offline**. Only after this succeeds does [`fetch.rs`](../src/fetch.rs) + call `Manifest::from_bytes` and trust `blake3_root`. + +4. **Root → leaf CVs.** `GroupPlan::new(root, size, outboard)` walks the + outboard tree top-down, reconstructing each group's expected leaf chaining + value, and verifies the whole tree hashes back up to `root`. A tampered + outboard cannot reach the signed root and is rejected here. + +5. **Leaf CV → streamed group.** As each 16 KiB group lands, + `GroupPlan::check_group` recomputes its BLAKE3 chaining value with the correct + input offset and compares it to the expected leaf CV. First mismatch aborts. + +Because the manifest is verified as **raw bytes before parsing**, and it fixes +`chunk_group_log`, the *tree geometry itself* is part of the signed statement — +an attacker cannot re-shape the tree to smuggle bytes. + +--- + +## 4. On-disk and on-wire formats + +`publish` emits three sidecars next to the artifact. The publisher hosts four +files total: the artifact, the `.obao`, the manifest, and the bundle. + +| File | Suffix | Contents | +| --- | --- | --- | +| Artifact | *(none)* | The original file, byte-for-byte | +| Outboard | `.obao` | Pre-order BLAKE3 Merkle tree at 16 KiB groups | +| Manifest | `.blacklight.json` | Signed JSON binding root, size, geometry | +| Bundle | `.blacklight.json.sigstore.json` | Sigstore v0.3 bundle over the manifest bytes | + +Suffixes are defined once in [`src/manifest.rs`](../src/manifest.rs): +`MANIFEST_SUFFIX = ".blacklight.json"`, `OUTBOARD_SUFFIX = ".obao"`, +`BUNDLE_SUFFIX = ".sigstore.json"`. + +### 4.1 Manifest JSON + +The manifest is the small signed document. Schema (from the `Manifest` struct): + +| Field | Type | Meaning | +| --- | --- | --- | +| `v` | u32 | Format version. Must be `1`. | +| `name` | string | Basename of the artifact (informational; default output name). | +| `size` | u64 | Exact file length in bytes. | +| `blake3_root` | string | 64-hex-char BLAKE3 root hash. Equals `blake3::hash(file)`. | +| `chunk_group_log` | u8 | Log2 of chunk-group size in 1 KiB chunks. Fixed at `4` (16 KiB) for v1. | +| `urls` | array of strings | Optional artifact URL hints. Omitted when empty. | + +It is serialized with `serde_json::to_vec_pretty` plus a trailing newline +(`Manifest::to_bytes`). There is **deliberately no canonicalization**: the exact +bytes the publisher signed are the exact bytes the client must present and +verify. Parsing uses `#[serde(deny_unknown_fields)]`, and `from_bytes` rejects +any `v != 1`, any `chunk_group_log != 4`, and any non-hex root. + +Real example (`demo.bin`, 32 MiB): + +```json +{ + "v": 1, + "name": "demo.bin", + "size": 33554432, + "blake3_root": "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", + "chunk_group_log": 4, + "urls": [ + "https://mirror.example.org/demo.bin" + ] +} +``` + +(The `blake3_root` above is illustrative; the real value is printed by +`publish` as `root `.) + +### 4.2 The `.obao` outboard tree + +The outboard is built by the `bao-tree` crate (`PreOrderOutboard::create` with +`BlockSize::from_chunk_log(4)`). "Outboard" means the tree lives in a **separate +file** from the artifact — the artifact stays byte-for-byte identical, and the +tree is a ~0.4% sidecar. + +Layout: a **pre-order** sequence of 64-byte parent nodes, each node being a pair +`(left CV, right CV)` of 32-byte BLAKE3 chaining values. Leaf chaining values are +**not** stored — they are exactly the child CVs held in the lowest parents, and +the client recomputes each leaf from the streamed data. Grouping chunks into +16 KiB blocks reduces the number of parents ~16× versus per-1-KiB-chunk BLAKE3, +which is where the low overhead comes from. + +Crucially, **the outboard's root equals `blake3::hash(file)`**. Chunk grouping +changes only the *layout* of the outboard, not the hashing math: BLAKE3's tree +is defined over 1 KiB chunks regardless, and a 16 KiB group is just a subtree of +16 chunks. The unit test `plan_root_matches_blake3` in +[`src/verify.rs`](../src/verify.rs) asserts `ob.root == blake3::hash(&data)` +across many sizes, which is what lets a single BLAKE3 root in the manifest +authorize the grouped tree. + +Edge cases the verifier encodes: + +- **Empty file (`size == 0`):** root is `blake3::hash(b"")`; zero groups. +- **Single group (`size <= 16 KiB`):** the whole file is one leaf whose *root* + hash is the trusted root; the outboard is empty (no parents). `GroupPlan::new` + rejects a non-empty outboard here (`"single-group outboard must be empty"`). +- **Trailing bytes / short outboard:** rejected as `BadOutboard`. + +### 4.3 The Sigstore bundle + +`.blacklight.json.sigstore.json` is a **Sigstore v0.3 bundle** (`sigstore-types` +`Bundle`): the signature over the manifest bytes, the Fulcio signing +certificate, and the Rekor inclusion proof, as JSON. It is self-contained enough +for offline verification against the embedded trusted root — no call to +Fulcio/Rekor at fetch time. + +--- + +## 5. The `fetch` state machine + +From [`src/fetch.rs`](../src/fetch.rs). Each stage gates the next; nothing about +the artifact is trusted until both the bundle and every group have verified. + +``` + ┌─────────────────────────────┐ + │ 1. LOAD manifest bytes │ load() — URL or local path + │ (raw, unparsed) │ + └──────────────┬──────────────┘ + │ + --allow-unsigned? ─┴─ no ──► ┌──────────────────────────────────┐ + │ yes │ 2. VERIFY BUNDLE (offline) │ + │ (warn loudly) │ sigstore::verify_manifest │ + │ │ - signature over manifest bytes │ + │ │ - Fulcio cert chain │ + │ │ - Rekor inclusion proof │ + │ │ - require identity + issuer │ + │ │ FAIL ► exit 1, no download │ + └──────────┬───────────┴────────────┬─────────────────────┘ + │ │ ok + ▼ ▼ + ┌─────────────────────────────────────────────┐ + │ 3. PARSE manifest (Manifest::from_bytes) │ now trusted + │ extract blake3_root, size │ + └───────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ 4. FETCH outboard (.obao, downloaded fully) │ + └───────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ 5. BUILD + SELF-CHECK GroupPlan │ + │ GroupPlan::new(root, size, outboard) │ + │ tampered .obao ► TamperError, no download │ + └───────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ 6. STREAM artifact into .blacklight- │ + │ partial; buffer 16 KiB groups │ + │ each full group ► GroupPlan::check_group │ + │ mismatch ► TamperError (exit 3): │ + │ delete partial, report byte offset │ + │ final short group verified too │ + │ length + group-count must match exactly │ + └───────────────────────┬──────────────────────┘ + ▼ + ┌─────────────────────────────────────────────┐ + │ 7. FINALIZE: flush, fsync, atomic rename into │ + │ place. On ANY error the partial is removed. │ + └─────────────────────────────────────────────┘ +``` + +Notes: + +- **Streaming buffer.** `stream_verified_inner` pulls `reqwest` byte chunks and + re-buckets them into exactly `GROUP_LEN` (16 KiB) groups regardless of how the + transport framed them, verifying each complete group before writing it. +- **No partial ever looks good.** The download goes to a + `.blacklight-partial` temp file; on any failure it is deleted, and only a + fully verified file is atomically `rename`d into place. This is why the + integration tests assert `!out.exists()` after a tampered fetch. +- **URL resolution.** The artifact URL is: `--url` override, else the manifest + location with `.blacklight.json` stripped, else the first manifest `urls` hint + (`resolve_artifact_url`). Integrity never depends on which of these is used. +- **Exit codes.** `TamperError` → exit **3** (mapped in + [`src/main.rs`](../src/main.rs)); any other error → exit **1**; success → **0**. + +--- + +## 6. Why weaker schemes don't stop a MITM (the end-to-end argument) + +The folk instinct — "check the hash and hackers can't tamper with your download" +— is correct in spirit but fails in every common instantiation, because of the +classic end-to-end argument: an integrity check is only as strong as the trust +you have in *where the reference value came from* and *when you check it*. + +- **In-band / same-channel checksum.** Hosting a `SHA256SUMS` file next to the + artifact protects nothing against a MITM: the same attacker who rewrites the + artifact also rewrites the checksum file. Both travel the same untrusted + channel. There is no independent anchor. + +- **A weak or broken hash (MD5).** MD5 is collision-broken; an attacker can craft + a malicious artifact that matches a published MD5. Even a strong hash doesn't + help if it's delivered in-band (previous point). blacklight uses BLAKE3 *and* + anchors the root in a signature. + +- **TLS "protects the download."** TLS authenticates the *server you connected + to* and encrypts the hop, but (a) it says nothing about whether that server — + a CDN edge, a mirror — is honest about the bytes, (b) a compromised or + malicious mirror serves tampered bytes over perfectly valid TLS, and (c) TLS + blinds *you* to the network layer, so you cannot inspect what arrived. TLS is + transport security, not artifact provenance. blacklight deliberately does not + depend on it: the artifact URL can be plain `http://` and integrity is + unchanged. + +- **Network-level / middlebox integrity checking.** Any check performed by the + network (a proxy, a middlebox, an "integrity-verifying CDN") is performed by a + party inside the untrusted region. It can lie about the result. + +The end-to-end fix, which blacklight implements: the integrity reference (the +BLAKE3 root) must be **bound to a publisher identity by a signature**, that +signature must be **publicly auditable** (Rekor), the reference must be verified +**at the client** against an **out-of-band anchor** (the embedded trusted root), +and the artifact bytes must be checked against it **as they arrive** — not by a +hop in the middle, and not after the whole (possibly malicious) file has already +been consumed. + +--- + +## 7. Limitations and future work + +- **Rollback / freshness (biggest gap).** A validly signed *older* version + replays cleanly. Mitigation is TUF-style metadata (version, timestamp, + expiry) that the client can require to be current. Not in v1. +- **Log monitoring.** blacklight verifies a Rekor inclusion proof but does not + monitor the log for rogue entries under a publisher's identity. A compromised + signing identity is *detectable* only if someone is watching the log + (`rekor-monitor` / witnesses). Making key-compromise *auditable in practice* + needs that external piece. +- **Keyless only.** No self-managed-key signing path exists in sigstore-rust + 0.10, so air-gapped or offline signing is not supported. +- **Outboard availability.** A withheld `.obao` fails the fetch closed but there + is no redundancy/fallback. +- **Fixed geometry.** `chunk_group_log` is pinned at 4 (16 KiB) for format v1. + Larger artifacts might prefer larger groups (fewer parents, coarser abort + granularity); this would be a v2 format bump. +- **Single artifact URL at a time.** No multi-source / range-parallel fetching; + the verifier is forward-only by design (see below), which trades random-access + speed for strict abort-on-first-bad-byte semantics. + +### Why the verifier is hand-rolled + +`bao-tree` ships a verifier (`valid_ranges`), but it needs *seekable* (random +access) data and it signals corruption by *omitting* a range from its result +rather than raising an error — the opposite of the "stop the instant a byte is +wrong, forward-only, over a stream" behavior that is the entire point here. So +[`src/verify.rs`](../src/verify.rs) implements a forward-only, fail-fast verifier +directly on the public `blake3::hazmat` chaining-value API (the same primitives +`bao-tree` uses internally): it reconstructs the expected per-group leaf CVs from +the outboard once (checking the tree up to the trusted root), then stream-checks +each 16 KiB group's recomputed CV as it lands. It is unit-tested for round-trip +equivalence with `bao-tree`'s own outboard format (`plan_root_matches_blake3`, +`detects_single_byte_flip_in_each_group`, `detects_tampered_outboard`, +`detects_wrong_root`). + +--- + +## Appendix: primitives and versions + +All cryptographic primitives pre-exist; blacklight's contribution is the +*composition*. + +| Component | Crate / version | Role | +| --- | --- | --- | +| Content hash + tree math | `blake3` 1.8.5 (hazmat API) | leaf CVs, subtree merges, root | +| Outboard tree engine | `bao-tree` 0.16 (n0-computer; the engine behind iroh-blobs) | pre-order 16 KiB-group outboard | +| Keyless signing/verify | `sigstore-{sign,verify,oidc,trust-root,types}` 0.10 (prefix-dev sigstore-rust; aws-lc-rs backend) | OIDC → Fulcio → Rekor; offline verify | +| Transport | `reqwest` 0.13 + `tokio` 1.52 | streaming HTTP | +| CLI | `clap` 4 | argument parsing | + +Rust edition 2024, MSRV ~1.85+. diff --git a/paper/PAPER.md b/paper/PAPER.md new file mode 100644 index 0000000..f3a9cb3 --- /dev/null +++ b/paper/PAPER.md @@ -0,0 +1,501 @@ +# Blacklight: Verified-Streaming Downloads Anchored in a Public Transparency Log + +**Andy Triboletti** +*Draft — iteration one. 2026.* + +## Abstract + +Software is routinely downloaded over networks that the downloader has no +reason to trust: café WiFi, third-party mirrors, content-delivery networks, and +caching proxies can all rewrite bytes in transit. The folk defense — "publish a +checksum of the file so people can verify what they downloaded" — is correct in +spirit but fails in three specific ways that are worth naming precisely: MD5 (the +checksum most commonly published) is collision-broken and offers no protection +against a deliberate attacker; a checksum delivered over the same channel as the +file can be replaced by the same man-in-the-middle that replaces the file; and +performing the check "at the network level" is impossible for TLS-wrapped +transfers and, more fundamentally, asks the untrusted middle to vouch for the +data it is relaying. The correct place for integrity is the endpoint, and the +correct binding is a *signature* whose authenticity is *publicly auditable*. + +We present **blacklight**, a download tool that composes two mature but +previously-uncombined mechanisms into a single trust chain. First, a publisher's +release is bound to a **BLAKE3 Merkle root** and signed with **Sigstore**, so +the signature is keyless (tied to an OIDC identity through a short-lived +certificate) and recorded in the **Rekor public transparency log**. Second, the +client verifies the signed Merkle root *offline*, then **streams** the artifact +and checks every 16 KiB chunk group against that root *as the bytes arrive*, +aborting the transfer at the first byte that does not match. The result is +integrity that is end-to-end (the network is never trusted), first-byte-to-last +(tampering is caught mid-transfer, not after a full download), and publicly +accountable (a compromised publisher key leaves a permanent, monitorable trail +in Rekor). + +To our knowledge no existing tool combines chunk-granular verified streaming +with transparency-log-anchored signatures in one verification path. We make the +composition, its trust-chain design, and a working Rust implementation the +contribution; all of the underlying cryptographic primitives pre-exist and are +cited. A reproducible attack demonstration shows blacklight aborting a tampered +32 MiB download after verifying ~16 MB, where the conventional +`curl | sha256sum` pipeline must read the entire file before it can notice. + +## 1. Introduction + +### 1.1 A real question, and three tempting wrong answers + +The motivating question is one that many practitioners have asked in some form: +*"I keep hearing about man-in-the-middle attacks and tampered downloads. Why +isn't the download's checksum just verified automatically, at the network level, +so bad bytes are rejected before they ever reach me?"* It is a good instinct. +The reason it does not already work as imagined is instructive, because each +plausible-sounding fix fails for a different, nameable reason. + +**Wrong answer 1: "Use the MD5 the project publishes."** MD5 collisions have +been practical since 2004, and *chosen-prefix* collisions — where an attacker +crafts a malicious file sharing the MD5 of a benign one — are well within reach. +The Flame malware (2012) exploited exactly this class of weakness to forge a +Microsoft code-signing certificate. A defense whose security rests on MD5 gives +zero assurance against the deliberate attacker it is meant to stop. Any serious +design uses a collision-resistant hash; blacklight uses BLAKE3. + +**Wrong answer 2: "Deliver the checksum next to the file."** If the attacker can +rewrite the file in transit, it can rewrite the checksum sitting beside it. The +client then dutifully verifies the attacker's file against the attacker's hash +and reports success. This is not a hashing problem; it is a *bootstrapping* (or +trust-anchoring) problem. RFC 9530's `Content-Digest`/`Repr-Digest` headers, for +example, detect accidental corruption but — being in-band and unauthenticated — +do nothing against a MITM unless the digest is independently signed and the +signing key is anchored in something the client already trusts. The fix is a +**digital signature** verifiable against a public key the device trusts a +priori, not a better checksum. + +**Wrong answer 3: "Check it at the network level."** Two obstacles. Operationally, +almost all downloads ride inside TLS; the access point or router sees ciphertext +and cannot hash the plaintext artifact without terminating TLS — which is itself +a man-in-the-middle. Architecturally, the deeper problem is that in a MITM +scenario the network *is* the adversary, so putting the integrity check in the +network asks the suspect to vouch for the evidence. This is precisely the +situation the **end-to-end argument** [Saltzer, Reed & Clark 1984] addresses: +a function that can only be completely and correctly implemented with knowledge +held at the endpoints (here, the trusted public key and the expected root hash) +should live at the endpoints. The link layer is not idle in a good design — +WPA3-SAE with protected management frames (802.11w) closes the classic +evil-twin/deauth attacks — but link security is orthogonal to *artifact* +integrity and cannot substitute for it. + +**Wrong answer 4 (the ambitious one): "Put it on a blockchain."** The instinct +here is that we want a record an attacker cannot quietly alter. That instinct is +right; the mechanism is over-specified. Blockchain consensus exists so that +mutually distrusting parties can agree on an ordering *without any authority*. +But software has a natural authority — the publisher is the only party entitled +to say what their release should hash to — so the consensus machinery solves a +problem we do not have. What we actually want from "blockchain" is its +*tamper-evident, append-only, publicly auditable* character, and that is exactly +a **Merkle transparency log** (Section 2.2), which delivers non-equivocation and +public auditability without mining, tokens, or global consensus. + +### 1.2 What survives, and what we build + +Strip the three tempting-but-wrong mechanisms away and three sound requirements +remain, each mapping cleanly to an existing primitive: + +1. *Verification should be automatic and enforced, not a manual step users skip.* + → A signature over a collision-resistant hash, checked by the client before + the bytes are accepted. +2. *There should be a record of what was published that no one can silently + rewrite.* → A public append-only Merkle transparency log (Sigstore/Rekor). +3. *Bad bytes should be rejected as early as possible — ideally the tool should + "stop processing bits" the moment something is wrong.* → **Verified + streaming**: a signed Merkle tree over the file lets the client verify each + chunk as it arrives and abort on the first bad one, instead of hashing the + whole file after the fact. + +Blacklight is the tool that puts requirements 2 and 3 into a single trust chain. +The remainder of this paper describes the design (Section 3), the +implementation (Section 4), an evaluation including a reproducible attack demo +(Section 5), how it relates to a substantial body of prior work (Section 6), and +its honest limitations (Section 7). + +### 1.3 Contributions + +- **A composed trust chain** in which a transparency-logged, keyless signature + covers a BLAKE3 Merkle *root* that in turn authorizes per-chunk verification + *during* transfer — closing the gap between "we know who signed this whole + file" and "we can reject a bad byte before downloading the rest." +- **A working, tested Rust implementation** (`publish`/`fetch`, ~1–2k LOC) built + on maintained crates (blake3, bao-tree, sigstore-rust) that signs against and + verifies against the real Sigstore infrastructure. +- **A forward-only, fail-fast streaming verifier** that reproduces BLAKE3's tree + semantics using the public `blake3::hazmat` API, because the obvious library + routine (`bao_tree::valid_ranges`) requires seekable data and reports + corruption as an *omitted range* rather than an error — the wrong shape for + abort-on-first-bad-byte. +- **A reproducible adversarial demonstration** (a tampering MITM proxy) with + measured detection latency, contrasted against the conventional + download-then-hash baseline. + +## 2. Background + +### 2.1 BLAKE3, Merkle trees, and verified streaming + +BLAKE3 [O'Connor, Aumasson, Neves & Wilcox-O'Hearn 2020] is internally a Merkle +tree over 1 KiB input chunks: chunk chaining values are pairwise combined up to a +single root. Because the hash is a tree, a verifier who holds the trusted root +and the interior node hashes can validate any individual chunk or contiguous +range *without* the rest of the file — the property BLAKE3's specification calls +**verified streaming** (§6.4). **Bao** is the reference construction for this, +defining a combined encoding (data interleaved with tree nodes) and an +*outboard* encoding (the tree stored separately from the data). The idea of +verifying subranges of a large file against a signed tree root before the whole +file is present predates BLAKE3: the **Tree Hash EXchange** format (THEX, 2003, +an expired Internet-Draft) used Tiger tree hashes for exactly this in Gnutella +and DC++, and **BitTorrent v2** (BEP 52, shipped 2020) verifies each 16 KiB block +against a per-file SHA-256 Merkle tree during transfer. What all of these share — +and what blacklight adds to — is that the trust root is a bare, unauthenticated +hash: they tell you the bytes match *a* root, not *whose* root. + +### 2.2 Transparency logs and Sigstore + +A transparency log is a public, append-only Merkle tree of entries that supports +compact *inclusion proofs* ("entry X is in the log") and *consistency proofs* +("the log only grew; nothing was rewritten"), so that even a misbehaving log +operator cannot show different histories to different clients without detection. +The design originates with **Certificate Transparency** [Laurie, Langley & Kasper, +RFC 6962; updated as RFC 9162] and was given a general formal treatment by +**Transparency Overlays** [Chase & Meiklejohn 2016]. Russ Cox's +*Transparent Logs for Skeptical Clients* (2019) and the resulting Go checksum +database (`sum.golang.org`) applied it to software packages at scale. + +**Sigstore** [Newman, Meyers & Torres-Arias 2022] combines three services into a +turnkey signing system: an OIDC-federated certificate authority (**Fulcio**) that +issues short-lived certificates binding an ephemeral key to a human/workload +identity, and a transparency log (**Rekor**) that records every signature so it +can be publicly monitored. The output is a self-describing *bundle* containing +the signature, the certificate, and a stapled Rekor inclusion proof — which means +a verifier can check everything **offline** once it holds the trust root. +Blacklight signs the manifest bytes with Sigstore and verifies the resulting +bundle offline against an embedded trust root. + +## 3. Design + +### 3.1 Threat model + +The adversary controls the network path end to end: the WiFi link, the router, +the ISP, the CDN, and any mirror or caching proxy. It can read, drop, reorder, +and — crucially — *rewrite* any bytes of any transfer. It can serve a completely +different file, corrupt selected bytes, truncate, or pad. What it **cannot** do is +forge the publisher's Sigstore signature over the manifest, because that would +require either the publisher's OIDC identity or a Fulcio/Rekor compromise that +would itself be publicly evident. + +A separate, weaker adversary is a **compromised publisher signing identity**. +Blacklight does not *prevent* this — no signing scheme can — but because every +signature is recorded in Rekor, a malicious release is publicly detectable by the +publisher and by third-party monitors, giving accountability and a bounded window +of exposure. This is the transparency guarantee: detection and non-equivocation, +not prevention. + +Explicitly **out of scope** for this iteration: rollback/freshness attacks (an +adversary replaying an older but validly-signed release — the domain of The +Update Framework's timestamp/snapshot metadata, Section 6); availability of the +outboard tree; and active, continuous log monitoring (delegated to existing +tools such as `rekor-monitor`). + +### 3.2 The trust chain + +Verification composes into a single unbroken chain from a human-meaningful +identity down to an individual 16 KiB group of bytes: + +``` +OIDC identity (e.g. you@example.com, verified by an issuer the client pins) + └─ Fulcio short-lived certificate binding that identity to an ephemeral key + └─ signature over the exact manifest bytes + └─ Rekor inclusion proof (this signature is in the public log) + └─ blake3_root in the manifest (the file's BLAKE3 Merkle root) + └─ outboard tree (interior node hashes), checked to hash + up to blake3_root + └─ per-16-KiB-group leaf chaining values + └─ each streamed group, verified on arrival +``` + +Every link is checked before the next is trusted. The manifest is verified as +*raw bytes* against the bundle before it is parsed; the outboard is checked to +hash up to the signed root before any artifact byte is streamed; and each group +is checked against the tree the instant it completes. + +### 3.3 Artifacts and formats + +`publish` emits, alongside the artifact, three sidecar files: + +- **Manifest** `.blacklight.json` — a small, versioned JSON document that + is the object actually signed. It binds the file name, size, the hex BLAKE3 + root, and the chunk-group size (fixed at log₂ = 4, i.e. 16 KiB, in format v1 + because it is baked into the outboard layout). Signing the *manifest* rather + than the raw file means the Rekor entry's subject digest is `sha256(manifest)`, + so no BLAKE3-specific entry type is required in the log. +- **Outboard** `.obao` — the pre-order BLAKE3 Merkle tree over the file, at + 16 KiB groups, with no length prefix. Its measured size is ~0.4% of the file. + Because chunk grouping affects only which interior nodes are stored (not the + hashing math), the outboard's root equals plain `blake3::hash(file)`. +- **Bundle** `.blacklight.json.sigstore.json` — the Sigstore v0.3 bundle + (signature + Fulcio certificate + stapled Rekor inclusion proof). + +The publisher hosts these four files anywhere — no special server is required. + +### 3.4 The fetch state machine + +``` +1. Load manifest bytes and the bundle. +2. Verify the bundle OFFLINE against the embedded trust root: signature, + Fulcio certificate chain, Rekor inclusion proof, AND the caller-supplied + identity policy (exact SAN + issuer). Refuse to continue on any failure. + -- Only now is the manifest parsed and its blake3_root trusted. -- +3. Fetch the (small) outboard. Build a verification plan and CHECK THAT THE + OUTBOARD HASHES UP TO THE SIGNED ROOT. A tampered outboard dies here. +4. Stream the artifact. Accumulate bytes into 16 KiB groups; the instant a + group completes, verify its BLAKE3 chaining value against the plan. On the + first mismatch: abort the transfer, delete the partial file, exit with a + distinct integrity code, and report the byte offset. +5. On clean completion, check total length equals the signed size (catching + truncation/padding), then atomically rename the temp file into place. +``` + +Requiring `--expect-identity` and `--expect-issuer` is a deliberate response to a +known weakness of the closest prior tool (Sigstore's archived `sget`), which +looked up *a* signature for the content without enforcing *whose*. A signature +from an attacker's own valid OIDC identity is still an attacker's signature; the +policy is what makes the check meaningful. + +## 4. Implementation + +Blacklight is ~1–2k lines of Rust (edition 2024). The dependency choices reflect +what is actually maintained and correct as of mid-2026: + +- **`blake3` 1.8.5** for hashing. Its `hazmat` module (stabilized for exactly + this use) exposes the chunk/subtree chaining-value primitives. +- **`bao-tree` 0.16** (n0-computer) to build the outboard tree on the publish + side. This is the production verified-streaming engine underneath iroh-blobs, + and it re-exports the same upstream `blake3`, so there is a single `Hash` type + across the codebase. We use 16 KiB chunk groups (`BlockSize::from_chunk_log(4)`), + which keeps outboard overhead near 0.4%. +- **`sigstore-sign` / `-verify` / `-oidc` / `-trust-root` / `-types` 0.10** (the + sigstore-rust workspace) for keyless signing and offline bundle verification. + Signing is keyless-only by necessity — this generation of the library has no + self-managed-key path — using ambient CI credentials when present and an + interactive browser OIDC flow otherwise, against Sigstore **staging** by + default (`--production` selects the public-good instance). Verification uses the + library's **embedded** trust roots, so it needs no network and no live Rekor + connection. +- **`reqwest` 0.13 + `tokio` 1.52** for streaming HTTP. + +**The forward-only verifier.** The natural choice, `bao_tree::valid_ranges`, +turned out to be the wrong shape: it requires *random-access* (seekable) data and +signals a corrupt group by *omitting* its range from the output stream rather +than raising an error — awkward and slow to turn into "stop at the first bad +byte." Instead, the verifier (in `verify.rs`) reconstructs the expected per-group +leaf chaining values from the outboard once (verifying the outboard against the +trusted root in the process), then streams the artifact forward, hashing each +completed 16 KiB group with `blake3::hazmat` (`set_input_offset` + +`finalize_non_root`, or the root hash for a single-group file) and comparing. A +mismatch aborts immediately with the group's byte offset. This hand-rolled walk +is unit-tested for exact round-trip agreement with bao-tree's outboard format +across file sizes from 0 bytes to 200 KB, including files that are empty, a +single group, and non-power-of-two numbers of groups. + +## 5. Evaluation + +### 5.1 Correctness and tamper detection + +The implementation ships with 13 automated tests: 9 unit tests (manifest +round-trip and rejection of malformed/mis-versioned manifests; the verifier's +agreement with bao-tree; single-byte-flip detection in every group with the +correct byte offset; rejection of a tampered outboard and of a wrong root) and 4 +integration tests that drive the real compiled binary over a local HTTP server: +a clean download succeeds and is byte-identical to the original; a tampered +artifact aborts at the correct group with the integrity exit code and leaves no +output file; a tampered outboard is rejected before any streaming; and a +truncated stream is rejected on the length check. All pass. + +### 5.2 Attack demonstration and detection latency + +The repository includes a self-contained demo (`demo/run_demo.sh`) that publishes +a file, serves it from an honest origin, and interposes a **tampering MITM proxy** +(`demo/evil_proxy.py`) that flips one byte at a configurable offset as the bytes +stream through — modeling exactly the in-transit attacker of Section 3.1. + +For a 32 MiB artifact with a byte flipped at offset 16,000,000, blacklight +catches the corruption at **chunk group 976 (byte offset 15,990,784)** and aborts, +having *consumed and verified* about 16 MB — the tampered byte's own 16 KiB group +and nothing beyond it. The conventional `curl | sha256sum` pipeline, by contrast, +must download all 33,554,432 bytes before its single flat hash can mismatch. The +detection-latency advantage scales with file size and with how early the +tampering occurs: the tighter the corruption is to the start, the less blacklight +reads before aborting, whereas the flat-hash baseline always pays for the whole +file. + +A note on honesty in the measurement: "bytes consumed and verified by the client" +is the security-relevant quantity, and it is bounded by one chunk group past the +tampered byte. The number of bytes the OS actually pulled onto the wire can be +higher, because TCP/HTTP read-ahead buffers eagerly (especially on a +zero-latency loopback); the demo reports both and never conflates them. The +guarantee blacklight makes is not "minimum bytes on the wire" but "no unverified +byte is ever accepted, written as good output, or acted upon." + +### 5.3 Overhead + +The outboard tree is a fixed, small tax on the publisher and on the client's +initial fetch: measured at **0.39%** of the artifact size at 16 KiB groups +(e.g. ~131 KB for a 32 MiB file), consistent with the theoretical 64 bytes per +16 KiB group. BLAKE3 hashing throughput is high enough that hashing is not the +bottleneck relative to network transfer. Offline verification adds no network +round trips beyond fetching the manifest, bundle, and outboard. + +## 6. Related Work + +**Software-update frameworks.** *The Update Framework* (TUF) [Samuel, Mathewson, +Cappos & Dingledine 2010] defines the canonical threat taxonomy for update +systems — mirror compromise, rollback/freeze, mix-and-match — and defends with +role separation, threshold signing, and freshness metadata. *Uptane* [Kuppusamy +et al. 2016] adapts TUF to automobiles and is deployed at scale. Blacklight is +narrower and complementary: it does not (yet) address rollback/freshness, which +is TUF's core strength, but it adds public transparency and per-byte transfer-time +verification, which base TUF does not require. + +**Transparency logs.** Certificate Transparency [RFC 6962; RFC 9162], the formal +Transparency Overlays model [Chase & Meiklejohn 2016], and the Go checksum +database [Cox 2019] are the lineage blacklight's Rekor anchoring sits in. + +**Blockchain-anchored update transparency** — the closest prior art to the +original "put it on a blockchain" instinct, and the work blacklight most directly +supersedes for this use case. *CHAINIAC* [Nikitin et al. 2017] provides proactive +software-update transparency via collectively-signed skipchains and verified +builds, evaluated on PyPI; it verifies *releases and metadata*, not bytes in +flight. *Catena* [Tomescu & Devadas 2017] and *Contour* [Al-Bassam & Meiklejohn +2018] achieve non-equivocation / binary transparency by embedding log digests in +Bitcoin. Blacklight takes the same tamper-evidence goal but obtains it from a +Merkle transparency log with witnessing rather than a blockchain — no consensus, +no tokens — and, unlike all three, drives verification down to the streamed +chunk. + +**Verified streaming.** Merkle's original tree-authentication constructions +[Merkle 1987, 1989] underlie everything here. THEX (2003), BitTorrent v2 (BEP 52), +and the BLAKE3 specification with Bao [O'Connor et al. 2020] provide chunk-granular +verified transfer — but against a bare, unauthenticated root. iroh-blobs +(built on the same bao-tree crate blacklight uses) does verified streaming in +production over QUIC, again with a bare BLAKE3 hash as the trust root and no +publisher identity or log. Blacklight's addition is precisely to make that root a +*signed, transparency-logged* object. + +**Web integrity mechanisms — useful contrasts.** Subresource Integrity [W3C 2016] +pins a whole-resource hash in HTML but verifies only after the full resource is +fetched and carries no transparency. RFC 9530 Digest Fields [Polli & Pardue 2024] +are in-band and unauthenticated (Section 1.1). Signed HTTP Exchanges with the MICE +encoding combine streaming Merkle verification with a signature and CT-logged +certificates — architecturally the nearest cousin — but are browser-scoped, ride +an expired IETF draft, and log the *certificate*, not the artifact. + +**The Sigstore ecosystem and its adoption.** The Sigstore paper [Newman et al. +2022] and *Speranza* [Merrill et al. 2023] define the keyless-signing and +privacy-preserving extensions blacklight builds on. Sigstore's archived `sget` +tool is the closest single existing "transparency-anchored downloader," but it +computed a flat whole-blob digest, verified only after the full download, and did +not enforce a signer-identity policy. Ecosystem deployments — SLSA provenance +(v1.0, 2023; v1.2, 2025), npm provenance (2023), and PyPI PEP 740 attestations +(2024) — show transparency-logged signing at scale, and recent work on catching +malicious package releases via Rekor monitoring [Trail of Bits 2025] is direct +evidence for blacklight's audit-trail claim. Interview studies of signing +adoption [Kalu et al. 2025] motivate blacklight's stance that verification must +be automatic and sit in the transfer path, not be a manual step. + +**Novelty statement (stated honestly).** Every primitive blacklight uses — BLAKE3, +Bao verified streaming, Merkle transparency logs, Sigstore keyless signing — is +prior work and is cited above. The contribution is the *composition* and the +*trust-chain design*: to our knowledge, blacklight is the first tool in which a +transparency-logged signature covers a Merkle root that authorizes +chunk-granular, abort-on-first-bad-byte verification during transfer from +untrusted mirrors. Signed HTTP Exchanges and content-addressed streamers +(iroh/BitTorrent v2) are the nearest architectural relatives, and we do not claim +novelty over their individual mechanisms — only over uniting them. + +## 7. Limitations and Future Work + +- **Rollback/freshness.** An adversary can serve an older, still-validly-signed + release. Blacklight has no notion of "latest." Adopting TUF-style timestamp and + snapshot metadata, or a signed monotonic version in the manifest with a + freshness check, is the natural next step. +- **Unaudited streaming dependencies.** bao-tree and the Bao construction are + maintained and widely used (iroh) but not formally audited; blacklight inherits + that caveat. The signing/verification path rests on sigstore-rust 0.x, whose + API churns; we pin exact versions and isolate it behind a thin module. +- **Outboard availability.** The client needs the outboard to verify; a + network-level adversary can withhold it (denial of service), though it cannot + forge it. Serving the Bao *combined* encoding, or embedding the tree, would + remove the separate fetch at some bandwidth cost. +- **Log monitoring is out of band.** Transparency yields detection only if + someone watches. Blacklight relies on existing monitors (`rekor-monitor`, + witness networks) rather than building its own; integrating an + inclusion-and-consistency-checking monitor into the publisher workflow is + future work. +- **Verified resume.** Chunk-group-aligned range requests would let an + interrupted download resume while re-verifying only the trailing partial group; + the outboard already makes this straightforward and it is planned. + +## 8. Conclusion + +The desire to "just check the hash so hackers can't tamper with my downloads" is +a good one; it fails only because the usual realizations put the check in the +wrong place, bind it to the wrong hash, or anchor it in nothing an attacker can't +also rewrite. Moving the check to the endpoint, binding it to a collision-resistant +Merkle root, signing that root keylessly, and recording the signature in a public +transparency log turns the instinct into a guarantee — and doing the verification +*as the bytes stream* turns "reject bad downloads" into "stop reading the moment a +byte is wrong." Blacklight shows the combination is buildable today from +maintained parts, and that it catches a real in-transit attack after reading a +single bad chunk group rather than an entire tampered file. + +## References + +1. J. H. Saltzer, D. P. Reed, D. D. Clark. "End-to-End Arguments in System + Design." *ACM Transactions on Computer Systems*, 2(4), 1984. +2. R. C. Merkle. "A Digital Signature Based on a Conventional Encryption + Function." *CRYPTO '87*, LNCS 293. And "A Certified Digital Signature." + *CRYPTO '89*, LNCS 435. +3. J. O'Connor, J.-P. Aumasson, S. Neves, Z. Wilcox-O'Hearn. "BLAKE3: one + function, fast everywhere." Specification, 2020. + https://github.com/BLAKE3-team/BLAKE3 +4. J. O'Connor. "Bao: an implementation of BLAKE3 verified streaming." + Specification. https://github.com/oconnor663/bao +5. J. Chapweske, G. Mohr. "Tree Hash EXchange format (THEX)." + Internet-Draft draft-jchapweske-thex-02, 2003 (expired). +6. B. Cohen. "The BitTorrent Protocol Specification v2." BEP 52, 2008/2020. + https://www.bittorrent.org/beps/bep_0052.html +7. B. Laurie, A. Langley, E. Kasper. "Certificate Transparency." RFC 6962, 2013. + Updated as RFC 9162 (Laurie, Messeri, Stradling), 2021. +8. M. Chase, S. Meiklejohn. "Transparency Overlays and Applications." + *ACM CCS 2016.* +9. R. Cox. "Transparent Logs for Skeptical Clients." 2019. + https://research.swtch.com/tlog — and the Go module checksum database, + go.dev/blog/module-mirror-launch, 2019. +10. J. Samuel, N. Mathewson, J. Cappos, R. Dingledine. "Survivable Key Compromise + in Software Update Systems." *ACM CCS 2010.* +11. T. K. Kuppusamy et al. "Uptane: Securing Software Updates for Automobiles." + *escar Europe 2016.* +12. K. Nikitin et al. "CHAINIAC: Proactive Software-Update Transparency via + Collectively Signed Skipchains and Verified Builds." *USENIX Security 2017.* +13. A. Tomescu, S. Devadas. "Catena: Efficient Non-equivocation via Bitcoin." + *IEEE S&P 2017.* +14. M. Al-Bassam, S. Meiklejohn. "Contour: A Practical System for Binary + Transparency." *CBT @ ESORICS 2018* (arXiv:1712.08427). +15. Z. Newman, J. S. Meyers, S. Torres-Arias. "Sigstore: Software Signing for + Everybody." *ACM CCS 2022.* +16. K. Merrill, Z. Newman, S. Torres-Arias, K. R. Sollins. "Speranza: Usable, + Privacy-friendly Software Signing." *ACM CCS 2023.* +17. K. Kalu et al. "An Industry Interview Study of Software Signing for Supply + Chain Security." *USENIX Security 2025.* +18. W3C. "Subresource Integrity." W3C Recommendation, 2016. +19. R. Polli, L. Pardue. "Digest Fields." RFC 9530, 2024. +20. SLSA: "Supply-chain Levels for Software Artifacts," v1.0 (2023), v1.2 (2025). + https://slsa.dev — npm provenance (GitHub, 2023); PyPI PEP 740 digital + attestations (2024). +21. Trail of Bits. "Catching malicious package releases using a transparency + log." 2025. https://blog.trailofbits.com/2025/12/12/ diff --git a/src/fetch.rs b/src/fetch.rs new file mode 100644 index 0000000..171532c --- /dev/null +++ b/src/fetch.rs @@ -0,0 +1,343 @@ +//! `blacklight fetch`: verify the manifest's Sigstore bundle offline, then +//! stream the artifact, verifying every 16 KiB chunk group against the signed +//! BLAKE3 root as it arrives — aborting the transfer on the first bad byte. +//! +//! Order of operations matters. Nothing about the artifact is trusted until: +//! 1. the manifest bytes verify against the Sigstore bundle (signature, +//! Fulcio cert chain, Rekor inclusion proof, and the required identity +//! policy), and +//! 2. every delivered chunk group verifies against the root in that manifest. +//! +//! The network, the mirror, and the CDN are all untrusted. + +use anyhow::{Context, Result, anyhow, bail}; +use futures_util::StreamExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::manifest::{BUNDLE_SUFFIX, MANIFEST_SUFFIX, Manifest, OUTBOARD_SUFFIX}; +use crate::sigstore::{self, Env, IdentityPolicy}; +use crate::verify::{GROUP_LEN, GroupPlan, VerifyError}; + +/// Give up if a connection can't be established, or if an established transfer +/// stalls with no bytes for this long. `read_timeout` (not a total `timeout`) +/// is deliberate: a large but healthy download must not be aborted just for +/// taking a while, only for going silent. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const READ_TIMEOUT: Duration = Duration::from_secs(30); + +fn http_client() -> Result { + reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .build() + .context("building HTTP client") +} + +/// Distinct error type so `main` can map integrity failures to exit code 3. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct TamperError(pub String); + +pub struct Options { + pub manifest: String, + pub expect_identity: Option, + pub expect_issuer: Option, + pub allow_unsigned: bool, + pub production: bool, + pub url_override: Option, + pub output: Option, +} + +pub async fn fetch(opts: Options) -> Result<()> { + let env = if opts.production { + Env::Production + } else { + Env::Staging + }; + let client = http_client()?; + + // 1. Load manifest + bundle (from URL or local path). + let manifest_bytes = load(&client, &opts.manifest) + .await + .context("cannot load manifest")?; + + if opts.allow_unsigned { + eprintln!( + "WARNING: --allow-unsigned — the manifest root is NOT proven to come from any publisher." + ); + } else { + let policy = build_policy(&opts)?; + let bundle_src = format!("{}{BUNDLE_SUFFIX}", opts.manifest); + let bundle_bytes = load(&client, &bundle_src) + .await + .with_context(|| format!("cannot load Sigstore bundle from {bundle_src}"))?; + eprintln!("verifying Sigstore bundle offline ({env:?}) …"); + let v = sigstore::verify_manifest(&manifest_bytes, &bundle_bytes, &policy, env) + .context("Sigstore verification FAILED — refusing to download")?; + eprintln!(" signer {} (issuer {})", v.identity, v.issuer); + if let Some(t) = v.integrated_time { + eprintln!(" rekor integrated at unix time {t}"); + } + } + + // Only now do we parse and trust the manifest contents. + let manifest = Manifest::from_bytes(&manifest_bytes)?; + let root = manifest.root()?; + eprintln!( + "manifest verified: {} ({} bytes, root {})", + manifest.name, + manifest.size, + &manifest.blake3_root[..16] + ); + + // 2. Fetch the outboard tree (small; downloaded fully before streaming data). + let outboard_src = artifact_relative(&opts.manifest, OUTBOARD_SUFFIX, &manifest); + let outboard = load(&client, &outboard_src) + .await + .with_context(|| format!("cannot load outboard from {outboard_src}"))?; + + // Build + self-check the verification plan. A tampered outboard is caught + // here (it won't hash up to the signed root). + let plan = GroupPlan::new(root, manifest.size, &outboard) + .map_err(|e| TamperError(e.to_string())) + .context("outboard failed to verify against signed root")?; + + // 3. Stream the artifact, verifying each group as it lands. + let artifact_src = resolve_artifact_url(&opts, &manifest)?; + let out_path = opts + .output + .clone() + .unwrap_or_else(|| PathBuf::from(&manifest.name)); + eprintln!("streaming {artifact_src} …"); + + stream_verified(&client, &artifact_src, &plan, &out_path).await +} + +/// The core loop: pull bytes, accumulate into 16 KiB groups, verify each group +/// against the plan the instant it completes, and abort on the first mismatch. +async fn stream_verified( + client: &reqwest::Client, + src: &str, + plan: &GroupPlan, + out_path: &Path, +) -> Result<()> { + let tmp_path = out_path.with_extension("blacklight-partial"); + let mut tmp = tokio::fs::File::create(&tmp_path) + .await + .with_context(|| format!("cannot create {}", tmp_path.display()))?; + + let result = stream_verified_inner(client, src, plan, &mut tmp).await; + + // On any failure — tampering or network — never leave a partial file that + // could be mistaken for a good download. + if result.is_err() { + drop(tmp); + let _ = tokio::fs::remove_file(&tmp_path).await; + return result.map(|_| ()); + } + + tmp.flush().await?; + tmp.sync_all().await?; + drop(tmp); + tokio::fs::rename(&tmp_path, out_path) + .await + .with_context(|| format!("cannot finalize {}", out_path.display()))?; + let bytes = result.unwrap(); + eprintln!( + "OK — {bytes} bytes verified against the signed root; wrote {}", + out_path.display() + ); + Ok(()) +} + +async fn stream_verified_inner( + client: &reqwest::Client, + src: &str, + plan: &GroupPlan, + tmp: &mut tokio::fs::File, +) -> Result { + let mut group_buf: Vec = Vec::with_capacity(GROUP_LEN); + let mut state = StreamState { + plan, + tmp, + group_buf: &mut group_buf, + group_index: 0, + total: 0, + }; + + if is_url(src) { + let resp = client + .get(src) + .send() + .await + .with_context(|| format!("request to {src} failed"))?; + if !resp.status().is_success() { + bail!("server returned HTTP {} for {src}", resp.status()); + } + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + // A network read error is NOT tampering — surface it as an ordinary + // error (exit 1), distinct from integrity failure (exit 3). + let chunk = chunk.with_context(|| format!("network error reading {src}"))?; + state.feed(&chunk).await?; + } + } else { + // Local artifact (e.g. a local manifest path, or --url pointing at a + // file). Stream it from disk through the exact same verification. + let mut file = tokio::fs::File::open(src) + .await + .with_context(|| format!("cannot open local artifact {src}"))?; + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = file + .read(&mut buf) + .await + .with_context(|| format!("error reading {src}"))?; + if n == 0 { + break; + } + state.feed(&buf[..n]).await?; + } + } + + state.finish().await +} + +/// Accumulates the byte stream into 16 KiB groups, verifying and writing each +/// as it completes. Shared by the URL and local-file paths. +struct StreamState<'a> { + plan: &'a GroupPlan, + tmp: &'a mut tokio::fs::File, + group_buf: &'a mut Vec, + group_index: usize, + total: u64, +} + +impl StreamState<'_> { + async fn feed(&mut self, mut data: &[u8]) -> Result<()> { + while !data.is_empty() { + let need = GROUP_LEN - self.group_buf.len(); + let take = need.min(data.len()); + self.group_buf.extend_from_slice(&data[..take]); + data = &data[take..]; + if self.group_buf.len() == GROUP_LEN { + verify_group(self.plan, self.group_index, self.group_buf)?; + self.tmp.write_all(self.group_buf).await?; + self.total += self.group_buf.len() as u64; + self.group_index += 1; + self.group_buf.clear(); + } + } + Ok(()) + } + + async fn finish(mut self) -> Result { + // Final short group (if any). + if !self.group_buf.is_empty() { + verify_group(self.plan, self.group_index, self.group_buf)?; + self.tmp.write_all(self.group_buf).await?; + self.total += self.group_buf.len() as u64; + self.group_index += 1; + } + + // Length must match exactly: a truncated or padded stream is a mismatch. + if self.total != self.plan.size() { + return Err(TamperError(format!( + "stream length {} != signed size {} (truncated or padded)", + self.total, + self.plan.size() + )) + .into()); + } + if self.group_index != self.plan.group_count() { + return Err(TamperError(format!( + "received {} groups, expected {}", + self.group_index, + self.plan.group_count() + )) + .into()); + } + Ok(self.total) + } +} + +fn verify_group(plan: &GroupPlan, index: usize, data: &[u8]) -> Result<()> { + match plan.check_group(index, data) { + Ok(()) => Ok(()), + Err(e @ VerifyError::GroupMismatch { .. }) => { + // The whole point: report exactly where tampering was caught, and + // how little was fetched past it (at most one group). + Err(TamperError(format!( + "{e} — aborting transfer; at most {} extra bytes were fetched past the tampered byte", + GROUP_LEN + )) + .into()) + } + Err(e) => Err(TamperError(e.to_string()).into()), + } +} + +fn build_policy(opts: &Options) -> Result { + let identity = opts.expect_identity.clone().ok_or_else(|| { + anyhow!("--expect-identity is required (or pass --allow-unsigned to skip verification)") + })?; + let issuer = opts.expect_issuer.clone().ok_or_else(|| { + anyhow!("--expect-issuer is required (or pass --allow-unsigned to skip verification)") + })?; + Ok(IdentityPolicy { identity, issuer }) +} + +/// Load bytes from an http(s) URL or a local filesystem path. +async fn load(client: &reqwest::Client, src: &str) -> Result> { + if is_url(src) { + let resp = client.get(src).send().await?; + if !resp.status().is_success() { + bail!("HTTP {} for {src}", resp.status()); + } + Ok(resp.bytes().await?.to_vec()) + } else { + Ok(tokio::fs::read(src).await?) + } +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") || s.starts_with("https://") +} + +/// Derive a sibling resource's location from the manifest location by swapping +/// the manifest suffix for another. Works for both URLs and paths. +fn artifact_relative(manifest_src: &str, new_suffix: &str, manifest: &Manifest) -> String { + if let Some(base) = manifest_src.strip_suffix(MANIFEST_SUFFIX) { + // base already ends with the artifact name; e.g. ".../demo.bin" + format!("{base}{new_suffix}") + } else { + // Fall back to a sibling of the manifest named after the artifact. + sibling(manifest_src, &format!("{}{new_suffix}", manifest.name)) + } +} + +/// Decide where the artifact bytes come from: explicit override, else the +/// manifest location minus its suffix, else the manifest's URL hints. The +/// result may be an http(s) URL or a local path; the streaming reader handles +/// both. +fn resolve_artifact_url(opts: &Options, manifest: &Manifest) -> Result { + if let Some(u) = &opts.url_override { + return Ok(u.clone()); + } + if let Some(base) = opts.manifest.strip_suffix(MANIFEST_SUFFIX) { + return Ok(base.to_string()); + } + if let Some(u) = manifest.urls.first() { + return Ok(u.clone()); + } + bail!("cannot determine artifact URL; pass --url") +} + +fn sibling(src: &str, name: &str) -> String { + match src.rfind('/') { + Some(i) => format!("{}{name}", &src[..=i]), + None => name.to_string(), + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8c78f7a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,130 @@ +mod fetch; +mod manifest; +mod publish; +mod sigstore; +mod verify; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use std::process::ExitCode; + +/// Verified-streaming downloads anchored in the Sigstore transparency log. +/// +/// `publish` produces, next to the artifact: an outboard BLAKE3 Merkle tree +/// (.obao), a manifest (.blacklight.json), and a Sigstore bundle +/// (.blacklight.json.sigstore.json). `fetch` verifies the bundle offline, +/// then streams the artifact, verifying every 16 KiB chunk group against the +/// signed root as it arrives — aborting on the first tampered byte. +#[derive(Parser)] +#[command(name = "blacklight", version, about)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Hash a file, build its outboard tree, and sign the manifest. + Publish { + /// File to publish. + file: PathBuf, + /// Directory for the sidecar files (defaults to the file's directory). + #[arg(long)] + out: Option, + /// Skip signing; emit only .obao + manifest. For local testing. + /// (Signing is otherwise keyless via OIDC — ambient CI or browser.) + #[arg(long)] + unsigned: bool, + /// Use Sigstore production infrastructure instead of staging. + #[arg(long)] + production: bool, + /// URL hint(s) to embed in the manifest. + #[arg(long)] + url: Vec, + }, + /// Fetch and verify an artifact via its manifest URL (or local path). + Fetch { + /// Manifest URL (…/file.blacklight.json) or local path. + manifest: String, + /// Expected signer identity (email or URI SAN), e.g. you@example.com. + #[arg(long)] + expect_identity: Option, + /// Expected OIDC issuer, e.g. https://github.com/login/oauth. + #[arg(long)] + expect_issuer: Option, + /// DANGEROUS: skip Sigstore bundle verification entirely. The + /// download is still integrity-checked against the manifest root, + /// but nothing proves who published that root. + #[arg(long)] + allow_unsigned: bool, + /// Verify against the Sigstore production trust root instead of staging. + #[arg(long)] + production: bool, + /// Override the artifact URL (defaults to manifest URL minus suffix, + /// then manifest url hints). + #[arg(long)] + url: Option, + /// Output path (defaults to the manifest's `name` in the current dir). + #[arg(short, long)] + output: Option, + }, +} + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err:#}"); + // Tampering gets a distinct exit code so scripts/tests can tell + // "integrity violation" from ordinary failures. + if err.is::() { + ExitCode::from(3) + } else { + ExitCode::FAILURE + } + } + } +} + +async fn run() -> Result<()> { + match Cli::parse().command { + Command::Publish { + file, + out, + unsigned, + production, + url, + } => { + publish::publish(publish::Options { + file, + out, + unsigned, + production, + urls: url, + }) + .await + } + Command::Fetch { + manifest, + expect_identity, + expect_issuer, + allow_unsigned, + production, + url, + output, + } => { + fetch::fetch(fetch::Options { + manifest, + expect_identity, + expect_issuer, + allow_unsigned, + production, + url_override: url, + output, + }) + .await + } + } +} diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..bdd70c1 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,199 @@ +//! The blacklight manifest: the small signed document that binds a file's +//! BLAKE3 Merkle root, its length, and the chunk-group size used for the +//! outboard tree. +//! +//! The Sigstore signature covers the manifest's exact bytes as hosted, so the +//! manifest is verified as raw bytes *before* parsing. There is deliberately +//! no canonicalization step: whatever bytes the publisher signed are the bytes +//! the client must present. + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// Chunk-group log2 (in 1 KiB BLAKE3 chunks) fixed for format v1. +/// 4 => 16 chunks => 16 KiB groups, ~0.4% outboard overhead. +pub const CHUNK_GROUP_LOG: u8 = 4; + +pub const MANIFEST_SUFFIX: &str = ".blacklight.json"; +pub const OUTBOARD_SUFFIX: &str = ".obao"; +pub const BUNDLE_SUFFIX: &str = ".sigstore.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + /// Format version. Must be 1. + pub v: u32, + /// Original file name (basename only; informational). + pub name: String, + /// File size in bytes. + pub size: u64, + /// Hex-encoded BLAKE3 root hash of the file. Identical to + /// `blake3::hash(file)` — chunk grouping affects only the outboard layout. + pub blake3_root: String, + /// Log2 of the chunk-group size in 1 KiB chunks. Baked into the outboard + /// tree layout, so it is part of the signed statement. + pub chunk_group_log: u8, + /// Optional artifact URL hints. The client may override; integrity never + /// depends on where the bytes come from. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub urls: Vec, +} + +impl Manifest { + pub fn new(name: String, size: u64, root: blake3::Hash) -> Self { + Self { + v: 1, + name, + size, + blake3_root: root.to_hex().to_string(), + chunk_group_log: CHUNK_GROUP_LOG, + urls: Vec::new(), + } + } + + /// Serialize to the exact bytes that get signed and hosted. + pub fn to_bytes(&self) -> Result> { + let mut out = serde_json::to_vec_pretty(self)?; + out.push(b'\n'); + Ok(out) + } + + /// Parse and validate. Callers must verify the Sigstore bundle over the + /// raw bytes *before* trusting anything returned here. + pub fn from_bytes(bytes: &[u8]) -> Result { + let m: Manifest = serde_json::from_slice(bytes).context("manifest is not valid JSON")?; + if m.v != 1 { + bail!("unsupported manifest version {} (expected 1)", m.v); + } + if m.chunk_group_log != CHUNK_GROUP_LOG { + bail!( + "unsupported chunk_group_log {} (format v1 fixes it at {})", + m.chunk_group_log, + CHUNK_GROUP_LOG + ); + } + // `name` is a basename only (documented, and used by `fetch` as the + // default output path). Reject anything that could escape the current + // directory — a hostile manifest must not steer writes to `../../…`, + // an absolute path, or `.`/`..`. + m.validate_name()?; + m.root()?; + Ok(m) + } + + /// Enforce the basename-only contract for `name`. + fn validate_name(&self) -> Result<()> { + let name = &self.name; + if name.is_empty() { + bail!("manifest name is empty"); + } + if name == "." || name == ".." { + bail!("manifest name {name:?} is a path traversal component"); + } + if name.contains('/') || name.contains('\\') || name.contains('\0') { + bail!("manifest name {name:?} must be a bare filename, not a path"); + } + // Belt and suspenders: the OS-parsed form must be exactly one normal + // component (rejects absolute paths, drive prefixes, `..`, etc.). + let path = std::path::Path::new(name); + let mut comps = path.components(); + match (comps.next(), comps.next()) { + (Some(std::path::Component::Normal(c)), None) if c == name.as_str() => Ok(()), + _ => bail!("manifest name {name:?} is not a single filename component"), + } + } + + pub fn root(&self) -> Result { + blake3::Hash::from_hex(&self.blake3_root).with_context(|| { + format!( + "blake3_root {:?} is not a 64-char hex hash", + self.blake3_root + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Manifest { + Manifest::new("demo.bin".into(), 1234, blake3::hash(b"hello")) + } + + #[test] + fn round_trip() { + let m = sample(); + let bytes = m.to_bytes().unwrap(); + let back = Manifest::from_bytes(&bytes).unwrap(); + assert_eq!(m, back); + assert_eq!(back.root().unwrap(), blake3::hash(b"hello")); + } + + #[test] + fn rejects_wrong_version() { + let mut m = sample(); + m.v = 2; + let err = Manifest::from_bytes(&m.to_bytes().unwrap()).unwrap_err(); + assert!(err.to_string().contains("unsupported manifest version")); + } + + #[test] + fn rejects_wrong_chunk_group() { + let mut m = sample(); + m.chunk_group_log = 0; + let err = Manifest::from_bytes(&m.to_bytes().unwrap()).unwrap_err(); + assert!(err.to_string().contains("chunk_group_log")); + } + + #[test] + fn rejects_bad_root_hex() { + let mut m = sample(); + m.blake3_root = "zz".into(); + assert!(Manifest::from_bytes(&m.to_bytes().unwrap()).is_err()); + } + + #[test] + fn rejects_unknown_fields() { + let json = + br#"{"v":1,"name":"a","size":1,"blake3_root":"00","chunk_group_log":4,"evil":true}"#; + assert!(Manifest::from_bytes(json).is_err()); + } + + #[test] + fn rejects_path_traversal_names() { + for evil in [ + "../evil", + "../../etc/passwd", + "/etc/passwd", + "a/b", + "sub/../x", + "..", + ".", + "", + "back\\slash", + "with\0null", + ] { + let mut m = sample(); + m.name = evil.into(); + let bytes = m.to_bytes().unwrap(); + assert!( + Manifest::from_bytes(&bytes).is_err(), + "should have rejected name {evil:?}" + ); + } + } + + #[test] + fn accepts_plain_basenames() { + for ok in ["demo.bin", "my-file_v1.2.tar.gz", "a"] { + let mut m = sample(); + m.name = ok.into(); + let bytes = m.to_bytes().unwrap(); + assert!( + Manifest::from_bytes(&bytes).is_ok(), + "should have accepted name {ok:?}" + ); + } + } +} diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..a4c95d0 --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,100 @@ +//! `blacklight publish`: hash the file, build its outboard Merkle tree, write +//! the manifest, and (unless `--unsigned`) sign the manifest with Sigstore. + +use anyhow::{Context, Result}; +use bao_tree::BlockSize; +use bao_tree::io::outboard::PreOrderOutboard; +use bao_tree::io::sync::CreateOutboard; +use std::fs::File; +use std::path::{Path, PathBuf}; + +use crate::manifest::{BUNDLE_SUFFIX, CHUNK_GROUP_LOG, MANIFEST_SUFFIX, Manifest, OUTBOARD_SUFFIX}; +use crate::sigstore; + +/// 16 KiB chunk groups. Fixed by the manifest format (see CHUNK_GROUP_LOG). +pub const BLOCK_SIZE: BlockSize = BlockSize::from_chunk_log(CHUNK_GROUP_LOG); + +pub struct Options { + pub file: PathBuf, + pub out: Option, + pub unsigned: bool, + pub production: bool, + pub urls: Vec, +} + +pub async fn publish(opts: Options) -> Result<()> { + let file = &opts.file; + let name = file + .file_name() + .and_then(|n| n.to_str()) + .context("input file has no valid name")? + .to_string(); + + let out_dir = opts + .out + .clone() + .unwrap_or_else(|| file.parent().map(Path::to_path_buf).unwrap_or_default()); + std::fs::create_dir_all(&out_dir).ok(); + + // Build the outboard tree. bao-tree hashes the file as it goes; the root it + // returns equals plain blake3::hash(file) — chunk grouping only changes the + // outboard layout, not the hashing math. + eprintln!("hashing and building outboard tree for {name} …"); + let f = File::open(file).with_context(|| format!("cannot open {}", file.display()))?; + let size = f.metadata()?.len(); + let ob = PreOrderOutboard::>::create(&f, BLOCK_SIZE) + .context("failed to build outboard tree")?; + let root = ob.root; + + let outboard_path = sidecar(&out_dir, &name, OUTBOARD_SUFFIX); + std::fs::write(&outboard_path, &ob.data) + .with_context(|| format!("cannot write {}", outboard_path.display()))?; + + // Manifest. + let mut manifest = Manifest::new(name.clone(), size, root); + manifest.urls = opts.urls.clone(); + let manifest_bytes = manifest.to_bytes()?; + let manifest_path = sidecar(&out_dir, &name, MANIFEST_SUFFIX); + std::fs::write(&manifest_path, &manifest_bytes) + .with_context(|| format!("cannot write {}", manifest_path.display()))?; + + eprintln!(" root {}", root.to_hex()); + eprintln!(" size {size} bytes"); + eprintln!( + " outboard {} ({} bytes, {:.2}% overhead)", + outboard_path.display(), + ob.data.len(), + 100.0 * ob.data.len() as f64 / size.max(1) as f64 + ); + eprintln!(" manifest {}", manifest_path.display()); + + // Sign the manifest bytes. + if opts.unsigned { + eprintln!(" (unsigned — no Sigstore bundle emitted)"); + return Ok(()); + } + + let bundle_path = sidecar( + &out_dir, + &name, + &format!("{MANIFEST_SUFFIX}{BUNDLE_SUFFIX}"), + ); + let env = if opts.production { + sigstore::Env::Production + } else { + sigstore::Env::Staging + }; + eprintln!("signing manifest via Sigstore ({env:?}, keyless OIDC) …"); + let bundle_json = sigstore::sign_manifest(&manifest_bytes, env) + .await + .context("Sigstore signing failed")?; + std::fs::write(&bundle_path, &bundle_json) + .with_context(|| format!("cannot write {}", bundle_path.display()))?; + eprintln!(" bundle {}", bundle_path.display()); + eprintln!("done. host: artifact, {OUTBOARD_SUFFIX}, {MANIFEST_SUFFIX}, and the bundle."); + Ok(()) +} + +fn sidecar(dir: &Path, name: &str, suffix: &str) -> PathBuf { + dir.join(format!("{name}{suffix}")) +} diff --git a/src/sigstore.rs b/src/sigstore.rs new file mode 100644 index 0000000..59f3cbb --- /dev/null +++ b/src/sigstore.rs @@ -0,0 +1,119 @@ +//! Sigstore signing and verification, wrapped behind small functions so the +//! rest of blacklight never depends on the exact sigstore-rust API (which +//! churns fast at 0.x). See docs/DESIGN.md for the trust chain. +//! +//! Signing is **keyless only**: sigstore-rust 0.10 has no path for a +//! self-managed private key, so `publish` obtains an OIDC identity (ambient CI +//! credentials if present, otherwise an interactive browser flow) and Fulcio +//! issues a short-lived certificate bound to that identity. The signature and a +//! Rekor inclusion proof are stapled into a v0.3 bundle. +//! +//! Verification is offline: the trusted roots for staging and production are +//! embedded in `sigstore-trust-root`, so `fetch` verifies the signature, the +//! certificate chain, and the Rekor inclusion proof without any network call — +//! and enforces the caller's identity policy (exact SAN + issuer match). + +use anyhow::{Context, Result, anyhow, bail}; +use sigstore_trust_root::{SIGSTORE_PRODUCTION_TRUSTED_ROOT, SIGSTORE_STAGING_TRUSTED_ROOT}; +use sigstore_types::Bundle; +use sigstore_verify::{VerificationPolicy, verify as sig_verify}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Env { + Staging, + Production, +} + +/// Identity policy the verifier enforces against the signing certificate. +#[derive(Debug, Clone)] +pub struct IdentityPolicy { + pub identity: String, + pub issuer: String, +} + +/// Result of a successful verification, surfaced to the user. +#[derive(Debug, Clone)] +pub struct Verified { + pub identity: String, + pub issuer: String, + /// Rekor integrated time (unix seconds), if the entry carried one. + pub integrated_time: Option, +} + +/// Sign the manifest bytes keyless and return the serialized v0.3 bundle JSON. +pub async fn sign_manifest(manifest_bytes: &[u8], env: Env) -> Result> { + use sigstore_oidc::{IdentityToken, get_identity_token}; + use sigstore_sign::SigningContext; + + let context = match env { + Env::Staging => SigningContext::staging(), + Env::Production => SigningContext::production(), + }; + + // Prefer zero-interaction ambient CI credentials; fall back to browser/OOB + // only when there genuinely is no ambient identity. A detection *error* + // (e.g. an id-token endpoint that is present but failing) is surfaced, not + // silently swallowed — otherwise a broken CI signing setup would quietly + // pop a browser that no one is watching. + let token: IdentityToken = match IdentityToken::detect_ambient().await { + Ok(Some(t)) => { + eprintln!(" using ambient CI OIDC identity"); + t + } + Err(e) => { + return Err(anyhow!("ambient OIDC detection failed: {e}")).context( + "an ambient CI identity was present but could not be used; \ + refusing to fall back to an interactive browser flow", + ); + } + Ok(None) => { + eprintln!(" no ambient CI identity; opening browser for OIDC sign-in …"); + get_identity_token(context.config().oidc_url.as_deref()) + .await + .context("could not obtain an OIDC identity token")? + } + }; + + let signer = context.signer(token); + let bundle = signer + .sign(manifest_bytes) + .await + .context("Fulcio/Rekor signing failed")?; + let json = bundle.to_json_pretty().context("serializing bundle")?; + Ok(json.into_bytes()) +} + +/// Verify the bundle over the manifest bytes, offline, enforcing the policy. +pub fn verify_manifest( + manifest_bytes: &[u8], + bundle_json: &[u8], + policy: &IdentityPolicy, + env: Env, +) -> Result { + let root_json = match env { + Env::Staging => SIGSTORE_STAGING_TRUSTED_ROOT, + Env::Production => SIGSTORE_PRODUCTION_TRUSTED_ROOT, + }; + let trusted_root = sigstore_trust_root::TrustedRoot::from_json(root_json) + .context("loading embedded trusted root")?; + + let bundle_str = std::str::from_utf8(bundle_json).context("bundle is not UTF-8")?; + let bundle = Bundle::from_json(bundle_str).context("parsing Sigstore bundle")?; + + let vpolicy = VerificationPolicy::default() + .require_identity(&policy.identity) + .require_issuer(&policy.issuer); + + let result = sig_verify(manifest_bytes, &bundle, &vpolicy, &trusted_root) + .map_err(|e| anyhow!("bundle verification error: {e}"))?; + + if !result.success { + bail!("bundle did not verify against the trusted root / identity policy"); + } + + Ok(Verified { + identity: result.identity.unwrap_or_else(|| policy.identity.clone()), + issuer: result.issuer.unwrap_or_else(|| policy.issuer.clone()), + integrated_time: result.integrated_time, + }) +} diff --git a/src/verify.rs b/src/verify.rs new file mode 100644 index 0000000..55b5b84 --- /dev/null +++ b/src/verify.rs @@ -0,0 +1,334 @@ +//! Forward-only, fail-fast streaming verifier. +//! +//! bao-tree's `valid_ranges` needs random-access (seekable) data and reports a +//! corrupt group as an *omitted* range rather than an error — awkward for the +//! "stop the moment a byte is wrong" behaviour that is the whole point here. So +//! we verify the way the BLAKE3 tree is actually defined, using the public +//! `blake3::hazmat` API (the same primitives bao-tree uses internally): +//! +//! * Feed the plain file bytes in order. +//! * Every time a full 16 KiB chunk group completes, compute its BLAKE3 +//! subtree chaining value and compare it against the corresponding leaf +//! hash held in the (already-verified-by-signature) outboard. +//! * On the first mismatch, abort immediately — the caller has fetched at +//! most one chunk group past the tampered byte. +//! +//! The outboard is a pre-order sequence of 64-byte (left CV, right CV) parent +//! pairs. We don't need the interior parents to check leaves: the leaf hashes +//! are the CVs stored at the lowest level. Rather than reason about pre-order +//! offsets, we reconstruct the expected per-group leaf CVs top-down from the +//! outboard once (cheap: the whole outboard is ~0.4% of the file and already in +//! memory), then stream-check data groups against that vector. We also verify +//! the outboard's own internal consistency up to the trusted root while doing +//! so, so a tampered outboard is caught too. + +use blake3::Hash; +use blake3::hazmat::{HasherExt, Mode, merge_subtrees_non_root, merge_subtrees_root}; + +const CHUNK_LEN: u64 = 1024; +const CHUNK_GROUP_LOG: u8 = 4; +/// 16 KiB. +pub const GROUP_LEN: usize = (CHUNK_LEN as usize) << CHUNK_GROUP_LOG; + +#[derive(Debug, thiserror::Error)] +pub enum VerifyError { + #[error( + "integrity check failed: chunk group {group} (byte offset {offset}) does not match the signed root" + )] + GroupMismatch { group: u64, offset: u64 }, + #[error( + "integrity check failed: the outboard tree does not hash to the signed root (tampered .obao)" + )] + OutboardRootMismatch, + #[error("integrity check failed: stream longer than the signed size ({expected} bytes)")] + LongStream { expected: u64 }, + #[error("malformed outboard: {0}")] + BadOutboard(&'static str), +} + +/// Expected leaf chaining values, one per 16 KiB group, in file order, derived +/// from the outboard and checked to hash up to the trusted root. +#[derive(Debug)] +pub struct GroupPlan { + size: u64, + /// Per-group expected CV. The final group may be short. + leaf_cvs: Vec, +} + +impl GroupPlan { + pub fn group_count(&self) -> usize { + self.leaf_cvs.len() + } + + /// Build and validate the plan from the trusted root, the file size, and the + /// outboard bytes. Fails if the outboard doesn't hash to `root`. + pub fn new(root: Hash, size: u64, outboard: &[u8]) -> Result { + if size == 0 { + // Empty file: the root is the hash of the empty input; no groups. + let empty = blake3::hash(b""); + if empty != root { + return Err(VerifyError::OutboardRootMismatch); + } + return Ok(GroupPlan { + size, + leaf_cvs: vec![], + }); + } + let group_count = size.div_ceil(GROUP_LEN as u64) as usize; + if group_count == 1 { + // Single group: the whole file is one leaf and its root hash is the + // trusted root. There are no parent pairs, so the outboard is empty. + // check_group() recomputes and compares against `root` directly. + if !outboard.is_empty() { + return Err(VerifyError::BadOutboard( + "single-group outboard must be empty", + )); + } + return Ok(GroupPlan { + size, + leaf_cvs: vec![root], + }); + } + let mut leaves = Vec::with_capacity(group_count); + let mut cursor = OutboardCursor { + data: outboard, + pos: 0, + }; + // Walk the tree top-down. Each leaf subtree covers up to GROUP_LEN bytes. + let computed_root = build(&mut cursor, 0, size, /* is_root */ true, &mut leaves)?; + if cursor.pos != outboard.len() { + return Err(VerifyError::BadOutboard("trailing bytes in outboard")); + } + if computed_root != root { + return Err(VerifyError::OutboardRootMismatch); + } + debug_assert_eq!(leaves.len(), group_count); + Ok(GroupPlan { + size, + leaf_cvs: leaves, + }) + } + + /// Verify the CV of the `group_index`-th group of `data` (the plain group + /// bytes) against the plan. Returns the group's byte offset on mismatch. + pub fn check_group(&self, group_index: usize, data: &[u8]) -> Result<(), VerifyError> { + let expected = self + .leaf_cvs + .get(group_index) + .ok_or(VerifyError::LongStream { + expected: self.size, + })?; + let start_chunk = (group_index as u64) << CHUNK_GROUP_LOG; + let is_root = self.leaf_cvs.len() == 1; // single-group file: leaf is the root + let actual = leaf_cv(start_chunk, data, is_root); + if &actual != expected { + return Err(VerifyError::GroupMismatch { + group: group_index as u64, + offset: start_chunk * CHUNK_LEN, + }); + } + Ok(()) + } + + pub fn size(&self) -> u64 { + self.size + } +} + +/// Compute the BLAKE3 chaining value (or root hash) of one chunk group. +fn leaf_cv(start_chunk: u64, data: &[u8], is_root: bool) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.set_input_offset(start_chunk * CHUNK_LEN); + hasher.update(data); + if is_root { + hasher.finalize() + } else { + Hash::from(hasher.finalize_non_root()) + } +} + +struct OutboardCursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> OutboardCursor<'a> { + fn read_pair(&mut self) -> Result<(Hash, Hash), VerifyError> { + let end = self.pos + 64; + if end > self.data.len() { + return Err(VerifyError::BadOutboard("outboard shorter than expected")); + } + let l: [u8; 32] = self.data[self.pos..self.pos + 32].try_into().unwrap(); + let r: [u8; 32] = self.data[self.pos + 32..end].try_into().unwrap(); + self.pos = end; + Ok((Hash::from(l), Hash::from(r))) + } +} + +/// The largest power-of-two number of chunk-groups strictly less than +/// `group_count`, i.e. the left-subtree split for a subtree of `group_count` +/// groups. Mirrors bao-tree's tree geometry at chunk-group granularity. +fn left_groups(group_count: u64) -> u64 { + debug_assert!(group_count > 1); + // Largest power of two < group_count. + let mut p = 1u64; + while p << 1 < group_count { + p <<= 1; + } + p +} + +/// Recursively reconstruct the expected leaf CVs from the outboard for the +/// subtree covering `[start_byte, start_byte+len)` and return the subtree hash. +fn build( + cursor: &mut OutboardCursor, + start_byte: u64, + len: u64, + is_root: bool, + leaves: &mut Vec, +) -> Result { + let start_chunk = start_byte / CHUNK_LEN; + let groups = len.div_ceil(GROUP_LEN as u64); + if groups <= 1 { + // Leaf group: its CV isn't stored in the outboard (only parents are). + // The caller will recompute it from data; here we record a placeholder + // slot and return the CV that the *parent* expects. But we can't know + // the leaf CV without the data. Instead we push the parent-provided CV. + // This branch is only reached for a single-group file (no parents at + // all); handle that by returning a sentinel the caller replaces. + unreachable!("build() is only entered when there is at least one parent"); + } + // There is at least one parent pair for this subtree. + let (l_cv, r_cv) = cursor.read_pair()?; + let left_len_groups = left_groups(groups); + let left_bytes = left_len_groups * GROUP_LEN as u64; + let right_start = start_byte + left_bytes; + let right_len = len - left_bytes; + + let l_actual = subtree(cursor, start_byte, left_bytes, l_cv, leaves)?; + let r_actual = subtree(cursor, right_start, right_len, r_cv, leaves)?; + if l_actual != l_cv || r_actual != r_cv { + return Err(VerifyError::BadOutboard("internal parent mismatch")); + } + let _ = start_chunk; + if is_root { + Ok(merge_subtrees_root( + &into_cv(l_cv), + &into_cv(r_cv), + Mode::Hash, + )) + } else { + Ok(Hash::from(merge_subtrees_non_root( + &into_cv(l_cv), + &into_cv(r_cv), + Mode::Hash, + ))) + } +} + +/// Handle a subtree that may be a single leaf group (CV supplied by the parent, +/// recorded for later data-checking) or a further parent subtree. +fn subtree( + cursor: &mut OutboardCursor, + start_byte: u64, + len: u64, + expected_cv: Hash, + leaves: &mut Vec, +) -> Result { + let groups = len.div_ceil(GROUP_LEN as u64); + if groups <= 1 { + // Leaf: the parent's CV for this position IS the group's expected leaf + // CV. Record it in file order; data verification happens during stream. + leaves.push(expected_cv); + Ok(expected_cv) + } else { + build(cursor, start_byte, len, /* is_root */ false, leaves) + } +} + +fn into_cv(h: Hash) -> blake3::hazmat::ChainingValue { + *h.as_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + use bao_tree::BlockSize; + use bao_tree::io::outboard::PreOrderOutboard; + use bao_tree::io::sync::CreateOutboard; + use std::io::Cursor; + + const BS: BlockSize = BlockSize::from_chunk_log(CHUNK_GROUP_LOG); + + fn make(size: usize) -> (Hash, u64, Vec, Vec) { + let mut data = vec![0u8; size]; + for (i, b) in data.iter_mut().enumerate() { + *b = (i % 251) as u8; + } + let ob = PreOrderOutboard::>::create(Cursor::new(&data), BS).unwrap(); + (ob.root, size as u64, ob.data, data) + } + + fn run_plan(root: Hash, size: u64, outboard: &[u8], data: &[u8]) -> Result<(), VerifyError> { + let plan = GroupPlan::new(root, size, outboard)?; + for (i, group) in data.chunks(GROUP_LEN).enumerate() { + plan.check_group(i, group)?; + } + Ok(()) + } + + #[test] + fn plan_root_matches_blake3() { + for &size in &[ + 0usize, + 1, + 1000, + GROUP_LEN, + GROUP_LEN + 1, + 5 * GROUP_LEN + 37, + 200_000, + ] { + let (root, sz, ob, data) = make(size); + assert_eq!(root, blake3::hash(&data), "size {size}"); + run_plan(root, sz, &ob, &data).unwrap_or_else(|e| panic!("size {size}: {e}")); + } + } + + #[test] + fn detects_single_byte_flip_in_each_group() { + let (root, size, ob, mut data) = make(5 * GROUP_LEN + 100); + let group_count = data.len().div_ceil(GROUP_LEN); + for g in 0..group_count { + let mut d = data.clone(); + let idx = g * GROUP_LEN + 3; + d[idx] ^= 0xFF; + let err = run_plan(root, size, &ob, &d).unwrap_err(); + match err { + VerifyError::GroupMismatch { group, offset } => { + assert_eq!(group as usize, g); + assert_eq!(offset, (g * GROUP_LEN) as u64); + } + other => panic!("group {g}: wrong error {other}"), + } + } + let _ = &mut data; + } + + #[test] + fn detects_tampered_outboard() { + let (root, size, mut ob, _data) = make(10 * GROUP_LEN); + ob[10] ^= 0xFF; + let err = GroupPlan::new(root, size, &ob).unwrap_err(); + assert!(matches!( + err, + VerifyError::OutboardRootMismatch | VerifyError::BadOutboard(_) + )); + } + + #[test] + fn detects_wrong_root() { + let (_root, size, ob, _data) = make(3 * GROUP_LEN); + let wrong = blake3::hash(b"not it"); + assert!(GroupPlan::new(wrong, size, &ob).is_err()); + } +} diff --git a/tests/tamper.rs b/tests/tamper.rs new file mode 100644 index 0000000..8832a3e --- /dev/null +++ b/tests/tamper.rs @@ -0,0 +1,181 @@ +//! End-to-end integration test driving the real `blacklight` binary over a +//! local HTTP server, exercising the clean path and several tampering attacks. +//! +//! No Sigstore here (that path needs interactive OIDC); these tests cover the +//! verified-streaming half — the part that must abort mid-transfer on the first +//! bad byte. Signing/verification of the manifest is covered by unit-level API +//! use plus the manual staging check documented in the README. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; + +const GROUP: usize = 16 * 1024; + +fn bin() -> PathBuf { + // Cargo sets CARGO_BIN_EXE_ for integration tests. + PathBuf::from(env!("CARGO_BIN_EXE_blacklight")) +} + +/// Minimal single-request-per-connection HTTP/1.1 file server rooted at `dir`. +/// If `tamper` is Some((path, offset)), it flips one byte of that path's body. +fn serve(dir: PathBuf, tamper: Option<(String, usize)>) -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let served = Arc::new(AtomicUsize::new(0)); + let served2 = served.clone(); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let dir = dir.clone(); + let tamper = tamper.clone(); + let counter = served2.clone(); + thread::spawn(move || handle(stream, &dir, tamper, counter)); + } + }); + (format!("http://{addr}"), served) +} + +fn handle( + mut stream: TcpStream, + dir: &Path, + tamper: Option<(String, usize)>, + counter: Arc, +) { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + if n == 0 { + return; + } + let req = String::from_utf8_lossy(&buf[..n]); + let path = req.split_whitespace().nth(1).unwrap_or("/").to_string(); + let rel = path.trim_start_matches('/'); + let full = dir.join(rel); + let mut body = match std::fs::read(&full) { + Ok(b) => b, + Err(_) => { + let _ = stream.write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + return; + } + }; + // Match guard rather than an `if let` chain, so this test compiles on the + // documented MSRV (let-chains stabilized later than edition 2024's floor). + match &tamper { + Some((tpath, off)) if &path == tpath && *off < body.len() => { + body[*off] ^= 0xFF; + } + _ => {} + } + counter.fetch_add(1, Ordering::SeqCst); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()); + let _ = stream.write_all(&body); + let _ = stream.flush(); +} + +fn publish(dir: &Path, name: &str, bytes: &[u8]) { + std::fs::write(dir.join(name), bytes).unwrap(); + let status = Command::new(bin()) + .arg("publish") + .arg(dir.join(name)) + .arg("--unsigned") + .status() + .unwrap(); + assert!(status.success(), "publish failed"); +} + +fn fetch(base: &str, name: &str, out: &Path) -> std::process::Output { + Command::new(bin()) + .arg("fetch") + .arg(format!("{base}/{name}.blacklight.json")) + .arg("--allow-unsigned") + .arg("-o") + .arg(out) + .output() + .unwrap() +} + +fn sample(size: usize) -> Vec { + (0..size).map(|i| ((i * 7 + 3) % 256) as u8).collect() +} + +#[test] +fn clean_download_succeeds_and_matches() { + let dir = tempdir(); + let data = sample(5 * GROUP + 123); + publish(&dir, "demo.bin", &data); + let (base, _) = serve(dir.clone(), None); + let out = dir.join("out.bin"); + let o = fetch(&base, "demo.bin", &out); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + assert_eq!(std::fs::read(&out).unwrap(), data); +} + +#[test] +fn tampered_artifact_aborts_with_no_output() { + let dir = tempdir(); + let data = sample(10 * GROUP + 7); + publish(&dir, "demo.bin", &data); + // Flip a byte inside group 3. + let off = 3 * GROUP + 500; + let (base, _) = serve(dir.clone(), Some(("/demo.bin".into(), off))); + let out = dir.join("out.bin"); + let o = fetch(&base, "demo.bin", &out); + assert_eq!(o.status.code(), Some(3), "expected integrity exit code 3"); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("chunk group 3"), "stderr: {stderr}"); + assert!(!out.exists(), "a partial output file was left behind"); +} + +#[test] +fn tampered_outboard_is_rejected_before_streaming() { + let dir = tempdir(); + let data = sample(8 * GROUP); + publish(&dir, "demo.bin", &data); + // Corrupt the outboard tree; it must no longer hash to the signed root. + let (base, _) = serve(dir.clone(), Some(("/demo.bin.obao".into(), 40))); + let out = dir.join("out.bin"); + let o = fetch(&base, "demo.bin", &out); + assert_eq!(o.status.code(), Some(3)); + assert!(!out.exists()); +} + +#[test] +fn truncated_stream_is_rejected() { + // The last group is short; if the server drops it entirely, length check fails. + let dir = tempdir(); + let data = sample(4 * GROUP); + publish(&dir, "demo.bin", &data); + // Overwrite hosted artifact with a truncated copy (server serves as-is). + std::fs::write(dir.join("demo.bin"), &data[..3 * GROUP]).unwrap(); + let (base, _) = serve(dir.clone(), None); + let out = dir.join("out.bin"); + let o = fetch(&base, "demo.bin", &out); + assert_eq!(o.status.code(), Some(3)); + assert!(!out.exists()); +} + +fn tempdir() -> PathBuf { + let base = std::env::temp_dir().join(format!( + "blacklight-it-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&base).unwrap(); + base +} + +static NEXT: AtomicUsize = AtomicUsize::new(0);