diff --git a/Cargo.lock b/Cargo.lock index d6de440ec9d4..62ef313dabd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,12 +53,24 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[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 = "bcrypt" version = "0.19.0" @@ -67,7 +79,7 @@ checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6" dependencies = [ "base64", "blowfish", - "getrandom", + "getrandom 0.4.2", "subtle", "zeroize", ] @@ -1639,6 +1651,7 @@ dependencies = [ "bun_semver_jsc", "bun_sha_hmac", "bun_shell_parser", + "bun_sigstore", "bun_simdutf_sys", "bun_sourcemap", "bun_sourcemap_jsc", @@ -1663,7 +1676,7 @@ dependencies = [ "const_format", "enum-map", "enumset", - "getrandom", + "getrandom 0.4.2", "libc", "lol_html", "rust-argon2", @@ -1772,6 +1785,23 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "bun_sigstore" +version = "0.0.0" +dependencies = [ + "bun_base64", + "bun_core", + "bun_http", + "bun_sha_hmac", + "bun_url", + "p256", + "pkcs8", + "rand_core", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "bun_simdutf_sys" version = "0.0.0" @@ -2327,6 +2357,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const_format" version = "0.2.36" @@ -2430,6 +2466,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2497,6 +2545,17 @@ dependencies = [ "syn", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -2525,6 +2584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -2544,12 +2604,46 @@ dependencies = [ "dtoa", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2622,6 +2716,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2672,6 +2776,18 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -2687,6 +2803,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -2737,6 +2864,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -2912,12 +3048,33 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "phf" version = "0.13.1" @@ -2977,6 +3134,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "plotters" version = "0.3.7" @@ -3021,6 +3188,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3054,6 +3230,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rayon" version = "1.12.0" @@ -3103,6 +3288,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3170,6 +3365,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.33.0" @@ -3247,6 +3456,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha3" version = "0.10.9" @@ -3257,6 +3477,16 @@ dependencies = [ "keccak", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3275,6 +3505,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[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" @@ -3390,6 +3630,12 @@ dependencies = [ "winapi-util", ] +[[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.3+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 95bcaaa93513..fecbe9dbe7bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "src/resolver", "src/safety", "src/semver", + "src/sigstore", "src/sourcemap", "src/sql", "src/sys", @@ -386,6 +387,7 @@ bun_paths = { path = "src/paths" } bun_resolver = { path = "src/resolver" } bun_safety = { path = "src/safety" } bun_semver = { path = "src/semver" } +bun_sigstore = { path = "src/sigstore" } bun_sourcemap = { path = "src/sourcemap" } bun_sql = { path = "src/sql" } bun_sys = { path = "src/sys" } diff --git a/docs/pm/cli/publish.mdx b/docs/pm/cli/publish.mdx index 8318465382e1..998618d146f8 100644 --- a/docs/pm/cli/publish.mdx +++ b/docs/pm/cli/publish.mdx @@ -121,6 +121,33 @@ Provide a one-time password directly to the CLI. If the password is valid, `bun bun publish --otp 123456 ``` +### `--provenance` + +Generate a signed [npm provenance](https://docs.npmjs.com/generating-provenance-statements) statement via Sigstore keyless signing and attach it to the published package. Requires `--access public` and a supported CI environment: + +- **GitHub Actions** — the workflow must grant `permissions: id-token: write`. +- **GitLab CI** — the job must define `SIGSTORE_ID_TOKEN` under [`id_tokens`](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html) with audience `sigstore`. + +Provenance is also enabled implicitly when `NPM_CONFIG_PROVENANCE=true` (which `actions/setup-node` sets when configured with `provenance: true`). Use `--no-provenance` to override. + +```sh terminal icon="terminal" +bun publish --provenance --access public +``` + +`--provenance` can also be set in the `publishConfig` field of your `package.json`: + +```json package.json icon="file-json" +{ + "publishConfig": { + "provenance": true + } +} +``` + +To attach an externally-generated Sigstore bundle instead of generating one, use `--provenance-file `. The bundle's subject must match the package name and tarball digest. `--provenance` and `--provenance-file` are mutually exclusive. + +For private Sigstore deployments, the Fulcio/Rekor endpoints can be overridden via `BUN_SIGSTORE_FULCIO_URL`, `BUN_SIGSTORE_REKOR_URL`, `BUN_SIGSTORE_TLOG_BASE_URL`, and `BUN_SIGSTORE_OIDC_AUDIENCE`. + `bun publish` respects the `NPM_CONFIG_TOKEN` environment variable, useful when publishing from GitHub Actions or other automated workflows. diff --git a/docs/snippets/cli/publish.mdx b/docs/snippets/cli/publish.mdx index 478c7fe30c8a..89c4fa5c30d4 100644 --- a/docs/snippets/cli/publish.mdx +++ b/docs/snippets/cli/publish.mdx @@ -87,6 +87,32 @@ bun publish --otp 123456 + + +Generate a signed npm provenance statement via Sigstore keyless signing. Requires `--access public` and running inside GitHub Actions (with `id-token: write` permission) or GitLab CI (with `SIGSTORE_ID_TOKEN`). Also enabled by `NPM_CONFIG_PROVENANCE=true`; override with `--no-provenance`. + +```sh terminal icon="terminal" +bun publish --provenance --access public +``` + +`--provenance` can also be set in the `publishConfig` field of your `package.json`. + +```json package.json icon="file-json" +{ + "publishConfig": { + "provenance": true // [!code ++] + } +} +``` + + + + + +Path to an externally-generated Sigstore provenance bundle to attach instead of generating one. The bundle's subject must match the package name and tarball digest. Mutually exclusive with `--provenance`. + + + ### Registry Configuration #### Custom Registry diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index f56dbb4665aa..fcd63e142eb5 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -824,7 +824,7 @@ deflate_decompress_bmi2 [BMI2] # memchr::arch::x86_64::memchr::*_raw::detect / avx2::packedpair::Finder::new. # PDB DBI records carry demangled Rust names for the directly-linked crate; # the older v0-mangled detect entries are kept for toolchain drift. -# (14 symbols) +# (15 symbols) # ---------------------------------------------------------------------------- memchr::arch::x86_64::avx2::memchr::One::find_raw_avx2 [AVX, AVX2] memchr::arch::x86_64::avx2::memchr::One::rfind_raw_avx2 [AVX, AVX2] @@ -837,6 +837,7 @@ memchr::arch::x86_64::memchr::memchr_raw::find_avx2 [AVX, AVX2] memchr::arch::x86_64::memchr::memchr2_raw::find_avx2 [AVX, AVX2] memchr::arch::x86_64::memchr::memchr3_raw::find_avx2 [AVX, AVX2] memchr::arch::x86_64::memchr::memrchr_raw::find_avx2 [AVX, AVX2] +memchr::arch::x86_64::memchr::count_raw::find_avx2 [AVX, AVX2] _RNvNvNtNtNt6memchr4arch6x86_646memchr10memchr_raw6detect [AVX, AVX2] _RNvNvNtNtNt6memchr4arch6x86_646memchr11memchr2_raw6detect [AVX, AVX2] _RNvNvNtNtNt6memchr4arch6x86_646memchr11memchr3_raw6detect [AVX, AVX2] @@ -850,6 +851,15 @@ _RNvNvNtNtNt6memchr4arch6x86_646memchr11memchr3_raw6detect [AVX, AVX blake2b_simd::avx2::compress1_loop [AVX, AVX2] +# ---------------------------------------------------------------------------- +# Rust sha2 SHA-NI (via p256 → bun_sigstore, publish --provenance). Gate: +# cpufeatures::new!(shani_cpuid, "sha", "sse2", "ssse3", "sse4.1") in +# sha2::sha256::x86::compress — falls back to soft::compress. +# (1 symbol) +# ---------------------------------------------------------------------------- +sha2::sha256::x86::digest_blocks [SHA] + + # ---------------------------------------------------------------------------- # MSVC STL vectorized algorithms. Gate: __isa_available. # (14 symbols) diff --git a/scripts/verify-baseline-static/allowlist-x64.txt b/scripts/verify-baseline-static/allowlist-x64.txt index 1c95b2ace975..4beb3dea6b07 100644 --- a/scripts/verify-baseline-static/allowlist-x64.txt +++ b/scripts/verify-baseline-static/allowlist-x64.txt @@ -950,7 +950,7 @@ _RNvNtNtNtNtNt17compiler_builtins4math9libm_math4arch3x863fma13fma_wi # ---------------------------------------------------------------------------- # Rust memchr AVX2 (via lolhtml deps). Gate: is_x86_feature_detected!. -# (11 symbols) +# (12 symbols) # ---------------------------------------------------------------------------- _RNvMNtNtNtNt6memchr4arch6x86_644avx26memchrNtB2_3One13find_raw_avx2 [AVX, AVX2] _RNvMNtNtNtNt6memchr4arch6x86_644avx26memchrNtB2_3One14rfind_raw_avx2 [AVX, AVX2] @@ -960,6 +960,7 @@ _RNvNvNtNtNt6memchr4arch6x86_646memchr10memchr_raw9find_avx2 _RNvNvNtNtNt6memchr4arch6x86_646memchr11memchr2_raw9find_avx2 [AVX, AVX2] _RNvNvNtNtNt6memchr4arch6x86_646memchr11memchr3_raw9find_avx2 [AVX, AVX2] _RNvNvNtNtNt6memchr4arch6x86_646memchr11memrchr_raw9find_avx2 [AVX, AVX2] +_RNvNvNtNtNt6memchr4arch6x86_646memchr9count_raw9find_avx2 [AVX, AVX2] # packedpair::Finder — only constructed when Finder::with_pair() sees avx2 in # is_x86_feature_detected!(); the *_impl fns are #[target_feature(enable="avx2")] # and only reached via that constructed Finder. @@ -976,6 +977,15 @@ _RNvMNtNtNtNt6memchr4arch6x86_644avx210packedpairNtB2_6Finder19find_p _RNvNt12blake2b_simd4avx214compress1_loop [AVX, AVX2] +# ---------------------------------------------------------------------------- +# Rust sha2 SHA-NI (via p256 → bun_sigstore, publish --provenance). Gate: +# cpufeatures::new!(shani_cpuid, "sha", "sse2", "ssse3", "sse4.1") in +# sha2::sha256::x86::compress — falls back to soft::compress. +# (1 symbol) +# ---------------------------------------------------------------------------- +_RNvNtNt4sha26sha2563x8613digest_blocks [SHA] + + # ---------------------------------------------------------------------------- # Bun.Image highway kernels (image_resize.cpp). Gate: HWY_DYNAMIC_DISPATCH -> # hwy::SupportedTargets() via CPUID. Same gate as the highway_strings block. diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 84b223a973fb..5fc46f318baa 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -327,6 +327,15 @@ static PUBLISH_PARAMS: &[ParamType] = concat_params![ clap::param!( "--tolerate-republish Don't exit with code 1 when republishing over an existing version number" ), + clap::param!( + "--provenance Generate a signed provenance statement (GitHub Actions / GitLab CI only)" + ), + clap::param!( + "--no-provenance Do not generate a provenance statement" + ), + clap::param!( + "--provenance-file Path to an externally-generated Sigstore provenance bundle to attach" + ), ] ]; @@ -1175,6 +1184,23 @@ Full documentation is available at https://bun.com/docs/cli/why. } cli.tolerate_republish = args.flag(b"--tolerate-republish"); + + // `bun_clap::flag()` has no argument positions, so npm's last-flag-wins is not reproducible; both flags together is an error. + match (args.flag(b"--provenance"), args.flag(b"--no-provenance")) { + (true, true) => { + Output::err_generic( + "--provenance and --no-provenance cannot be used together", + (), + ); + Global::crash(); + } + (true, false) => cli.publish_config.provenance = Some(true), + (false, true) => cli.publish_config.provenance = Some(false), + (false, false) => {} + } + if let Some(path) = args.option(b"--provenance-file") { + cli.publish_config.provenance_file = path; + } } // link and unlink default to not saving, all others default to diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 800be1444fb2..4f578aca70be 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -175,6 +175,10 @@ pub struct PublishConfig { pub otp: &'static [u8], pub auth_type: Option, pub tolerate_republish: bool, + /// `None` = neither `--provenance` flag nor `publishConfig.provenance` set; `NPM_CONFIG_PROVENANCE` is consulted at publish time. + pub provenance: Option, + /// `--provenance-file `: attach this pre-built bundle instead of generating one. + pub provenance_file: &'static [u8], } #[derive(Copy, Clone, PartialEq, Eq)] @@ -862,6 +866,12 @@ impl Options { self.publish_config.auth_type = Some(auth_type); } self.publish_config.tolerate_republish = cli.tolerate_republish; + if let Some(prov) = cli.publish_config.provenance { + self.publish_config.provenance = Some(prov); + } + if !cli.publish_config.provenance_file.is_empty() { + self.publish_config.provenance_file = cli.publish_config.provenance_file; + } if !cli.ca.is_empty() { self.ca = cli.ca.iter().map(|s| Box::<[u8]>::from(*s)).collect(); diff --git a/src/runtime/Cargo.toml b/src/runtime/Cargo.toml index d1f50e8ce455..1fb9de87e772 100644 --- a/src/runtime/Cargo.toml +++ b/src/runtime/Cargo.toml @@ -82,6 +82,7 @@ bun_safety.workspace = true bun_semver.workspace = true bun_semver_jsc.workspace = true bun_sha_hmac.workspace = true +bun_sigstore.workspace = true bun_shell_parser.workspace = true bun_simdutf_sys.workspace = true bun_sourcemap.workspace = true diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 749980616d6b..7e80eb327ce9 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -1952,6 +1952,11 @@ pub(crate) fn pack( }; } } + if ctx.manager.options.publish_config.provenance.is_none() { + if let Some(prov) = config.get(b"provenance").and_then(|e| e.as_bool()) { + ctx.manager.options.publish_config.provenance = Some(prov); + } + } } // maybe otp diff --git a/src/runtime/cli/publish_command.rs b/src/runtime/cli/publish_command.rs index ac6e1736eb84..d979c5f4d051 100644 --- a/src/runtime/cli/publish_command.rs +++ b/src/runtime/cli/publish_command.rs @@ -88,6 +88,12 @@ type SHA512Digest = [u8; sha::SHA512::DIGEST]; pub(crate) struct PublishCommand; +/// Bundle for the PUT body's `_attachments`, whether freshly generated or loaded from `--provenance-file`. +struct ProvenanceAttachment { + media_type: String, + bundle_json: Vec, +} + // Const generics cannot vary field types; the script fields and script_env are // kept as Option<> in both instantiations and we rely on // invariants (always None / never used when DIRECTORY_PUBLISH == false). @@ -372,6 +378,12 @@ impl<'a, const DIRECTORY_PUBLISH: bool> Context<'a, DIRECTORY_PUBLISH> { } } + if manager.options.publish_config.provenance.is_none() { + if let Some(prov) = config.get(b"provenance").and_then(|e| e.as_bool()) { + manager.options.publish_config.provenance = Some(prov); + } + } + // maybe otp } @@ -861,6 +873,130 @@ impl PublishCommand { false } + /// Precedence: `--provenance-file`, then the CLI flag / `publishConfig.provenance` (merged into one field), then `NPM_CONFIG_PROVENANCE` (set by `actions/setup-node`). + fn provenance_requested( + ctx: &Context<'_, DIRECTORY_PUBLISH>, + ) -> bool { + let cfg = &ctx.manager.options.publish_config; + if !cfg.provenance_file.is_empty() { + return true; + } + if let Some(p) = cfg.provenance { + return p; + } + // Both spellings, as with NPM_CONFIG_REGISTRY in `PackageManagerOptions::load`: npm sets the lowercase form in lifecycle scripts. + let env = bun_core::getenv_z(bun_core::zstr!("NPM_CONFIG_PROVENANCE")) + .or_else(|| bun_core::getenv_z(bun_core::zstr!("npm_config_provenance"))); + match env { + Some(v) => strings::eql_case_insensitive_ascii(v, b"true", true) || v == b"1", + None => false, + } + } + + /// Usage and network errors crash rather than publishing unattested, matching npm's `EUSAGE`. + fn maybe_generate_provenance( + ctx: &Context<'_, DIRECTORY_PUBLISH>, + ) -> Option { + if !Self::provenance_requested(ctx) { + return None; + } + let cfg = &ctx.manager.options.publish_config; + + // `Some(false)` + a file is valid (publishConfig may say false), hence `== Some(true)` rather than `is_some()`. + if cfg.provenance == Some(true) && !cfg.provenance_file.is_empty() { + Output::err_generic( + "--provenance and --provenance-file are mutually exclusive", + (), + ); + Global::crash(); + } + + // npm queries the registry's visibility endpoint for this; requiring an explicit `--access public` gives the same outcome offline. + if cfg.access != Some(Access::Public) && cfg.provenance_file.is_empty() { + Output::err_generic( + "provenance requires --access public (provenance cannot be \ + generated for private packages)", + (), + ); + Global::crash(); + } + + // Tarball's SHA-512 as lowercase hex — the in-toto subject digest. + let mut integrity: SHA512Digest = [0u8; sha::SHA512::DIGEST]; + let mut sha512 = sha::SHA512::init(); + sha512.update(&ctx.tarball_bytes); + sha512.r#final(&mut integrity); + let sha512_hex = bun_fmt::hex_lower(&integrity).to_string(); + // Raw manifest version including any `+build` suffix, as in npm's purl and the `.sigstore` attachment key. + let subject = + bun_sigstore::provenance::subject(&ctx.package_name, &ctx.package_version, &sha512_hex); + + // `--provenance-file`: verify subject matches, skip generation. + if !cfg.provenance_file.is_empty() { + let bytes = match File::read_from(Fd::cwd(), cfg.provenance_file) { + Ok(b) => b, + Err(e) => { + Output::err( + e, + "Invalid provenance provided: failed to read {}", + (bstr::BStr::new(cfg.provenance_file),), + ); + Global::crash(); + } + }; + return match bun_sigstore::verify_bundle(bytes, &subject) { + Ok(att) => { + bun_core::prettyln!( + " Attached provenance bundle from {}", + bstr::BStr::new(cfg.provenance_file), + ); + Some(ProvenanceAttachment { + media_type: att.media_type, + bundle_json: att.bundle_json, + }) + } + Err(e) => { + e.print(); + Global::crash(); + } + }; + } + + // Preflight: supported CI + OIDC token available. + let provider = match bun_sigstore::ensure_provenance_generation() { + Ok(p) => p, + Err(e) => { + e.print(); + Global::crash(); + } + }; + + let payload = bun_sigstore::provenance::generate(provider, &subject); + let endpoints = bun_sigstore::Endpoints::from_env(); + + let att = match bun_sigstore::attest(&payload, &endpoints) { + Ok(a) => a, + Err(e) => { + e.print(); + Global::crash(); + } + }; + + bun_core::prettyln!( + " Signed provenance statement with source and build information from \ + {}", + provider.display_name(), + ); + if let Some(url) = &att.transparency_log_url { + bun_core::prettyln!(" Transparency log: {}", url); + } + + Some(ProvenanceAttachment { + media_type: att.media_type.to_owned(), + bundle_json: att.bundle_json, + }) + } + fn publish( ctx: &Context<'_, DIRECTORY_PUBLISH>, ) -> Result<(), PublishError> { @@ -913,12 +1049,15 @@ impl PublishCommand { return Ok(()); } + // Before the body is built, so the bundle can go into `_attachments`. + let provenance = Self::maybe_generate_provenance(ctx); + // Note: `AsyncHTTP::init_sync` requires `&'static [u8]` for the // request body. Single-shot CLI path — adopt the // already-owned `Box<[u8]>` (base64-encoded tarball; can be multi-MB) // into the process-lifetime side-table. Zero-copy. let publish_req_body: &'static [u8] = crate::cli::cli_adopt( - Self::construct_publish_request_body::(ctx)?, + Self::construct_publish_request_body::(ctx, provenance.as_ref())?, ); let mut print_buf: Vec = Vec::new(); @@ -2014,6 +2153,7 @@ impl PublishCommand { fn construct_publish_request_body( ctx: &Context<'_, DIRECTORY_PUBLISH>, + provenance: Option<&ProvenanceAttachment>, ) -> Result, AllocError> { let tag: &[u8] = if !ctx.manager.options.publish_config.tag.is_empty() { ctx.manager.options.publish_config.tag @@ -2087,7 +2227,29 @@ impl PublishCommand { }; debug_assert!(count == encoded_tarball_len); - let _ = write!(&mut buf, "\",\"length\":{}}}}}}}", ctx.tarball_bytes.len()); + let _ = write!(&mut buf, "\",\"length\":{}}}", ctx.tarball_bytes.len()); + + // libnpmpublish attaches the bundle JSON as a plain string (not base64) under `{name}-{version}.sigstore`. + if let Some(prov) = provenance { + // `length` is npm's `JSON.stringify(bundle).length`, i.e. UTF-16 units; Rekor checkpoints contain U+2014, so the body must be encoded as UTF-8. + let data_len = std::str::from_utf8(&prov.bundle_json) + .map(|s| s.encode_utf16().count()) + .unwrap_or(prov.bundle_json.len()); + let _ = write!( + &mut buf, + ",\"{}-{}.sigstore\":{{\"content_type\":{},\"data\":{},\"length\":{}}}", + bstr::BStr::new(&ctx.package_name), + bstr::BStr::new(&ctx.package_version), + bun_fmt::format_json_string_utf8( + prov.media_type.as_bytes(), + Default::default() + ), + bun_fmt::format_json_string_utf8(&prov.bundle_json, Default::default()), + data_len, + ); + } + + buf.extend_from_slice(b"}}"); } Ok(buf.into_boxed_slice()) diff --git a/src/runtime/dns_jsc/options_jsc.rs b/src/runtime/dns_jsc/options_jsc.rs index 3be03b0fa89a..d108251b63b2 100644 --- a/src/runtime/dns_jsc/options_jsc.rs +++ b/src/runtime/dns_jsc/options_jsc.rs @@ -223,8 +223,9 @@ pub(crate) fn result_to_js(this: &GaiResult, global: &JSGlobalObject) -> JsResul b"family", // `bun_sys::net::Address` exposes `.family() -> i32`. match this.address.family() { - f if f == super::netc::AF_INET as _ => JSValue::js_number(4.0), - f if f == super::netc::AF_INET6 as _ => JSValue::js_number(6.0), + // `as _` is ambiguous here once serde_json (`impl PartialEq for i32`) is linked. + f if f == super::netc::AF_INET as i32 => JSValue::js_number(4.0), + f if f == super::netc::AF_INET6 as i32 => JSValue::js_number(6.0), _ => JSValue::js_number(0.0), }, ); diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 4919642333e9..de4006669dbb 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -1476,7 +1476,8 @@ mod _async_tasks { 'brk: { match result { Err(ref err) => { - if err.errno == E::EEXIST as _ && !args.flags.error_on_exist { + // `as _` is ambiguous here once serde_json (`impl PartialEq for u16`) is linked. + if err.errno == E::EEXIST as u16 && !args.flags.error_on_exist { break 'brk; } parent.finish_concurrently(result); @@ -1845,7 +1846,7 @@ mod _async_tasks { &this.args, ); if let Err(e) = &r { - if e.errno == E::EEXIST as _ && !args.flags.error_on_exist { + if e.errno == E::EEXIST as u16 && !args.flags.error_on_exist { this.finish_concurrently(Ok(())); return; } @@ -1884,7 +1885,7 @@ mod _async_tasks { &this.args, ); if let Err(e) = &r { - if e.errno == E::EEXIST as _ && !args.flags.error_on_exist { + if e.errno == E::EEXIST as u16 && !args.flags.error_on_exist { this.on_copy(src, dest); this.finish_concurrently(Ok(())); return; @@ -8286,7 +8287,7 @@ impl NodeFS { args, ); if let Err(ref e) = r { - if e.errno == E::EEXIST as _ && !cp_flags.error_on_exist { + if e.errno == E::EEXIST as u16 && !cp_flags.error_on_exist { return Ok(()); } } @@ -8315,7 +8316,7 @@ impl NodeFS { args, ); if let Err(ref e) = r { - if e.errno == E::EEXIST as _ && !cp_flags.error_on_exist { + if e.errno == E::EEXIST as u16 && !cp_flags.error_on_exist { return Ok(()); } } @@ -8443,7 +8444,7 @@ impl NodeFS { args, ); if let Err(ref e) = r { - if e.errno == E::EEXIST as _ && !cp_flags.error_on_exist { + if e.errno == E::EEXIST as u16 && !cp_flags.error_on_exist { continue; } return r; diff --git a/src/runtime/node/win_watcher.rs b/src/runtime/node/win_watcher.rs index c97a3e5e0a65..44b479e50dd7 100644 --- a/src/runtime/node/win_watcher.rs +++ b/src/runtime/node/win_watcher.rs @@ -346,7 +346,7 @@ impl PathWatcher { let readlink_result = sys::readlink(path, &mut outbuf); let event_path: &ZStr = match readlink_result { sys::Result::Err(err) => 'brk: { - if err.errno == sys::E::NOENT as _ { + if err.errno == sys::E::NOENT as u16 { return sys::Result::Err(sys::Error { errno: err.errno, syscall: sys::Tag::open, diff --git a/src/sigstore/Cargo.toml b/src/sigstore/Cargo.toml new file mode 100644 index 000000000000..0a93aa70cf9b --- /dev/null +++ b/src/sigstore/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bun_sigstore" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[lib] +path = "lib.rs" + +[dependencies] +bun_core.workspace = true +bun_http.workspace = true +bun_url.workspace = true +bun_base64.workspace = true +bun_sha_hmac.workspace = true +thiserror.workspace = true +# JSON body/bundle (de)serialization — already transitive via lol_html. +# NOTE: serde_json defines `impl PartialEq for i32` (and all other +# integer types), which makes `x == Y as _` patterns elsewhere in bun_runtime +# fail inference (E0282). Those call sites have been fixed to use explicit +# `as u16` / `as i32`. +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# RustCrypto primitives for Sigstore keyless signing — the same crates +# `sigstore-rs` uses under the hood. We depend on them directly rather than +# on the `sigstore` crate because: +# - sigstore-rs only supports `hashedrekord` (plain signing), not DSSE +# attestation — which is what npm provenance requires; +# - its `IdentityToken` hard-requires an `email` claim that GitHub Actions +# OIDC tokens do not carry; +# - its `sign`/`fulcio`/`rekor` features pull reqwest + tokio + aws-lc-rs, +# duplicating bun's own HTTP (`bun_http`) and TLS (BoringSSL) stacks. +# The protocol itself is ~3 HTTP calls + one ECDSA signature, so we speak it +# directly and stay on bun's I/O path. +p256 = { version = "0.13", default-features = false, features = ["ecdsa", "pem", "std"] } +pkcs8 = { version = "0.10", default-features = false, features = ["pem", "std"] } +rand_core = { version = "0.6.4", default-features = false, features = ["getrandom"] } diff --git a/src/sigstore/lib.rs b/src/sigstore/lib.rs new file mode 100644 index 000000000000..f5ec9e985bf3 --- /dev/null +++ b/src/sigstore/lib.rs @@ -0,0 +1,939 @@ +//! npm provenance via Sigstore keyless signing (OIDC token -> ephemeral P-256 -> Fulcio cert -> DSSE -> Rekor -> bundle v0.2), ported from sigstore-js; `Cargo.toml` explains why not the `sigstore` crate. + +use std::io::Write as _; + +use bun_core::{MutableString, Output, ZStr, strings}; +use bun_http as http; +use bun_http::HeaderBuilder; +use bun_url::URL; + +use p256::ecdsa::{Signature, SigningKey, signature::Signer as _}; +use p256::pkcs8::EncodePublicKey as _; + +use serde::{Deserialize, Serialize}; + +pub mod provenance; + +// ── Public entry point ── + +/// Serialized bundle plus what the publish path needs to attach and report it. +pub struct Attestation { + /// `application/vnd.dev.sigstore.bundle+json;version=0.2` + pub media_type: &'static str, + /// Serialized bundle JSON. + pub bundle_json: Vec, + /// `https://search.sigstore.dev/?logIndex=N` — for the user-facing notice. + pub transparency_log_url: Option, +} + +/// Public-good Sigstore defaults (as libnpmpublish); `BUN_SIGSTORE_*` env vars override them for tests and private deployments. +pub struct Endpoints { + pub fulcio_url: String, + pub rekor_url: String, + pub tlog_search_url: String, + pub oidc_audience: String, +} + +impl Endpoints { + pub fn from_env() -> Self { + let env = |key: &ZStr, default: &str| -> String { + match bun_core::getenv_z(key) { + Some(v) if !v.is_empty() => utf8_lossy(v).into_owned(), + _ => default.to_owned(), + } + }; + Self { + fulcio_url: env( + bun_core::zstr!("BUN_SIGSTORE_FULCIO_URL"), + DEFAULT_FULCIO_URL, + ), + rekor_url: env(bun_core::zstr!("BUN_SIGSTORE_REKOR_URL"), DEFAULT_REKOR_URL), + tlog_search_url: env( + bun_core::zstr!("BUN_SIGSTORE_TLOG_BASE_URL"), + DEFAULT_TLOG_BASE_URL, + ), + oidc_audience: env(bun_core::zstr!("BUN_SIGSTORE_OIDC_AUDIENCE"), "sigstore"), + } + } +} + +pub const DEFAULT_FULCIO_URL: &str = "https://fulcio.sigstore.dev"; +pub const DEFAULT_REKOR_URL: &str = "https://rekor.sigstore.dev"; +pub const DEFAULT_TLOG_BASE_URL: &str = "https://search.sigstore.dev/"; + +/// in-toto DSSE payload type — what npm passes to `sigstore.attest()`. +pub const INTOTO_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json"; + +/// v0.2 wire format (`x509CertificateChain` verification material); npm accepts v0.2+. +const BUNDLE_V02_MEDIA_TYPE: &str = "application/vnd.dev.sigstore.bundle+json;version=0.2"; + +/// Sign `payload` (a JSON in-toto statement, embedded byte-for-byte in the DSSE envelope) into a Sigstore bundle. +pub fn attest(payload: &[u8], endpoints: &Endpoints) -> Result { + // 1) OIDC identity token from CI. + let id_token = fetch_identity_token(&endpoints.oidc_audience)?; + let subject = extract_jwt_subject(&id_token)?; + + // 2) Ephemeral P-256 keypair + proof-of-possession (sign the subject). + let signing_key = SigningKey::random(&mut rand_core::OsRng); + let public_key_pem = signing_key + .verifying_key() + .to_public_key_pem(p256::pkcs8::LineEnding::LF) + .map_err(|e| SigstoreError::Crypto(e.to_string()))?; + let proof: Signature = signing_key.sign(subject.as_bytes()); + let proof_der = proof.to_der(); + + // 3) Fulcio: exchange (token, pubkey, proof) → short-lived x509 cert chain. + let cert_chain = fulcio_request_cert( + &endpoints.fulcio_url, + &id_token, + &public_key_pem, + proof_der.as_bytes(), + )?; + let leaf_cert_pem = cert_chain + .first() + .ok_or_else(|| SigstoreError::Fulcio("empty certificate chain".into()))?; + let leaf_cert_der = pem_to_der(leaf_cert_pem)?; + + // 4) DSSE PAE-encode the in-toto payload, sign, build envelope. + let pae = dsse_pre_auth_encoding(INTOTO_PAYLOAD_TYPE, payload); + let dsse_sig: Signature = signing_key.sign(&pae); + let dsse_sig_der = dsse_sig.to_der(); + + let payload_b64 = b64_std(payload); + let sig_b64 = b64_std(dsse_sig_der.as_bytes()); + + // 5) Rekor: upload an intoto entry for the envelope. + let cert_pem_b64 = b64_std(leaf_cert_pem.as_bytes()); + let tlog_entry = rekor_create_intoto_entry( + &endpoints.rekor_url, + INTOTO_PAYLOAD_TYPE, + payload, + &payload_b64, + &sig_b64, + leaf_cert_pem, + &cert_pem_b64, + )?; + + // 6) Assemble the serialized Sigstore bundle. + let bundle = SerializedBundle { + media_type: BUNDLE_V02_MEDIA_TYPE, + verification_material: SerializedVerificationMaterial { + x509_certificate_chain: SerializedX509Chain { + certificates: vec![SerializedX509Cert { + raw_bytes: b64_std(&leaf_cert_der), + }], + }, + tlog_entries: vec![tlog_entry.clone()], + timestamp_verification_data: SerializedTimestampVerificationData { + rfc3161_timestamps: vec![], + }, + }, + dsse_envelope: SerializedEnvelope { + payload: payload_b64, + payload_type: INTOTO_PAYLOAD_TYPE, + signatures: vec![SerializedEnvelopeSignature { + sig: sig_b64, + keyid: "", + }], + }, + }; + + let bundle_json = + serde_json::to_vec(&bundle).map_err(|e| SigstoreError::Bundle(e.to_string()))?; + + let transparency_log_url = Some(format!( + "{}?logIndex={}", + endpoints.tlog_search_url.trim_end_matches('/').to_owned() + "/", + tlog_entry.log_index, + )); + + Ok(Attestation { + media_type: BUNDLE_V02_MEDIA_TYPE, + bundle_json, + transparency_log_url, + }) +} + +/// A pre-built bundle accepted by [`verify_bundle`]. +pub struct LoadedBundle { + pub media_type: String, + pub bundle_json: Vec, +} + +/// `--provenance-file`: libnpmpublish `verifyProvenance` minus `sigstore.verify()` (the registry re-verifies); checks only that the subject name and sha512 match the package. +pub fn verify_bundle( + bytes: Vec, + expected_subject: &serde_json::Value, +) -> Result { + let bundle: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| SigstoreError::Usage(format!("Invalid provenance provided: {e}")))?; + + let payload_b64 = bundle + .pointer("/dsseEnvelope/payload") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + SigstoreError::Usage("No dsseEnvelope with payload found in sigstore bundle".into()) + })?; + let payload = bun_base64::decode_alloc(payload_b64.as_bytes()).map_err(|_| { + SigstoreError::Usage("Failed to parse payload from dsseEnvelope: bad base64".into()) + })?; + let stmt: serde_json::Value = serde_json::from_slice(&payload).map_err(|e| { + SigstoreError::Usage(format!("Failed to parse payload from dsseEnvelope: {e}")) + })?; + + let subjects = stmt + .get("subject") + .and_then(|v| v.as_array()) + .filter(|a| !a.is_empty()) + .ok_or_else(|| { + SigstoreError::Usage("No subject found in sigstore bundle payload".into()) + })?; + if subjects.len() > 1 { + return Err(SigstoreError::Usage( + "Found more than one subject in the sigstore bundle payload".into(), + )); + } + let got = &subjects[0]; + let want = &expected_subject[0]; + + let got_name = got.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let want_name = want.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if got_name != want_name { + return Err(SigstoreError::Usage(format!( + "Provenance subject {got_name} does not match the package: {want_name}" + ))); + } + let got_digest = got.pointer("/digest/sha512").and_then(|v| v.as_str()); + let want_digest = want.pointer("/digest/sha512").and_then(|v| v.as_str()); + if got_digest != want_digest { + return Err(SigstoreError::Usage( + "Provenance subject digest does not match the package".into(), + )); + } + + let media_type = bundle + .get("mediaType") + .and_then(|v| v.as_str()) + .unwrap_or(BUNDLE_V02_MEDIA_TYPE) + .to_owned(); + + Ok(LoadedBundle { + media_type, + bundle_json: bytes, + }) +} + +// ── OIDC identity — sigstore-js `CIContextProvider` ── + +fn env(key: &ZStr) -> Option<&'static [u8]> { + bun_core::getenv_z(key).filter(|v| !v.is_empty()) +} + +/// Lossy on purpose: npm reads these same values through Node's `process.env`, which is itself a lossy decode. +#[allow(clippy::disallowed_methods)] // lossy decode documented above +pub(crate) fn utf8_lossy(bytes: &[u8]) -> std::borrow::Cow<'_, str> { + String::from_utf8_lossy(bytes) +} + +/// Selects the SLSA predicate shape and the preflight error wording. +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum CiProvider { + GithubActions, + GitlabCi, +} + +impl CiProvider { + /// Same env checks as the `ci-info` lookups in `libnpmpublish/lib/provenance.js`. + pub fn detect() -> Option { + if env(bun_core::zstr!("GITHUB_ACTIONS")).is_some() { + Some(Self::GithubActions) + } else if env(bun_core::zstr!("GITLAB_CI")).is_some() { + Some(Self::GitlabCi) + } else { + None + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::GithubActions => "GitHub Actions", + Self::GitlabCi => "GitLab CI", + } + } +} + +/// Port of libnpmpublish `ensureProvenanceGeneration`: fail with npm's wording before any network call. +pub fn ensure_provenance_generation() -> Result { + match CiProvider::detect() { + Some(CiProvider::GithubActions) => { + if env(bun_core::zstr!("ACTIONS_ID_TOKEN_REQUEST_URL")).is_none() { + return Err(SigstoreError::Usage( + "Provenance generation in GitHub Actions requires \"write\" access to the \ + \"id-token\" permission" + .into(), + )); + } + Ok(CiProvider::GithubActions) + } + Some(CiProvider::GitlabCi) => { + if env(bun_core::zstr!("SIGSTORE_ID_TOKEN")).is_none() { + return Err(SigstoreError::Usage( + "Provenance generation in GitLab CI requires \"SIGSTORE_ID_TOKEN\" with \ + \"sigstore\" audience to be present in \"id_tokens\". For more info see:\n\ + https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html" + .into(), + )); + } + Ok(CiProvider::GitlabCi) + } + None => Err(SigstoreError::Usage( + "Automatic provenance generation not supported outside of GitHub Actions or \ + GitLab CI" + .into(), + )), + } +} + +fn fetch_identity_token(audience: &str) -> Result { + // cosign-compatible env override — also how GitLab supplies its token. + if let Some(tok) = env(bun_core::zstr!("SIGSTORE_ID_TOKEN")) { + return Ok(utf8_lossy(tok).into_owned()); + } + + // GitHub Actions: GET $ACTIONS_ID_TOKEN_REQUEST_URL&audience=... with a bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN. + let (req_url, req_tok) = match ( + env(bun_core::zstr!("ACTIONS_ID_TOKEN_REQUEST_URL")), + env(bun_core::zstr!("ACTIONS_ID_TOKEN_REQUEST_TOKEN")), + ) { + (Some(u), Some(t)) => (u, t), + _ => { + return Err(SigstoreError::Identity( + "no OIDC token available — set SIGSTORE_ID_TOKEN or run in GitHub Actions with \ + `id-token: write` permission" + .into(), + )); + } + }; + + let mut url = utf8_lossy(req_url).into_owned(); + let sep = if strings::contains_char(url.as_bytes(), b'?') { + '&' + } else { + '?' + }; + url.push(sep); + url.push_str("audience="); + // encodeURIComponent semantics (as @actions/core); `strings::percent_encode_write` is path-segment-only and leaves `&=+` unescaped. + for b in audience.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + url.push(b as char); + } + _ => { + use std::fmt::Write as _; + let _ = write!(url, "%{b:02X}"); + } + } + } + + let mut auth = Vec::with_capacity(7 + req_tok.len()); + auth.extend_from_slice(b"Bearer "); + auth.extend_from_slice(req_tok); + + let body = http_json( + http::Method::GET, + &url, + &[(b"Accept", b"application/json"), (b"Authorization", &auth)], + b"", + "GitHub Actions OIDC", + )?; + + #[derive(Deserialize)] + struct Resp { + value: String, + } + let r: Resp = serde_json::from_slice(&body) + .map_err(|e| SigstoreError::Identity(format!("malformed OIDC response: {e}")))?; + Ok(r.value) +} + +/// Port of sigstore-js `oidc.extractJWTSubject`: verified `email`, else `sub`. +fn extract_jwt_subject(jwt: &str) -> Result { + // Second `.`-separated segment; an unsigned token has no third segment. + let (_header, rest) = strings::split_once_char(jwt.as_bytes(), b'.') + .ok_or_else(|| SigstoreError::Identity("malformed JWT".into()))?; + let payload = strings::split_once_char(rest, b'.').map_or(rest, |(payload, _sig)| payload); + let decoded = bun_base64::decode_alloc(payload) + .map_err(|_| SigstoreError::Identity("malformed JWT: bad base64".into()))?; + + #[derive(Deserialize)] + struct Claims { + #[serde(default)] + sub: Option, + #[serde(default)] + email: Option, + // `Value`, not `bool`: Dex and Azure AD v1 emit the string `"true"`; sigstore-js treats anything but boolean true as unverified, so must we. + #[serde(default)] + email_verified: Option, + } + let c: Claims = serde_json::from_slice(&decoded) + .map_err(|e| SigstoreError::Identity(format!("malformed JWT claims: {e}")))?; + + if let Some(email) = c.email.filter(|e| !e.is_empty()) { + if c.email_verified == Some(serde_json::Value::Bool(true)) { + return Ok(email); + } + } + c.sub + .filter(|s| !s.is_empty()) + .ok_or_else(|| SigstoreError::Identity("JWT subject not found".into())) +} + +// ── Fulcio — sigstore-js `CAClient` / `external/fulcio.ts` ── + +fn fulcio_request_cert( + base_url: &str, + id_token: &str, + public_key_pem: &str, + proof_of_possession: &[u8], +) -> Result, SigstoreError> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Credentials<'a> { + oidc_identity_token: &'a str, + } + #[derive(Serialize)] + struct PublicKey<'a> { + algorithm: &'a str, + content: &'a str, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct PublicKeyRequest<'a> { + public_key: PublicKey<'a>, + proof_of_possession: String, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Req<'a> { + credentials: Credentials<'a>, + public_key_request: PublicKeyRequest<'a>, + } + + let req = Req { + credentials: Credentials { + oidc_identity_token: id_token, + }, + public_key_request: PublicKeyRequest { + public_key: PublicKey { + algorithm: "ECDSA", + content: public_key_pem, + }, + proof_of_possession: b64_std(proof_of_possession), + }, + }; + let req_body = serde_json::to_vec(&req).map_err(|e| SigstoreError::Fulcio(e.to_string()))?; + + let url = format!("{}/api/v2/signingCert", base_url.trim_end_matches('/')); + let body = http_json( + http::Method::POST, + &url, + &[ + (b"Content-Type", b"application/json"), + (b"Accept", b"application/json"), + ], + &req_body, + "Fulcio", + )?; + + #[derive(Deserialize)] + struct Chain { + certificates: Vec, + } + #[derive(Deserialize)] + struct Signed { + chain: Chain, + } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Resp { + #[serde(default)] + signed_certificate_embedded_sct: Option, + #[serde(default)] + signed_certificate_detached_sct: Option, + } + let r: Resp = serde_json::from_slice(&body) + .map_err(|e| SigstoreError::Fulcio(format!("malformed response: {e}")))?; + let chain = r + .signed_certificate_embedded_sct + .or(r.signed_certificate_detached_sct) + .ok_or_else(|| SigstoreError::Fulcio("no certificate in response".into()))? + .chain + .certificates; + if chain.is_empty() { + return Err(SigstoreError::Fulcio("empty certificate chain".into())); + } + Ok(chain) +} + +// ── Rekor — sigstore-js `toProposedIntotoEntry` + `TLogClient` ── + +/// POST an `intoto` v0.0.2 entry (npm still submits `intoto`, not `dsse`, so bundles stay diffable against npm's) and convert the response into `tlogEntries[0]`. +#[allow(clippy::too_many_arguments)] +fn rekor_create_intoto_entry( + base_url: &str, + payload_type: &str, + payload: &[u8], + payload_b64: &str, + sig_b64: &str, + cert_pem: &str, + cert_pem_b64: &str, +) -> Result { + let payload_hash_hex = hex(&sha256(payload)); + + // sigstore-js `calculateDSSEHash`: SHA-256 of the key-sorted envelope JSON; with no keyid the sorted shape is fixed, so a template suffices. + let canon = format!( + r#"{{"payload":{},"payloadType":{},"signatures":[{{"publicKey":{},"sig":{}}}]}}"#, + json_str(payload_b64), + json_str(payload_type), + json_str(cert_pem), + json_str(sig_b64), + ); + let envelope_hash_hex = hex(&sha256(canon.as_bytes())); + + // Rekor double-base64-encodes payload and signature in the intoto entry. + let payload_b64_b64 = b64_std(payload_b64.as_bytes()); + let sig_b64_b64 = b64_std(sig_b64.as_bytes()); + + let proposed = serde_json::json!({ + "apiVersion": "0.0.2", + "kind": "intoto", + "spec": { + "content": { + "envelope": { + "payloadType": payload_type, + "payload": payload_b64_b64, + "signatures": [ + { "sig": sig_b64_b64, "publicKey": cert_pem_b64 } + ], + }, + "hash": { "algorithm": "sha256", "value": envelope_hash_hex }, + "payloadHash": { "algorithm": "sha256", "value": payload_hash_hex }, + }, + }, + }); + let req_body = + serde_json::to_vec(&proposed).map_err(|e| SigstoreError::Rekor(e.to_string()))?; + + let url = format!("{}/api/v1/log/entries", base_url.trim_end_matches('/')); + let body = http_json( + http::Method::POST, + &url, + &[ + (b"Content-Type", b"application/json"), + (b"Accept", b"application/json"), + ], + &req_body, + "Rekor", + )?; + + // Response is `{ "": { body, integratedTime, logID, logIndex, verification: {...} } }`. + let entries: serde_json::Map = serde_json::from_slice(&body) + .map_err(|e| SigstoreError::Rekor(format!("malformed response: {e}")))?; + let (_uuid, entry) = entries + .into_iter() + .next() + .ok_or_else(|| SigstoreError::Rekor("empty response".into()))?; + + rekor_entry_to_tlog(&entry) +} + +fn rekor_entry_to_tlog(e: &serde_json::Value) -> Result { + let get_str = |k: &str| -> Result { + e.get(k) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) + .ok_or_else(|| SigstoreError::Rekor(format!("missing `{k}`"))) + }; + let get_i64 = |k: &str| -> Result { + e.get(k) + .and_then(|v| v.as_i64()) + .ok_or_else(|| SigstoreError::Rekor(format!("missing `{k}`"))) + }; + + let body_b64 = get_str("body")?; + let body_json = bun_base64::decode_alloc(body_b64.as_bytes()) + .map_err(|_| SigstoreError::Rekor("bad base64 in `body`".into()))?; + #[derive(Deserialize)] + struct KindVersion { + kind: String, + #[serde(rename = "apiVersion")] + api_version: String, + } + let kv: KindVersion = serde_json::from_slice(&body_json) + .map_err(|e| SigstoreError::Rekor(format!("bad `body` JSON: {e}")))?; + + let log_id_hex = get_str("logID")?; + let log_id_raw = + hex_decode(&log_id_hex).ok_or_else(|| SigstoreError::Rekor("bad hex in `logID`".into()))?; + + let verification = e.get("verification"); + let inclusion_promise = verification + .and_then(|v| v.get("signedEntryTimestamp")) + .and_then(|v| v.as_str()) + .map(|s| SerializedInclusionPromise { + // Already base64 in the Rekor response — pass through as-is. + signed_entry_timestamp: s.to_owned(), + }); + + // Absent or null is legal (sigstore-js parity); present-but-malformed is an error rather than a silently corrupt proof. + let inclusion_proof = match verification + .and_then(|v| v.get("inclusionProof")) + .filter(|v| !v.is_null()) + { + None => None, + Some(p) => { + let parsed = (|| -> Option { + Some(SerializedInclusionProof { + log_index: p.get("logIndex")?.as_i64()?.to_string(), + root_hash: b64_std(&hex_decode(p.get("rootHash")?.as_str()?)?), + tree_size: p.get("treeSize")?.as_i64()?.to_string(), + // One bad hash fails the whole proof rather than shortening the chain. + hashes: p + .get("hashes")? + .as_array()? + .iter() + .map(|h| Some(b64_std(&hex_decode(h.as_str()?)?))) + .collect::>>()?, + checkpoint: SerializedCheckpoint { + envelope: p.get("checkpoint")?.as_str()?.to_owned(), + }, + }) + })(); + Some(parsed.ok_or_else(|| { + SigstoreError::Rekor("malformed `inclusionProof` in response".into()) + })?) + } + }; + + Ok(SerializedTlogEntry { + log_index: get_i64("logIndex")?.to_string(), + log_id: SerializedLogId { + key_id: b64_std(&log_id_raw), + }, + kind_version: SerializedKindVersion { + kind: kv.kind, + version: kv.api_version, + }, + integrated_time: get_i64("integratedTime")?.to_string(), + inclusion_promise, + inclusion_proof, + canonicalized_body: body_b64, + }) +} + +// ── DSSE helpers ── + +/// DSSE PAE: `"DSSEv1" SP len(type) SP type SP len(body) SP body`. +fn dsse_pre_auth_encoding(payload_type: &str, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(64 + payload_type.len() + payload.len()); + write!( + &mut out, + "DSSEv1 {} {} {} ", + payload_type.len(), + payload_type, + payload.len() + ) + .ok(); + out.extend_from_slice(payload); + out +} + +// ── Serialized Sigstore bundle — mirrors sigstore-js `SerializedBundle` ── + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SerializedBundle<'a> { + media_type: &'a str, + verification_material: SerializedVerificationMaterial, + dsse_envelope: SerializedEnvelope<'a>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SerializedVerificationMaterial { + x509_certificate_chain: SerializedX509Chain, + tlog_entries: Vec, + timestamp_verification_data: SerializedTimestampVerificationData, +} + +#[derive(Serialize)] +struct SerializedX509Chain { + certificates: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SerializedX509Cert { + raw_bytes: String, +} + +#[derive(Serialize)] +struct SerializedTimestampVerificationData { + #[serde(rename = "rfc3161Timestamps")] + rfc3161_timestamps: Vec<()>, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct SerializedTlogEntry { + log_index: String, + log_id: SerializedLogId, + kind_version: SerializedKindVersion, + integrated_time: String, + #[serde(skip_serializing_if = "Option::is_none")] + inclusion_promise: Option, + #[serde(skip_serializing_if = "Option::is_none")] + inclusion_proof: Option, + canonicalized_body: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct SerializedLogId { + key_id: String, +} + +#[derive(Serialize, Clone)] +struct SerializedKindVersion { + kind: String, + version: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct SerializedInclusionPromise { + signed_entry_timestamp: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct SerializedInclusionProof { + log_index: String, + root_hash: String, + tree_size: String, + hashes: Vec, + checkpoint: SerializedCheckpoint, +} + +#[derive(Serialize, Clone)] +struct SerializedCheckpoint { + envelope: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SerializedEnvelope<'a> { + payload: String, + payload_type: &'a str, + signatures: Vec>, +} + +#[derive(Serialize)] +struct SerializedEnvelopeSignature<'a> { + sig: String, + keyid: &'a str, +} + +// ── HTTP plumbing over `bun_http::AsyncHTTP` ── + +/// One-shot synchronous request: the body on 2xx, otherwise `SigstoreError::Http` tagged with `who`. +fn http_json( + method: http::Method, + url: &str, + headers: &[(&[u8], &[u8])], + body: &[u8], + who: &'static str, +) -> Result, SigstoreError> { + // `URL<'a>` borrows into its input; keep the bytes alive past `req`. + let url_bytes = url.as_bytes().to_vec(); + let parsed_url = URL::parse(&url_bytes); + let host = parsed_url.host; + + let mut hb = HeaderBuilder::default(); + for (k, v) in headers { + hb.count(k, v); + } + hb.count(b"User-Agent", user_agent()); + hb.count(b"Connection", b"keep-alive"); + hb.count(b"Host", host); + let len_s; // keep alive across count+append + if !body.is_empty() { + len_s = body.len().to_string(); + hb.count(b"Content-Length", len_s.as_bytes()); + } else { + len_s = String::new(); + } + hb.allocate().map_err(|_| SigstoreError::OutOfMemory)?; + for (k, v) in headers { + hb.append(k, v); + } + hb.append(b"User-Agent", user_agent()); + hb.append(b"Connection", b"keep-alive"); + hb.append(b"Host", host); + if !body.is_empty() { + hb.append(b"Content-Length", len_s.as_bytes()); + } + + let mut response_buf = MutableString::init(1024).map_err(|_| SigstoreError::OutOfMemory)?; + + let mut req = http::AsyncHTTP::init_sync( + method, + parsed_url, + hb.entries, + hb.content.written_slice(), + body, + None, + None, + http::FetchRedirect::Follow, + ); + + let res = req + .send_sync(&mut response_buf) + .map_err(|e| SigstoreError::Http { + who, + detail: format!("{e}"), + })?; + + let status = res.status_code(); + if status >= 400 || status == 0 { + let body_preview = utf8_lossy(&response_buf.list); + let body_preview: String = body_preview.chars().take(512).collect(); + return Err(SigstoreError::Http { + who, + detail: format!("HTTP {status}: {body_preview}"), + }); + } + + Ok(std::mem::take(&mut response_buf.list)) +} + +fn user_agent() -> &'static [u8] { + static UA: std::sync::OnceLock> = std::sync::OnceLock::new(); + UA.get_or_init(|| format!("bun/{}", bun_core::Global::package_json_version).into_bytes()) +} + +// ── Misc small helpers ── + +fn sha256(data: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + bun_sha_hmac::sha::hashers::SHA256::hash(data, &mut out); + out +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0xF) as usize] as char); + } + s +} + +fn hex_decode(s: &str) -> Option> { + let s = s.as_bytes(); + if !s.len().is_multiple_of(2) { + return None; + } + let nyb = |c: u8| -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } + }; + let mut out = Vec::with_capacity(s.len() / 2); + let (pairs, _rest) = s.as_chunks::<2>(); + for &[hi, lo] in pairs { + out.push((nyb(hi)? << 4) | nyb(lo)?); + } + Some(out) +} + +/// Standard padded base64, as every Sigstore/Fulcio/Rekor field uses; `encode_alloc` output is ASCII, so `from_utf8` cannot fail. +#[allow(clippy::disallowed_methods)] // ASCII by construction, documented above +fn b64_std(data: &[u8]) -> String { + String::from_utf8(bun_base64::encode_alloc(data)).expect("base64 is ASCII") +} + +/// JSON string literal for embedding in a hand-written canonical template. +fn json_str(s: &str) -> String { + serde_json::to_string(s).expect("string always serializes") +} + +/// DER body of a single-block PEM (not worth a `pem` dependency for one call site). +fn pem_to_der(pem: &str) -> Result, SigstoreError> { + let mut body: Vec = Vec::new(); + let mut in_block = false; + for line in strings::split(pem.as_bytes(), b"\n") { + let line = line.trim_ascii(); + if line.starts_with(b"-----BEGIN ") { + in_block = true; + continue; + } + if line.starts_with(b"-----END ") { + break; + } + if in_block { + body.extend_from_slice(line); + } + } + if body.is_empty() { + return Err(SigstoreError::Fulcio("malformed PEM certificate".into())); + } + bun_base64::decode_alloc(&body) + .map_err(|_| SigstoreError::Fulcio("malformed PEM base64".into())) +} + +// ── Errors ── + +#[derive(thiserror::Error, Debug)] +pub enum SigstoreError { + #[error("OutOfMemory")] + OutOfMemory, + /// User-facing precondition failures (npm's `EUSAGE`). + #[error("{0}")] + Usage(String), + #[error("identity token: {0}")] + Identity(String), + #[error("crypto: {0}")] + Crypto(String), + #[error("Fulcio: {0}")] + Fulcio(String), + #[error("Rekor: {0}")] + Rekor(String), + #[error("bundle: {0}")] + Bundle(String), + #[error("{who}: {detail}")] + Http { who: &'static str, detail: String }, +} + +impl SigstoreError { + /// Prints in `Output::err` style; the publish path then crashes. + pub fn print(&self) { + match self { + SigstoreError::Usage(msg) => { + // No flag name: these also arise from --provenance-file, NPM_CONFIG_PROVENANCE and publishConfig. + Output::err_generic("provenance: {}", (msg.as_str(),)); + } + other => { + Output::err_generic( + "failed to generate provenance: {}", + (format!("{other}").as_str(),), + ); + } + } + } +} diff --git a/src/sigstore/provenance.rs b/src/sigstore/provenance.rs new file mode 100644 index 000000000000..66732035f779 --- /dev/null +++ b/src/sigstore/provenance.rs @@ -0,0 +1,263 @@ +//! In-toto provenance statements ported from `libnpmpublish/lib/provenance.js`: SLSA v1 for GitHub Actions, SLSA v0.2 for GitLab CI. + +use serde_json::{Value, json}; + +use crate::CiProvider; + +const INTOTO_STATEMENT_V1_TYPE: &str = "https://in-toto.io/Statement/v1"; +const INTOTO_STATEMENT_V01_TYPE: &str = "https://in-toto.io/Statement/v0.1"; +const SLSA_PREDICATE_V1_TYPE: &str = "https://slsa.dev/provenance/v1"; +const SLSA_PREDICATE_V02_TYPE: &str = "https://slsa.dev/provenance/v0.2"; + +const GITHUB_BUILDER_ID_PREFIX: &str = "https://github.com/actions/runner"; +const GITHUB_BUILD_TYPE: &str = + "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; + +const GITLAB_BUILD_TYPE_PREFIX: &str = "https://github.com/npm/cli/gitlab"; +const GITLAB_BUILD_TYPE_VERSION: &str = "v0alpha1"; + +/// `pkg:npm/@` with a leading `@` encoded as `%40`, as `npm-package-arg` `toPurl` does. +pub fn to_purl(name: &[u8], version: &[u8]) -> String { + let mut s = String::with_capacity(10 + name.len() + version.len()); + s.push_str("pkg:npm/"); + if let Some(stripped) = name.strip_prefix(b"@") { + s.push_str("%40"); + s.push_str(&crate::utf8_lossy(stripped)); + } else { + s.push_str(&crate::utf8_lossy(name)); + } + s.push('@'); + s.push_str(&crate::utf8_lossy(version)); + s +} + +/// Build the `subject` array for the in-toto statement. +pub fn subject(name: &[u8], version: &[u8], sha512_hex: &str) -> Value { + json!([{ + "name": to_purl(name, version), + "digest": { "sha512": sha512_hex }, + }]) +} + +/// Statement JSON for `provider`, wrapping the value returned by [`subject`]. +pub fn generate(provider: CiProvider, subject: &Value) -> Vec { + let stmt = match provider { + CiProvider::GithubActions => github_statement(subject), + CiProvider::GitlabCi => gitlab_statement(subject), + }; + serde_json::to_vec(&stmt).expect("provenance statement always serializes") +} + +fn env(key: &bun_core::ZStr) -> String { + match bun_core::getenv_z(key) { + Some(v) => crate::utf8_lossy(v).into_owned(), + None => String::new(), + } +} + +macro_rules! e { + ($name:literal) => { + env(bun_core::zstr!($name)) + }; +} + +fn github_statement(subject: &Value) -> Value { + let repo = e!("GITHUB_REPOSITORY"); + let server = e!("GITHUB_SERVER_URL"); + let workflow_ref = e!("GITHUB_WORKFLOW_REF"); + + // GITHUB_WORKFLOW_REF minus the `//` prefix, split at the first `@` into (path, ref) as npm does. + let prefix = format!("{repo}/"); + let relative = workflow_ref.strip_prefix(&prefix).unwrap_or(&workflow_ref); + // `@` is ASCII, so the byte index is a char boundary. + let (workflow_path, workflow_git_ref) = + match bun_core::strings::index_of_char_usize(relative.as_bytes(), b'@') { + Some(i) => (&relative[..i], &relative[i + 1..]), + None => (relative, ""), + }; + + json!({ + "_type": INTOTO_STATEMENT_V1_TYPE, + "subject": subject, + "predicateType": SLSA_PREDICATE_V1_TYPE, + "predicate": { + "buildDefinition": { + "buildType": GITHUB_BUILD_TYPE, + "externalParameters": { + "workflow": { + "ref": workflow_git_ref, + "repository": format!("{server}/{repo}"), + "path": workflow_path, + }, + }, + "internalParameters": { + "github": { + "event_name": e!("GITHUB_EVENT_NAME"), + "repository_id": e!("GITHUB_REPOSITORY_ID"), + "repository_owner_id": e!("GITHUB_REPOSITORY_OWNER_ID"), + }, + }, + "resolvedDependencies": [ + { + "uri": format!("git+{server}/{repo}@{}", e!("GITHUB_REF")), + "digest": { "gitCommit": e!("GITHUB_SHA") }, + } + ], + }, + "runDetails": { + "builder": { + "id": format!("{GITHUB_BUILDER_ID_PREFIX}/{}", e!("RUNNER_ENVIRONMENT")), + }, + "metadata": { + "invocationId": format!( + "{server}/{repo}/actions/runs/{}/attempts/{}", + e!("GITHUB_RUN_ID"), + e!("GITHUB_RUN_ATTEMPT"), + ), + }, + }, + }, + }) +} + +fn gitlab_statement(subject: &Value) -> Value { + // Same key list as libnpmpublish; unset vars are omitted, as `JSON.stringify` drops npm's `undefined` values. + macro_rules! ci_params { + ($($k:literal),* $(,)?) => {{ + let mut m = serde_json::Map::new(); + $( + if let Some(v) = bun_core::getenv_z(bun_core::zstr!($k)) { + m.insert($k.into(), crate::utf8_lossy(v).into_owned().into()); + } + )* + serde_json::Value::Object(m) + }}; + } + let parameters = ci_params![ + "CI", + "CI_API_GRAPHQL_URL", + "CI_API_V4_URL", + "CI_BUILD_BEFORE_SHA", + "CI_BUILD_ID", + "CI_BUILD_NAME", + "CI_BUILD_REF", + "CI_BUILD_REF_NAME", + "CI_BUILD_REF_SLUG", + "CI_BUILD_STAGE", + "CI_COMMIT_BEFORE_SHA", + "CI_COMMIT_BRANCH", + "CI_COMMIT_REF_NAME", + "CI_COMMIT_REF_PROTECTED", + "CI_COMMIT_REF_SLUG", + "CI_COMMIT_SHA", + "CI_COMMIT_SHORT_SHA", + "CI_COMMIT_TIMESTAMP", + "CI_COMMIT_TITLE", + "CI_CONFIG_PATH", + "CI_DEFAULT_BRANCH", + "CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX", + "CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX", + "CI_DEPENDENCY_PROXY_SERVER", + "CI_DEPENDENCY_PROXY_USER", + "CI_JOB_ID", + "CI_JOB_NAME", + "CI_JOB_NAME_SLUG", + "CI_JOB_STAGE", + "CI_JOB_STARTED_AT", + "CI_JOB_URL", + "CI_NODE_TOTAL", + "CI_PAGES_DOMAIN", + "CI_PAGES_URL", + "CI_PIPELINE_CREATED_AT", + "CI_PIPELINE_ID", + "CI_PIPELINE_IID", + "CI_PIPELINE_SOURCE", + "CI_PIPELINE_URL", + "CI_PROJECT_CLASSIFICATION_LABEL", + "CI_PROJECT_DESCRIPTION", + "CI_PROJECT_ID", + "CI_PROJECT_NAME", + "CI_PROJECT_NAMESPACE", + "CI_PROJECT_NAMESPACE_ID", + "CI_PROJECT_PATH", + "CI_PROJECT_PATH_SLUG", + "CI_PROJECT_REPOSITORY_LANGUAGES", + "CI_PROJECT_ROOT_NAMESPACE", + "CI_PROJECT_TITLE", + "CI_PROJECT_URL", + "CI_PROJECT_VISIBILITY", + "CI_REGISTRY", + "CI_REGISTRY_IMAGE", + "CI_REGISTRY_USER", + "CI_RUNNER_DESCRIPTION", + "CI_RUNNER_ID", + "CI_RUNNER_TAGS", + "CI_SERVER_HOST", + "CI_SERVER_NAME", + "CI_SERVER_PORT", + "CI_SERVER_PROTOCOL", + "CI_SERVER_REVISION", + "CI_SERVER_SHELL_SSH_HOST", + "CI_SERVER_SHELL_SSH_PORT", + "CI_SERVER_URL", + "CI_SERVER_VERSION", + "CI_SERVER_VERSION_MAJOR", + "CI_SERVER_VERSION_MINOR", + "CI_SERVER_VERSION_PATCH", + "CI_TEMPLATE_REGISTRY_HOST", + "GITLAB_CI", + "GITLAB_FEATURES", + "GITLAB_USER_ID", + "GITLAB_USER_LOGIN", + "RUNNER_GENERATE_ARTIFACTS_METADATA", + ]; + + let project_url = e!("CI_PROJECT_URL"); + let commit_sha = e!("CI_COMMIT_SHA"); + + json!({ + "_type": INTOTO_STATEMENT_V01_TYPE, + "subject": subject, + "predicateType": SLSA_PREDICATE_V02_TYPE, + "predicate": { + "buildType": format!("{GITLAB_BUILD_TYPE_PREFIX}/{GITLAB_BUILD_TYPE_VERSION}"), + "builder": { + "id": format!("{project_url}/-/runners/{}", e!("CI_RUNNER_ID")), + }, + "invocation": { + "configSource": { + "uri": format!("git+{project_url}"), + "digest": { "sha1": commit_sha }, + "entryPoint": e!("CI_JOB_NAME"), + }, + "parameters": parameters, + "environment": { + "name": e!("CI_RUNNER_DESCRIPTION"), + "architecture": e!("CI_RUNNER_EXECUTABLE_ARCH"), + "server": e!("CI_SERVER_URL"), + "project": e!("CI_PROJECT_PATH"), + "job": { "id": e!("CI_JOB_ID") }, + "pipeline": { + "id": e!("CI_PIPELINE_ID"), + "ref": e!("CI_CONFIG_PATH"), + }, + }, + }, + "metadata": { + "buildInvocationId": e!("CI_JOB_URL"), + "completeness": { + "parameters": true, + "environment": true, + "materials": false, + }, + "reproducible": false, + }, + "materials": [ + { + "uri": format!("git+{project_url}"), + "digest": { "sha1": commit_sha }, + } + ], + }, + }) +} diff --git a/test/cli/install/bun-publish-provenance.test.ts b/test/cli/install/bun-publish-provenance.test.ts new file mode 100644 index 000000000000..70084ebdd47f --- /dev/null +++ b/test/cli/install/bun-publish-provenance.test.ts @@ -0,0 +1,596 @@ +import { spawn, write } from "bun"; +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { VerdaccioRegistry, bunEnv, bunExe } from "harness"; +import { join } from "path"; + +// Must be module-scope: test timeouts are captured at `test()` +// registration, before `beforeAll` runs. +setDefaultTimeout(1000 * 60 * 5); + +// A Verdaccio instance is used only for `registry.createTestDir()` — all +// actual publish PUTs in these tests go to an in-process `Bun.serve` mock +// so we can inspect the body. +const registry = new VerdaccioRegistry(); +beforeAll(async () => { + await registry.start(); +}); +afterAll(() => { + registry.stop(); +}); + +async function publish(env: Record, cwd: string, ...args: string[]) { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "publish", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env, + }); + const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]); + return { out, err, exitCode }; +} + +// Any syntactically-valid PEM — Fulcio response parsing only needs to +// base64-decode the body into `rawBytes` for the bundle; neither the +// registry nor the test asserts on the DER contents. +const DUMMY_CERT_PEM = + "-----BEGIN CERTIFICATE-----\n" + "TUlJQkZ1bGNpb01vY2tDZXJ0aWZpY2F0ZQ==\n" + "-----END CERTIFICATE-----\n"; + +// Minimal JWT with `{"sub":"repo:oven-sh/bun:ref:refs/heads/main","aud":"sigstore"}` +// for the proof-of-possession subject extraction. Segments are unpadded +// base64url, same as real OIDC issuers — exercises the url-safe decode path. +function fakeJwt(): string { + const b64url = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url"); + return ( + b64url({ alg: "none", typ: "JWT" }) + + "." + + b64url({ sub: "repo:oven-sh/bun:ref:refs/heads/main", aud: "sigstore" }) + + ".sig" + ); +} + +// Rekor /api/v1/log/entries response — `{ "": }`. +// Includes an `inclusionProof.checkpoint` with a U+2014 EM DASH (as real +// Rekor does per the sumdb/note signed-note format) so the UTF-8 → JSON +// encoding path in the publish body is exercised. +const FAKE_CHECKPOINT = "rekor.sigstore.dev - 12345678\n424242\nabcd\n\n\u2014 rekor.sigstore.dev wNI9aj==\n"; +function fakeRekorResponse() { + const body = { + apiVersion: "0.0.2", + kind: "intoto", + spec: { content: {} }, + }; + return { + mockuuid0000: { + body: Buffer.from(JSON.stringify(body)).toString("base64"), + integratedTime: 1700000000, + // 32-byte hex — becomes the bundle's `logId.keyId`. + logID: "c0ffee".repeat(10) + "ee".repeat(2), + logIndex: 424242, + verification: { + signedEntryTimestamp: Buffer.from("set").toString("base64"), + inclusionProof: { + logIndex: 424242, + rootHash: "aa".repeat(32), + treeSize: 500000, + hashes: ["bb".repeat(32)], + checkpoint: FAKE_CHECKPOINT, + }, + }, + }, + }; +} + +describe("--provenance", () => { + test("attaches a sigstore bundle built against mock Fulcio/Rekor (GitHub Actions)", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + + // What the registry receives, so we can assert on the PUT body. + let putBody: any = null; + let fulcioReq: any = null; + let rekorReq: any = null; + let oidcUrl: string | null = null; + + using server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + // GitHub Actions OIDC token endpoint. + if (url.pathname === "/gha-oidc") { + oidcUrl = req.url; + expect(url.searchParams.get("audience")).toBe("sigstore"); + expect(req.headers.get("authorization")).toBe("Bearer gha-req-tok"); + return Response.json({ value: fakeJwt() }); + } + // Fulcio. + if (url.pathname === "/api/v2/signingCert") { + fulcioReq = await req.json(); + return Response.json({ + signedCertificateEmbeddedSct: { + chain: { certificates: [DUMMY_CERT_PEM, DUMMY_CERT_PEM] }, + }, + }); + } + // Rekor. + if (url.pathname === "/api/v1/log/entries") { + rekorReq = await req.json(); + return Response.json(fakeRekorResponse(), { status: 201 }); + } + // npm registry PUT. + if (req.method === "PUT") { + putBody = await req.json(); + return new Response("{}", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + const base = `http://localhost:${server.port}`; + + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-1", version: "1.2.3" })), + ]); + + const env = { + ...bunEnv, + // Pretend we're in GitHub Actions with id-token: write. + GITHUB_ACTIONS: "true", + ACTIONS_ID_TOKEN_REQUEST_URL: `${base}/gha-oidc`, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "gha-req-tok", + GITHUB_REPOSITORY: "oven-sh/bun", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_WORKFLOW_REF: "oven-sh/bun/.github/workflows/release.yml@refs/heads/main", + GITHUB_REF: "refs/heads/main", + GITHUB_SHA: "deadbeef", + GITHUB_EVENT_NAME: "push", + GITHUB_REPOSITORY_ID: "1", + GITHUB_REPOSITORY_OWNER_ID: "2", + GITHUB_RUN_ID: "99", + GITHUB_RUN_ATTEMPT: "1", + RUNNER_ENVIRONMENT: "github-hosted", + // Point sigstore at our mock. + BUN_SIGSTORE_FULCIO_URL: base, + BUN_SIGSTORE_REKOR_URL: base, + CI: "1", + }; + + const { out, err, exitCode } = await publish(env, packageDir, "--provenance", "--access", "public"); + // user-facing notices go to stdout via Output::prettyln. + expect(out + err).toContain("Signed provenance statement"); + expect(out + err).toContain("Transparency log"); + expect(err).not.toContain("error:"); + if (exitCode !== 0) expect(err).toBe(""); + expect(exitCode).toBe(0); + + // ── OIDC flow ──────────────────────────────────────────────────── + expect(oidcUrl).not.toBeNull(); + + // ── Fulcio request shape (sigstore-js `toCertificateRequest`) ──── + expect(fulcioReq).not.toBeNull(); + expect(fulcioReq.credentials.oidcIdentityToken).toBe(fakeJwt()); + expect(fulcioReq.publicKeyRequest.publicKey.algorithm).toBe("ECDSA"); + expect(fulcioReq.publicKeyRequest.publicKey.content).toContain("-----BEGIN PUBLIC KEY-----"); + expect(typeof fulcioReq.publicKeyRequest.proofOfPossession).toBe("string"); + expect(fulcioReq.publicKeyRequest.proofOfPossession.length).toBeGreaterThan(0); + + // ── Rekor request shape (sigstore-js `toProposedIntotoEntry`) ──── + expect(rekorReq).not.toBeNull(); + expect(rekorReq.kind).toBe("intoto"); + expect(rekorReq.apiVersion).toBe("0.0.2"); + expect(rekorReq.spec.content.envelope.payloadType).toBe("application/vnd.in-toto+json"); + expect(rekorReq.spec.content.hash.algorithm).toBe("sha256"); + expect(rekorReq.spec.content.payloadHash.algorithm).toBe("sha256"); + + // ── Registry PUT body (libnpmpublish) ──────────────────────────── + expect(putBody).not.toBeNull(); + expect(putBody.name).toBe("prov-pkg-1"); + expect(putBody._attachments["prov-pkg-1-1.2.3.tgz"]).toBeDefined(); + + const att = putBody._attachments["prov-pkg-1-1.2.3.sigstore"]; + expect(att).toBeDefined(); + expect(att.content_type).toBe("application/vnd.dev.sigstore.bundle+json;version=0.2"); + expect(att.length).toBe(att.data.length); + + const bundle = JSON.parse(att.data); + expect(bundle.mediaType).toBe("application/vnd.dev.sigstore.bundle+json;version=0.2"); + + // DSSE envelope — payload is the base64'd in-toto statement. + expect(bundle.dsseEnvelope.payloadType).toBe("application/vnd.in-toto+json"); + expect(bundle.dsseEnvelope.signatures).toHaveLength(1); + const stmt = JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, "base64").toString("utf8")); + expect(stmt._type).toBe("https://in-toto.io/Statement/v1"); + expect(stmt.predicateType).toBe("https://slsa.dev/provenance/v1"); + expect(stmt.subject).toHaveLength(1); + expect(stmt.subject[0].name).toBe("pkg:npm/prov-pkg-1@1.2.3"); + expect(stmt.subject[0].digest.sha512).toMatch(/^[0-9a-f]{128}$/); + // GitHub env → buildDefinition. + expect(stmt.predicate.buildDefinition.externalParameters.workflow.repository).toBe( + "https://github.com/oven-sh/bun", + ); + expect(stmt.predicate.buildDefinition.externalParameters.workflow.path).toBe(".github/workflows/release.yml"); + expect(stmt.predicate.buildDefinition.externalParameters.workflow.ref).toBe("refs/heads/main"); + expect(stmt.predicate.runDetails.builder.id).toBe("https://github.com/actions/runner/github-hosted"); + + // Verification material: leaf cert + tlog entry from mock Rekor. + expect(bundle.verificationMaterial.x509CertificateChain.certificates).toHaveLength(1); + expect(bundle.verificationMaterial.tlogEntries).toHaveLength(1); + const tlog = bundle.verificationMaterial.tlogEntries[0]; + expect(tlog.logIndex).toBe("424242"); + expect(tlog.kindVersion).toEqual({ kind: "intoto", version: "0.0.2" }); + expect(tlog.integratedTime).toBe("1700000000"); + expect(tlog.inclusionPromise.signedEntryTimestamp).toBe(Buffer.from("set").toString("base64")); + // Non-ASCII round-trip: U+2014 in the checkpoint must survive the JSON + // string encoding of `_attachments[*].data` intact (no Latin-1 mojibake). + expect(tlog.inclusionProof.checkpoint.envelope).toBe(FAKE_CHECKPOINT); + expect(tlog.inclusionProof.checkpoint.envelope).toContain("\u2014"); + expect(att.data).not.toContain("\u00e2"); // â — first byte of mojibake'd em-dash + }); + + test("errors outside of supported CI", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + using server = Bun.serve({ + port: 0, + fetch: () => new Response("{}", { status: 200 }), + }); + const base = `http://localhost:${server.port}`; + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-2", version: "1.0.0" })), + ]); + const env = { + ...bunEnv, + GITHUB_ACTIONS: undefined, + GITLAB_CI: undefined, + CI: undefined, + }; + const { err, exitCode } = await publish(env, packageDir, "--provenance", "--access", "public"); + expect(err).toContain("Automatic provenance generation not supported"); + expect(exitCode).toBe(1); + }); + + test("errors in GitHub Actions without id-token permission", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + using server = Bun.serve({ + port: 0, + fetch: () => new Response("{}", { status: 200 }), + }); + const base = `http://localhost:${server.port}`; + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-3", version: "1.0.0" })), + ]); + const env = { + ...bunEnv, + GITHUB_ACTIONS: "true", + ACTIONS_ID_TOKEN_REQUEST_URL: undefined, + CI: "1", + }; + const { err, exitCode } = await publish(env, packageDir, "--provenance", "--access", "public"); + expect(err).toContain('"write" access to the "id-token" permission'); + expect(exitCode).toBe(1); + }); + + test("requires --access public", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + using server = Bun.serve({ + port: 0, + fetch: () => new Response("{}", { status: 200 }), + }); + const base = `http://localhost:${server.port}`; + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-4", version: "1.0.0" })), + ]); + const { err, exitCode } = await publish({ ...bunEnv, GITHUB_ACTIONS: "true", CI: "1" }, packageDir, "--provenance"); + // Flag-agnostic wording: the same check fires for publishConfig.provenance + // and NPM_CONFIG_PROVENANCE, where no --provenance flag was typed. + expect(err).toContain("provenance requires --access public"); + expect(err).not.toContain("--provenance requires"); + expect(exitCode).toBe(1); + }); + + test("NPM_CONFIG_PROVENANCE=true enables it; --no-provenance wins", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + let putBody: any = null; + using server = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") { + putBody = await req.json(); + return new Response("{}", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }, + }); + const base = `http://localhost:${server.port}`; + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-5", version: "1.0.0" })), + ]); + + // NPM_CONFIG_PROVENANCE=true alone (no CI) → should try and fail + // without --no-provenance… + { + const { err, exitCode } = await publish( + { + ...bunEnv, + NPM_CONFIG_PROVENANCE: "true", + GITHUB_ACTIONS: undefined, + GITLAB_CI: undefined, + }, + packageDir, + "--access", + "public", + ); + expect(err).toContain("Automatic provenance generation not supported"); + expect(exitCode).toBe(1); + } + + // …but --no-provenance overrides it and publishes cleanly. + { + const { err, exitCode } = await publish( + { + ...bunEnv, + NPM_CONFIG_PROVENANCE: "true", + GITHUB_ACTIONS: undefined, + GITLAB_CI: undefined, + }, + packageDir, + "--access", + "public", + "--no-provenance", + ); + if (exitCode !== 0) expect(err).toBe(""); + expect(exitCode).toBe(0); + expect(putBody).not.toBeNull(); + expect(putBody._attachments["prov-pkg-5-1.0.0.sigstore"]).toBeUndefined(); + } + }); + + test("publishConfig.provenance: true enables it; --no-provenance wins", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + let putBody: any = null; + using server = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") { + putBody = await req.json(); + return new Response("{}", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }, + }); + const base = `http://localhost:${server.port}`; + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write( + packageJson, + JSON.stringify({ + name: "prov-pkg-7", + version: "1.0.0", + publishConfig: { provenance: true }, + }), + ), + ]); + + // publishConfig.provenance: true alone (no CI, no flag) → should try + // and fail… + { + const { err, exitCode } = await publish( + { ...bunEnv, GITHUB_ACTIONS: undefined, GITLAB_CI: undefined }, + packageDir, + "--access", + "public", + ); + expect(err).toContain("Automatic provenance generation not supported"); + expect(exitCode).toBe(1); + } + + // …but --no-provenance on the CLI overrides publishConfig and + // publishes cleanly. + { + const { err, exitCode } = await publish( + { ...bunEnv, GITHUB_ACTIONS: undefined, GITLAB_CI: undefined }, + packageDir, + "--access", + "public", + "--no-provenance", + ); + if (exitCode !== 0) expect(err).toBe(""); + expect(exitCode).toBe(0); + expect(putBody).not.toBeNull(); + expect(putBody._attachments["prov-pkg-7-1.0.0.sigstore"]).toBeUndefined(); + } + + // publishConfig.provenance: false suppresses NPM_CONFIG_PROVENANCE. + putBody = null; + await write( + packageJson, + JSON.stringify({ + name: "prov-pkg-7", + version: "1.0.1", + publishConfig: { provenance: false }, + }), + ); + { + const { err, exitCode } = await publish( + { + ...bunEnv, + NPM_CONFIG_PROVENANCE: "true", + GITHUB_ACTIONS: undefined, + GITLAB_CI: undefined, + }, + packageDir, + "--access", + "public", + ); + if (exitCode !== 0) expect(err).toBe(""); + expect(exitCode).toBe(0); + expect(putBody).not.toBeNull(); + expect(putBody._attachments["prov-pkg-7-1.0.1.sigstore"]).toBeUndefined(); + } + }); + + test("--provenance-file: attaches when subject matches, rejects when it doesn't", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + let putBody: any = null; + using server = Bun.serve({ + port: 0, + async fetch(req) { + if (req.method === "PUT") { + putBody = await req.json(); + return new Response("{}", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + const base = `http://localhost:${server.port}`; + + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-6", version: "2.0.0" })), + ]); + + const makeBundle = (subjectName: string, sha512: string) => ({ + mediaType: "application/vnd.dev.sigstore.bundle+json;version=0.2", + dsseEnvelope: { + payload: Buffer.from(JSON.stringify({ subject: [{ name: subjectName, digest: { sha512 } }] })).toString( + "base64", + ), + payloadType: "application/vnd.in-toto+json", + signatures: [{ sig: "AA==", keyid: "" }], + }, + }); + + // Subject mismatch → rejected before any PUT. + { + const badPath = join(packageDir, "bad.sigstore"); + await write(badPath, JSON.stringify(makeBundle("pkg:npm/other@1.0.0", "00"))); + const { err, exitCode } = await publish( + { ...bunEnv }, + packageDir, + "--provenance-file", + badPath, + "--access", + "public", + ); + expect(err).toContain("does not match the package"); + expect(exitCode).toBe(1); + expect(putBody).toBeNull(); + } + + // Subject match → attached under `_attachments["*.sigstore"]`. We need + // the tarball's SHA-512 for the bundle's subject, so pack first, then + // publish that exact tarball. + { + await using packProc = Bun.spawn({ + cmd: [bunExe(), "pm", "pack"], + cwd: packageDir, + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + const [packStderr, packExitCode] = await Promise.all([packProc.stderr.text(), packProc.exited]); + if (packExitCode !== 0) expect(packStderr).toBe(""); + expect(packExitCode).toBe(0); + const tarballPath = join(packageDir, "prov-pkg-6-2.0.0.tgz"); + const tarball = await Bun.file(tarballPath).arrayBuffer(); + const sha512 = Buffer.from(await crypto.subtle.digest("SHA-512", tarball)).toString("hex"); + + const goodBundle = makeBundle("pkg:npm/prov-pkg-6@2.0.0", sha512); + const goodPath = join(packageDir, "good.sigstore"); + const goodBundleJson = JSON.stringify(goodBundle); + await write(goodPath, goodBundleJson); + + const { out, err, exitCode } = await publish( + { ...bunEnv }, + packageDir, + tarballPath, + "--provenance-file", + goodPath, + "--access", + "public", + ); + expect(err).not.toContain("error:"); + expect(out + err).toContain("Attached provenance bundle"); + expect(exitCode).toBe(0); + expect(putBody).not.toBeNull(); + const att = putBody._attachments["prov-pkg-6-2.0.0.sigstore"]; + expect(att).toBeDefined(); + expect(att.content_type).toBe("application/vnd.dev.sigstore.bundle+json;version=0.2"); + expect(att.data).toBe(goodBundleJson); + expect(att.length).toBe(goodBundleJson.length); + } + }); + + test("--provenance and --provenance-file are mutually exclusive", async () => { + const { packageDir, packageJson } = await registry.createTestDir(); + using server = Bun.serve({ + port: 0, + fetch: () => new Response("{}", { status: 200 }), + }); + const base = `http://localhost:${server.port}`; + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + `[install]\ncache = false\nregistry = { url = "${base}", token = "tok" }\n`, + ), + write(packageJson, JSON.stringify({ name: "prov-pkg-7", version: "1.0.0" })), + write(join(packageDir, "x.sigstore"), "{}"), + ]); + const { err, exitCode } = await publish( + { ...bunEnv }, + packageDir, + "--provenance", + "--provenance-file", + join(packageDir, "x.sigstore"), + "--access", + "public", + ); + expect(err).toContain("mutually exclusive"); + expect(exitCode).toBe(1); + + // …but `publishConfig.provenance: false` + `--provenance-file` is not a + // conflict — npm checks `=== true`, not "is set". We should get past the + // exclusion check and fail on bundle validation instead. + await write( + packageJson, + JSON.stringify({ + name: "prov-pkg-7", + version: "1.0.0", + publishConfig: { provenance: false }, + }), + ); + const { err: err2, exitCode: exitCode2 } = await publish( + { ...bunEnv }, + packageDir, + "--provenance-file", + join(packageDir, "x.sigstore"), + "--access", + "public", + ); + expect(err2).not.toContain("mutually exclusive"); + expect(exitCode2).toBe(1); // fails on bundle parse/validation, not exclusion + }); +});