diff --git a/.github/workflows/spire.yml b/.github/workflows/spire.yml new file mode 100644 index 000000000..cb307d27e --- /dev/null +++ b/.github/workflows/spire.yml @@ -0,0 +1,112 @@ +name: SPIRE + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + spiffe: + name: Integration (Linux) + if: ${{ !startsWith(github.head_ref, 'dependabot/github_actions/') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: stable + + - name: Cache cargo registry + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-spire-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-spire- + ${{ runner.os }}-cargo- + + - name: Download SPIRE + run: | + set -euo pipefail + VERSION="1.9.6" + TARBALL="spire-${VERSION}-linux-amd64-musl.tar.gz" + curl -fsSL "https://github.com/spiffe/spire/releases/download/v${VERSION}/${TARBALL}" \ + -o /tmp/spire.tar.gz + mkdir -p /tmp/spire-bin + tar -xzf /tmp/spire.tar.gz -C /tmp/spire-bin --strip-components=1 + echo "/tmp/spire-bin/bin" >> "$GITHUB_PATH" + + - name: Start SPIRE server + run: | + set -euo pipefail + mkdir -p /tmp/nono-test-spire/data/server + spire-server run -config testdata/spire/server.conf & + for _ in $(seq 1 30); do + [ -S /tmp/nono-test-spire/server.sock ] && break + sleep 0.5 + done + [ -S /tmp/nono-test-spire/server.sock ] || { echo "SPIRE server did not start"; exit 1; } + + - name: Start SPIRE agent + run: | + set -euo pipefail + mkdir -p /tmp/nono-test-spire/data/agent + + # Generate join token and mask it immediately so it never appears in logs. + RAW=$(spire-server token generate \ + -socketPath /tmp/nono-test-spire/server.sock \ + -spiffeID spiffe://test.nono/agent) + TOKEN=$(echo "$RAW" | grep -oP '(?<=Token: )\S+') + echo "::add-mask::$TOKEN" + + spire-agent run -config testdata/spire/agent.conf -joinToken "$TOKEN" & + + for _ in $(seq 1 30); do + [ -S /tmp/nono-test-spire/agent.sock ] && break + sleep 0.5 + done + [ -S /tmp/nono-test-spire/agent.sock ] || { echo "SPIRE agent did not start"; exit 1; } + + - name: Register workload entry + run: | + set -euo pipefail + spire-server entry create \ + -socketPath /tmp/nono-test-spire/server.sock \ + -spiffeID "spiffe://test.nono/nono-proxy" \ + -parentID "spiffe://test.nono/agent" \ + -selector "unix:uid:$(id -u)" + + # Wait for the agent to sync the entry before tests start. + for _ in $(seq 1 20); do + spire-agent api fetch jwt \ + -socketPath /tmp/nono-test-spire/agent.sock \ + -audience warmup 2>/dev/null | grep -q "spiffe://" && break + sleep 0.5 + done + + - name: Run SPIFFE proxy-level integration tests + env: + SPIRE_AGENT_SOCKET: /tmp/nono-test-spire/agent.sock + SPIRE_TRUST_DOMAIN: test.nono + SPIRE_WORKLOAD_SPIFFE_ID: spiffe://test.nono/nono-proxy + run: cargo test -p nono-proxy --test spiffe_integration -- --nocapture + + - name: Run SPIFFE binary-level integration tests + env: + SPIRE_AGENT_SOCKET: /tmp/nono-test-spire/agent.sock + SPIRE_TRUST_DOMAIN: test.nono + SPIRE_WORKLOAD_SPIFFE_ID: spiffe://test.nono/nono-proxy + run: cargo test -p nono-cli --test spiffe_run -- --nocapture diff --git a/.gitignore b/.gitignore index 844ffcf89..bcb7339f6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ # Claude state and config .claude/ -# misc -proj/ +# Demo assets and runtime (not part of the distributed codebase) +demo/ .worktrees .nono diagnostics.json diff --git a/Cargo.lock b/Cargo.lock index 54d54eb62..317329874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,9 +153,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -379,9 +379,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.18" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" +checksum = "47712fde1909402600ccfbb26e47d482d2e58bb9e9e603d9f17e67cc435a6319" dependencies = [ "aws-credential-types", "aws-runtime", @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -446,9 +446,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.7.5" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" +checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -462,7 +462,7 @@ dependencies = [ "bytes-utils", "fastrand", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "percent-encoding", "pin-project-lite", "tracing", @@ -471,9 +471,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.102.0" +version = "1.103.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" +checksum = "0469f435f645ad2162cfb463b15bde37115966ee3acf2d87fb4871ee309b8401" dependencies = [ "arc-swap", "aws-credential-types", @@ -484,6 +484,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -496,9 +497,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.104.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" +checksum = "085faefb253f770655e162b9304321e62a1e71adf7f019ee1f4454228a377b3a" dependencies = [ "arc-swap", "aws-credential-types", @@ -509,6 +510,7 @@ dependencies = [ "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", @@ -521,9 +523,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.107.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" +checksum = "3c72b08911d8128dd360fe1b22a9fec0fa8b552dde8ec828dcf20ef5ec974e9f" dependencies = [ "arc-swap", "aws-credential-types", @@ -535,6 +537,7 @@ dependencies = [ "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", @@ -547,9 +550,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.4.5" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -569,9 +572,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -580,9 +583,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.6" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -591,7 +594,7 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "percent-encoding", "pin-project-lite", @@ -601,9 +604,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.13" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -625,9 +628,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.7" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -636,18 +639,18 @@ dependencies = [ [[package]] name = "aws-smithy-observability" -version = "0.2.6" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.15" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +checksum = "dd22a6ba36e3f113cb8d5b3d1fe0ed31c76ee608ef63322d753bb8d2c9479e77" dependencies = [ "aws-smithy-types", "urlencoding", @@ -655,9 +658,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.11.3" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -671,7 +674,7 @@ dependencies = [ "http 0.2.12", "http 1.4.2", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "pin-utils", @@ -681,9 +684,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -699,9 +702,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", @@ -710,9 +713,9 @@ dependencies = [ [[package]] name = "aws-smithy-schema" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -721,9 +724,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.5.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", "bytes", @@ -731,7 +734,7 @@ dependencies = [ "http 0.2.12", "http 1.4.2", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "itoa", "num-integer", @@ -744,18 +747,21 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.15" +version = "0.61.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.16" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -872,9 +878,9 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "serde_core", @@ -944,9 +950,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -1134,7 +1140,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1250,7 +1256,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", - "itertools", + "itertools 0.13.0", "num-traits", "oorandom", "page_size", @@ -1268,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools", + "itertools 0.13.0", ] [[package]] @@ -1285,9 +1291,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1304,9 +1310,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1350,9 +1356,9 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -1379,9 +1385,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1389,12 +1395,11 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ "defmt-parser", - "proc-macro-error2", "proc-macro2", "quote", "syn", @@ -1503,7 +1508,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1596,7 +1601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1707,6 +1712,20 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1825,16 +1844,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -1969,9 +1990,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http 1.4.2", @@ -1979,14 +2000,14 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "pin-project-lite", ] @@ -2023,7 +2044,7 @@ dependencies = [ "futures-core", "h2", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "httparse", "httpdate", "itoa", @@ -2051,6 +2072,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2062,7 +2096,7 @@ dependencies = [ "futures-channel", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "hyper", "ipnet", "libc", @@ -2203,9 +2237,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +checksum = "d4ffa3a0547a138e59ddd6fa3b7c672ed47e6ad6a3cd177984ff1116aa5ba742" dependencies = [ "crossbeam-deque", "globset", @@ -2277,6 +2311,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2285,9 +2328,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" dependencies = [ "defmt", "jiff-static", @@ -2301,9 +2344,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" dependencies = [ "proc-macro2", "quote", @@ -2312,9 +2355,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] name = "jiff-tzdb-platform" @@ -2341,6 +2384,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[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 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "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", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2371,11 +2444,11 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -2401,9 +2474,9 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.46.7" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185abd1edcef44ac028ec6588ad1360155fbd0a8baa20243a28f5b2a05537a48" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" dependencies = [ "ahash", "bytecount", @@ -2414,12 +2487,12 @@ dependencies = [ "getrandom 0.3.4", "idna", "itoa", + "jsonschema-regex", "num-cmp", "num-traits", "percent-encoding", "referencing", "regex", - "regex-syntax", "reqwest", "rustls", "serde", @@ -2428,6 +2501,15 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + [[package]] name = "keyring" version = "3.6.3" @@ -2477,9 +2559,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -2528,9 +2610,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2565,9 +2647,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2676,7 +2758,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "tracing", "tracing-subscriber", "ureq", @@ -2725,6 +2807,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "spiffe", "subtle", "tempfile", "thiserror 2.0.18", @@ -2746,7 +2829,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2765,9 +2848,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2805,11 +2888,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2863,9 +2945,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.3.6" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ "is-wsl", "libc", @@ -2963,6 +3045,26 @@ 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", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3064,28 +3166,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3105,7 +3185,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags 2.13.0", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -3114,6 +3194,38 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3142,15 +3254,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -3164,16 +3277,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3199,9 +3312,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3210,9 +3323,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3273,6 +3386,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -3338,9 +3460,9 @@ dependencies = [ [[package]] name = "referencing" -version = "0.46.7" +version = "0.46.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c335a4796e6081e384712c269f29ed8d2f6613e7afdd54a8467bb7d48f99859f" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" dependencies = [ "ahash", "fluent-uri", @@ -3367,9 +3489,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -3411,7 +3533,7 @@ dependencies = [ "futures-util", "h2", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper", "hyper-rustls", @@ -3423,7 +3545,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.7.0", "serde", "serde_json", "serde_urlencoded", @@ -3455,9 +3577,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3487,14 +3609,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "aws-lc-rs", "log", @@ -3536,7 +3658,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -3546,7 +3668,28 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] @@ -3569,9 +3712,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -3593,9 +3736,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "ryu-js" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" [[package]] name = "same-file" @@ -3667,7 +3810,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "serde", "sha2 0.10.9", "zbus", @@ -3844,9 +3987,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4039,7 +4182,7 @@ dependencies = [ "ambient-id", "base64", "open", - "rand 0.9.4", + "rand 0.9.5", "reqwest", "serde", "serde_json", @@ -4160,7 +4303,7 @@ dependencies = [ "der", "hex", "jiff", - "rand 0.9.4", + "rand 0.9.5", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -4186,7 +4329,7 @@ dependencies = [ "der", "hex", "jiff", - "rand 0.9.4", + "rand 0.9.5", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -4280,9 +4423,25 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "similar" @@ -4307,14 +4466,40 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "spiffe" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b316e388514701f5c8aac295909a66f72f738b8ac632814d62692309877742" +dependencies = [ + "arc-swap", + "base64ct", + "fastrand", + "futures", + "hyper-util", + "prost", + "prost-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-util", + "tonic", + "tonic-prost", + "tower", + "url", + "zeroize", +] + [[package]] name = "spki" version = "0.7.3" @@ -4351,9 +4536,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4390,7 +4575,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4435,9 +4620,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4494,9 +4679,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4566,6 +4751,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4596,9 +4792,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -4606,7 +4802,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4629,14 +4825,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4645,14 +4841,53 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "h2", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] [[package]] name = "tower" @@ -4662,11 +4897,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4681,7 +4920,7 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "tokio", @@ -4889,7 +5128,7 @@ dependencies = [ "percent-encoding", "rustls", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.6.2", "ureq-proto", "utf8-zero", "webpki-roots", @@ -4945,9 +5184,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -5140,9 +5379,9 @@ dependencies = [ [[package]] name = "which" -version = "8.0.4" +version = "8.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" dependencies = [ "libc", ] @@ -5169,7 +5408,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5476,9 +5715,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5611,7 +5850,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde_repr", "sha1", @@ -5651,18 +5890,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -5746,9 +5985,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zvariant" diff --git a/Makefile b/Makefile index e843b49b1..ca14abb5d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ # make check Run clippy and format check # make release Build release binaries -.PHONY: all build build-lib build-cli build-ffi build-arm64 test test-lib test-cli test-ffi check clippy fmt clean install audit help +.PHONY: all build build-lib build-cli build-ffi build-arm64 test test-lib test-cli test-ffi test-spiffe check clippy fmt clean install audit help # Default target all: build @@ -54,6 +54,9 @@ test-ffi: test-doc: cargo test --doc +test-spiffe: + bash scripts/spire-test.sh + # Check targets (lint + format) check: clippy fmt-check @@ -139,6 +142,7 @@ help: @echo " make test-cli Run CLI tests only" @echo " make test-ffi Run C FFI tests only" @echo " make test-doc Run doc tests only" + @echo " make test-spiffe Run SPIFFE/SPIRE integration tests (downloads SPIRE if needed)" @echo "" @echo "Check:" @echo " make check Run clippy and format check" diff --git a/crates/nono-cli/src/audit_integrity.rs b/crates/nono-cli/src/audit_integrity.rs index b69333afc..6c7512d7d 100644 --- a/crates/nono-cli/src/audit_integrity.rs +++ b/crates/nono-cli/src/audit_integrity.rs @@ -175,6 +175,7 @@ mod tests { path: Some("/v1/chat".to_string()), status: Some(403), reason: Some("policy".to_string()), + spiffe_context: None, }) .unwrap(); recorder diff --git a/crates/nono-cli/src/audit_ledger.rs b/crates/nono-cli/src/audit_ledger.rs index 78e2b59d5..8719ce5d8 100644 --- a/crates/nono-cli/src/audit_ledger.rs +++ b/crates/nono-cli/src/audit_ledger.rs @@ -232,6 +232,7 @@ mod tests { path: Some("/".to_string()), status: Some(200), reason: None, + spiffe_context: None, }], audit_event_count: 9, audit_integrity: Some(AuditIntegritySummary { diff --git a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs index 2c8e69e91..dddfdc455 100644 --- a/crates/nono-cli/src/exec_strategy/supervisor_linux.rs +++ b/crates/nono-cli/src/exec_strategy/supervisor_linux.rs @@ -1258,6 +1258,7 @@ fn record_network_audit_denial( credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: None, target, upstream: None, port: if sockaddr.port == 0 { diff --git a/crates/nono-cli/src/network_policy.rs b/crates/nono-cli/src/network_policy.rs index bfdf22ea8..c1c37820e 100644 --- a/crates/nono-cli/src/network_policy.rs +++ b/crates/nono-cli/src/network_policy.rs @@ -270,6 +270,7 @@ pub fn resolve_credentials( .transpose()?, oauth2, aws_auth: cred.aws_auth.clone(), + spiffe: cred.spiffe.clone(), }); } else if let Some(cred) = policy.credentials.get(name) { // Validate env_var against dangerous variable blocklist @@ -303,6 +304,7 @@ pub fn resolve_credentials( tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }); } // We already validated existence above, so this else branch won't be hit @@ -463,6 +465,7 @@ pub fn partition_allow_domain( tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }); } } @@ -618,6 +621,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -660,6 +664,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -698,6 +703,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -746,6 +752,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -834,6 +841,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -869,6 +877,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -904,6 +913,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -944,6 +954,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -1078,6 +1089,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -1153,6 +1165,8 @@ mod tests { client_id: "my-client".to_string(), client_secret: "env://CLIENT_SECRET".to_string(), scope: "api.read".to_string(), + client_assertion: None, + extra_params: Default::default(), }), inject_mode: InjectMode::Header, inject_header: "Authorization".to_string(), @@ -1168,6 +1182,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -1218,6 +1233,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -1374,6 +1390,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -1415,6 +1432,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); custom.insert( @@ -1437,6 +1455,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); diff --git a/crates/nono-cli/src/profile/mod.rs b/crates/nono-cli/src/profile/mod.rs index 87daabfed..6a9ed4955 100644 --- a/crates/nono-cli/src/profile/mod.rs +++ b/crates/nono-cli/src/profile/mod.rs @@ -369,6 +369,10 @@ pub struct CustomCredentialDef { /// credential chain). Mutually exclusive with `credential_key` and `auth`. #[serde(default)] pub aws_auth: Option, + + /// SPIFFE/SPIRE Workload API auth. Mutually exclusive with `credential_key`, `auth`, and `aws_auth`. + #[serde(default)] + pub spiffe: Option, } /// Host-side source that materializes a proxy credential for `cmd://`. @@ -476,7 +480,7 @@ fn default_inject_header() -> String { "Authorization".to_string() } -/// Check if a character is a valid HTTP token character per RFC 7230. +/// Check if a character is a valid HTTP header token character. fn is_http_token_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!( @@ -601,14 +605,38 @@ fn validate_custom_credential(name: &str, cred: &CustomCredentialDef) -> Result< ))); } - // At least one of credential_key, auth, or aws_auth must be set - if cred.credential_key.is_none() && cred.auth.is_none() && cred.aws_auth.is_none() { + // Mutual exclusion: spiffe is incompatible with credential_key, auth (oauth2), and aws_auth. + if cred.spiffe.is_some() + && (cred.credential_key.is_some() || cred.auth.is_some() || cred.aws_auth.is_some()) + { return Err(NonoError::ProfileParse(format!( - "custom credential '{}' must have either 'credential_key', 'auth', or 'aws_auth' set", + "custom credential '{}' has 'spiffe' set together with 'credential_key', 'auth' \ + (oauth2), or 'aws_auth'; spiffe is mutually exclusive with all other auth fields", name ))); } + // At least one auth mechanism must be set + if cred.credential_key.is_none() + && cred.auth.is_none() + && cred.aws_auth.is_none() + && cred.spiffe.is_none() + { + return Err(NonoError::ProfileParse(format!( + "custom credential '{}' must have either 'credential_key', 'auth', 'aws_auth', \ + or 'spiffe' set", + name + ))); + } + + // Validate inject_header for SPIFFE routes (credential_key has its own validate_header_mode()). + if let Some(nono_proxy::config::SpiffeAuthConfig::Jwt { + ref inject_header, .. + }) = cred.spiffe + { + validate_header_name(name, inject_header)?; + } + // Validate OAuth2 auth if present if let Some(ref auth) = cred.auth { validate_oauth2_auth(name, auth)?; @@ -820,20 +848,42 @@ fn validate_oauth2_auth(name: &str, auth: &OAuth2Config) -> Result<()> { // Validate token_url — same rules as upstream URL (HTTPS or loopback HTTP) validate_upstream_url(&auth.token_url, &format!("{}/auth.token_url", name))?; - // client_id must not be empty - if auth.client_id.is_empty() { - return Err(NonoError::ProfileParse(format!( - "auth.client_id for custom credential '{}' cannot be empty", - name - ))); - } - - // client_secret must not be empty - if auth.client_secret.is_empty() { - return Err(NonoError::ProfileParse(format!( - "auth.client_secret for custom credential '{}' cannot be empty", - name - ))); + // When using client_assertion, client_id/client_secret are not required, + // but the assertion config itself must have valid fields. + if let Some(ref assertion) = auth.client_assertion { + match assertion { + nono_proxy::config::ClientAssertionConfig::SpiffeJwt { + workload_api_socket, + audience, + .. + } => { + if workload_api_socket.is_empty() { + return Err(NonoError::ProfileParse(format!( + "auth.client_assertion.workload_api_socket for custom credential '{}' cannot be empty", + name + ))); + } + if audience.is_empty() { + return Err(NonoError::ProfileParse(format!( + "auth.client_assertion.audience for custom credential '{}' cannot be empty", + name + ))); + } + } + } + } else { + if auth.client_id.is_empty() { + return Err(NonoError::ProfileParse(format!( + "auth.client_id for custom credential '{}' cannot be empty", + name + ))); + } + if auth.client_secret.is_empty() { + return Err(NonoError::ProfileParse(format!( + "auth.client_secret for custom credential '{}' cannot be empty", + name + ))); + } } Ok(()) @@ -885,8 +935,26 @@ fn validate_aws_auth(name: &str, aws: &nono_proxy::config::AwsAuthConfig) -> Res } /// Validate header injection mode fields. +/// Validate a single header name: non-empty, valid HTTP token characters only. +fn validate_header_name(cred_name: &str, header: &str) -> Result<()> { + if header.is_empty() { + return Err(NonoError::ProfileParse(format!( + "inject_header for custom credential '{}' cannot be empty", + cred_name + ))); + } + if !header.chars().all(is_http_token_char) { + return Err(NonoError::ProfileParse(format!( + "inject_header '{}' for custom credential '{}' contains invalid characters; \ + header names must be valid HTTP tokens (alphanumeric and !#$%&'*+-.^_`|~)", + header, cred_name + ))); + } + Ok(()) +} + fn validate_header_mode(name: &str, cred: &CustomCredentialDef) -> Result<()> { - // Validate inject_header (RFC 7230 token) + // Validate inject_header if cred.inject_header.is_empty() { return Err(NonoError::ProfileParse(format!( "inject_header for custom credential '{}' cannot be empty", @@ -5001,7 +5069,7 @@ mod tests { } // ============================================================================ - // is_http_token_char tests (RFC 7230) + // is_http_token_char tests // ============================================================================ #[test] @@ -5014,7 +5082,7 @@ mod tests { #[test] fn test_http_token_char_special_chars() { - // RFC 7230 tchar: !#$%&'*+-.^_`|~ + // valid special chars in header token names: !#$%&'*+-.^_`|~ for c in "!#$%&'*+-.^_`|~".chars() { assert!(is_http_token_char(c), "Expected '{}' to be valid tchar", c); } @@ -5035,7 +5103,7 @@ mod tests { // Custom credential validation integration tests // // These test the full validation chain including: - // - inject_header (RFC 7230 token validation) + // - inject_header (token validation) // - credential_format (CRLF injection prevention) // - credential_key (alphanumeric + underscore) // - upstream URL (HTTPS required, HTTP only for loopback) @@ -5060,6 +5128,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, } } @@ -5202,6 +5271,57 @@ mod tests { assert!(validate_custom_credential("local", &cred).is_ok()); } + #[test] + fn test_validate_custom_credential_spiffe_only_valid() { + let cred = CustomCredentialDef { + upstream: "http://127.0.0.1:8080".to_string(), + credential_key: None, + auth: None, + inject_mode: InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: None, + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + endpoint_policy: None, + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + aws_auth: None, + spiffe: Some(nono_proxy::config::SpiffeAuthConfig::Jwt { + workload_api_socket: "/run/spire/agent/api.sock".to_string(), + audience: vec!["test-audience".to_string()], + inject_header: "Authorization".to_string(), + credential_format: None, + svid_hint: None, + }), + }; + assert!(validate_custom_credential("testapi", &cred).is_ok()); + } + + #[test] + fn test_validate_custom_credential_spiffe_with_credential_key_rejected() { + let mut cred = header_cred_builder(); + cred.spiffe = Some(nono_proxy::config::SpiffeAuthConfig::Jwt { + workload_api_socket: "/run/spire/agent/api.sock".to_string(), + audience: vec!["test-audience".to_string()], + inject_header: "Authorization".to_string(), + credential_format: None, + svid_hint: None, + }); + let result = validate_custom_credential("test", &cred); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("spiffe is mutually exclusive") + ); + } + // ============================================================================ // Injection Mode Validation Tests // ============================================================================ @@ -5226,6 +5346,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; assert!(validate_custom_credential("telegram", &cred).is_ok()); } @@ -5250,6 +5371,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("telegram", &cred); let err = result.expect_err("missing path_pattern should be rejected"); @@ -5276,6 +5398,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("telegram", &cred); let err = result.expect_err("pattern without {} should be rejected"); @@ -5302,6 +5425,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; assert!(validate_custom_credential("telegram", &cred).is_ok()); } @@ -5326,6 +5450,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("telegram", &cred); let err = result.expect_err("replacement without {} should be rejected"); @@ -5352,6 +5477,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; assert!(validate_custom_credential("google_maps", &cred).is_ok()); } @@ -5376,6 +5502,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("google_maps", &cred); let err = result.expect_err("missing query_param_name should be rejected"); @@ -5402,6 +5529,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("google_maps", &cred); let err = result.expect_err("empty query_param_name should be rejected"); @@ -5428,6 +5556,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; // BasicAuth mode doesn't require additional fields // Credential value is expected to be "username:password" format @@ -5616,6 +5745,8 @@ mod tests { client_id: "my-client".to_string(), client_secret: "env://CLIENT_SECRET".to_string(), scope: "read write".to_string(), + client_assertion: None, + extra_params: Default::default(), }), inject_mode: InjectMode::Header, inject_header: "Authorization".to_string(), @@ -5631,6 +5762,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, } } @@ -5667,6 +5799,8 @@ mod tests { client_id: "my-client".to_string(), client_secret: "env://SECRET".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }); let result = validate_custom_credential("test", &cred); let err = result.expect_err("HTTP to remote token_url should be rejected"); @@ -5681,6 +5815,8 @@ mod tests { client_id: "my-client".to_string(), client_secret: "env://SECRET".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }); assert!(validate_custom_credential("test", &cred).is_ok()); } @@ -5693,6 +5829,8 @@ mod tests { client_id: "".to_string(), client_secret: "env://SECRET".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }); let result = validate_custom_credential("test", &cred); let err = result.expect_err("empty client_id should be rejected"); @@ -5708,6 +5846,8 @@ mod tests { client_id: "my-client".to_string(), client_secret: "".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }); let result = validate_custom_credential("test", &cred); let err = result.expect_err("empty client_secret should be rejected"); @@ -5723,6 +5863,8 @@ mod tests { client_id: "my-client".to_string(), client_secret: "env://SECRET".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }); assert!(validate_custom_credential("test", &cred).is_ok()); } @@ -6217,6 +6359,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -6241,6 +6384,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -6383,6 +6527,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -6407,6 +6552,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -8034,6 +8180,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; assert!( validate_custom_credential("example", &cred).is_ok(), @@ -8061,6 +8208,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("example", &cred); let err = result.expect_err("file:// URI without env_var should be rejected"); @@ -8091,6 +8239,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("example", &cred); let err = result.expect_err("file:// URI with relative path should be rejected"); @@ -8121,6 +8270,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("example", &cred); assert!( @@ -8183,6 +8333,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; assert!(validate_custom_credential("example", &cred).is_ok()); } @@ -8591,6 +8742,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }; let result = validate_custom_credential("example", &cred); assert!(result.is_err(), "env://LD_PRELOAD should be rejected"); @@ -9393,6 +9545,7 @@ mod tests { tls_ca: None, tls_client_cert: None, tls_client_key: None, + spiffe: None, } } @@ -9456,6 +9609,8 @@ mod tests { client_id: "cid".to_string(), client_secret: "env://SECRET".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }), ..aws_auth_cred_builder() }; @@ -9475,6 +9630,7 @@ mod tests { credential_key: None, auth: None, aws_auth: None, + spiffe: None, ..aws_auth_cred_builder() }; let result = validate_custom_credential("bedrock", &cred); diff --git a/crates/nono-cli/src/proxy_runtime.rs b/crates/nono-cli/src/proxy_runtime.rs index c38517db6..6cb31b98e 100644 --- a/crates/nono-cli/src/proxy_runtime.rs +++ b/crates/nono-cli/src/proxy_runtime.rs @@ -20,7 +20,7 @@ use nono_proxy::config::{ }; use regex::Regex; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{ @@ -497,7 +497,17 @@ impl ProxyCredentialCaptureBackend { std::thread::sleep(Duration::from_millis(25)); } - let output = child.wait_with_output().map_err(|err| { + const STDOUT_CAP: u64 = 64 * 1024; + const STDERR_CAP: u64 = 4 * 1024; + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + if let Some(out) = child.stdout.take() { + out.take(STDOUT_CAP + 1).read_to_end(&mut stdout_buf).ok(); + } + if let Some(err) = child.stderr.take() { + err.take(STDERR_CAP).read_to_end(&mut stderr_buf).ok(); + } + let status = child.wait().map_err(|err| { self.capture_error( entry, CaptureErrorDetails::new("collect_failed", start.elapsed()).reason(format!( @@ -505,6 +515,21 @@ impl ProxyCredentialCaptureBackend { )), ) })?; + if stdout_buf.len() > STDOUT_CAP as usize { + stdout_buf.truncate(STDOUT_CAP as usize); + return Err(self.capture_error( + entry, + CaptureErrorDetails::new("output_too_large", start.elapsed()).reason(format!( + "credential capture command stdout exceeded {} bytes", + STDOUT_CAP + )), + )); + } + let output = std::process::Output { + status, + stdout: stdout_buf, + stderr: stderr_buf, + }; let status_code = output.status.code(); if !output.status.success() { let stderr_redacted = redacted_stderr(&output.stderr, &self.redaction_policy); @@ -1859,6 +1884,7 @@ fn collect_tool_sandbox_proxy_grants( }) .transpose()?, aws_auth: None, + spiffe: None, }; if let Some(existing) = custom_credentials.get(&grant.name) { @@ -1955,20 +1981,44 @@ fn load_command_credential_source( std::thread::sleep(Duration::from_millis(25)); } - let output = child.wait_with_output().map_err(|err| { + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); + if let Some(out) = child.stdout.take() { + out.take(64 * 1024 + 1).read_to_end(&mut stdout_buf).ok(); + } + if let Some(err) = child.stderr.take() { + err.take(4 * 1024).read_to_end(&mut stderr_buf).ok(); + } + let status = child.wait().map_err(|err| { NonoError::SandboxInit(format!( "failed to collect supervisor credential source '{command}': {err}" )) })?; - if !output.status.success() { + if stdout_buf.len() > 64 * 1024 { return Err(NonoError::SandboxInit(format!( - "supervisor credential source '{command}' failed with exit code {}", - output - .status - .code() - .map_or_else(|| "unknown".to_string(), |code| code.to_string()) + "supervisor credential source '{command}' stdout exceeded 64 KiB" ))); } + let output = std::process::Output { + status, + stdout: stdout_buf, + stderr: stderr_buf, + }; + if !output.status.success() { + let code = output + .status + .code() + .map_or_else(|| "unknown".to_string(), |c| c.to_string()); + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + return Err(NonoError::SandboxInit(if stderr.is_empty() { + format!("supervisor credential source '{command}' failed with exit code {code}") + } else { + format!( + "supervisor credential source '{command}' failed with exit code {code}: {stderr}" + ) + })); + } let value = String::from_utf8(output.stdout).map_err(|err| { NonoError::SandboxInit(format!( "supervisor credential source '{command}' produced non-UTF-8 stdout: {err}" @@ -2302,6 +2352,31 @@ pub(crate) fn build_proxy_config_from_flags( } } routes.extend(endpoint_routes); + // Credential route upstreams must also be reachable by the proxy filter + // when the child curls the real upstream URL (CONNECT + TLS intercept). + // Use url::Url::parse so credentials embedded in the URL (user:pass@host) + // don't end up in the allowlist as a garbled "user:pass@host:port" string. + for route in &routes { + if let Ok(parsed) = url::Url::parse(&route.upstream) + && let Some(host) = parsed.host_str() + { + let default_port = if parsed.scheme() == "https" { + 443u16 + } else { + 80u16 + }; + let port = parsed.port().unwrap_or(default_port); + let host_port = format!("{}:{}", host, port); + if !plain_hosts.iter().any(|h| h == &host_port) { + plain_hosts.push(host_port); + } + // HostFilter matches on hostname only; also allow the bare host so + // CONNECT targets like localhost:19871 pass the filter as "localhost". + if !plain_hosts.iter().any(|h| h == host) { + plain_hosts.push(host.to_string()); + } + } + } resolved.routes = routes; let deny_domain = proxy @@ -2416,6 +2491,7 @@ fn synthesize_credential_provider_proxy_config( tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }); consumers_by_provider .entry(route.provider.clone()) @@ -2514,6 +2590,98 @@ impl nono_proxy::NonceResolver for TokenBrokerNonceResolver { } } +/// Attempt to canonicalize `p`, falling back to the original path if the +/// filesystem entry does not exist yet (e.g. a SPIRE socket that is not +/// currently running). On macOS `/var` is a symlink to `/private/var`; without +/// canonicalization the component-safe `starts_with` check would miss matches +/// between `/var/run/spire.sock` and `/private/var/run/spire.sock`. +fn try_canonicalize(p: &std::path::Path) -> std::path::PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +/// Deny the sandboxed child direct access to SPIRE Workload API sockets used +/// by SPIFFE-authenticated proxy routes. +/// +/// The proxy supervisor holds the SPIFFE identity and mediates all SVIDs. The +/// sandboxed child must not be able to reach the socket independently — doing +/// so would let it obtain its own SVIDs and bypass the proxy's auth boundary. +/// +/// Emits a Seatbelt `(deny network-outbound (path ...))` rule on macOS. +/// On Linux, Landlock has no deny-within-allow semantics, so we rely on the +/// unix_socket allowlist being absent (the child never has the socket granted). +/// +/// Hard errors if a `unix_socket` capability already grants the socket — +/// that combination would silently undermine the isolation guarantee. +fn enforce_spiffe_socket_isolation( + proxy_config: &nono_proxy::config::ProxyConfig, + caps: &mut CapabilitySet, +) -> Result<()> { + use nono_proxy::config::SpiffeAuthConfig; + use std::collections::BTreeSet; + + // Collect unique SPIRE socket paths from all SPIFFE routes. + let mut socket_paths: BTreeSet = BTreeSet::new(); + for route in &proxy_config.routes { + if let Some(SpiffeAuthConfig::Jwt { + workload_api_socket, + .. + }) = &route.spiffe + { + socket_paths.insert(workload_api_socket.clone()); + } + } + + if socket_paths.is_empty() { + return Ok(()); + } + + // Hard error if any SPIRE socket is also in the unix_socket grant list. + // Using Path::starts_with for component-safe comparison. + let unix_caps = caps.unix_socket_capabilities(); + for socket_path in &socket_paths { + let spire = try_canonicalize(std::path::Path::new(socket_path)); + for uc in unix_caps { + let granted = try_canonicalize(uc.resolved.as_path()); + if spire == granted || spire.starts_with(&granted) || granted.starts_with(&spire) { + return Err(NonoError::ConfigParse(format!( + "SPIFFE route uses Workload API socket '{}' which is also \ + granted via unix_socket capability '{}'; this would allow \ + the sandboxed process to obtain SVIDs directly and bypass \ + the proxy auth boundary — remove the unix_socket grant", + socket_path, + uc.resolved.display() + ))); + } + } + } + + // On macOS, emit an explicit Seatbelt deny rule so the child cannot reach + // the socket even if a future capability accidentally grants the parent dir. + #[cfg(target_os = "macos")] + for socket_path in &socket_paths { + let escaped = crate::policy::escape_seatbelt_path(socket_path)?; + caps.add_platform_rule(format!("(deny network-outbound (path \"{}\"))", escaped))?; + debug!( + "SPIFFE: emitted Seatbelt deny for SPIRE socket {}", + socket_path + ); + } + + #[cfg(not(target_os = "macos"))] + { + // On Linux, log a debug note. The child never has the socket granted, + // so no active deny is needed. The conflict check above is the guard. + for socket_path in &socket_paths { + debug!( + "SPIFFE: SPIRE socket {} is not in unix_socket grants (Linux, no deny needed)", + socket_path + ); + } + } + + Ok(()) +} + /// Wire up TLS interception on a `ProxyConfig`: pick a session-scoped /// directory for the ephemeral CA bundle, merge any parent `SSL_CERT_FILE` /// so corporate trust survives our env-var override, and (on macOS) load a @@ -2656,6 +2824,15 @@ pub(crate) fn start_proxy_runtime( bind_ports: proxy.allow_bind_ports.clone(), }); + // SPIFFE/SPIRE hardening: deny the sandboxed child direct access to the + // SPIRE Workload API socket. The proxy holds the SPIFFE identity; the + // child must not be able to obtain SVIDs independently. This prevents a + // compromised agent from escalating its own identity. + // + // Hard error if the user has explicitly granted the socket via unix_socket + // caps — that combination undermines the isolation guarantee. + enforce_spiffe_socket_isolation(&proxy_config, caps)?; + // Grant the sandboxed child a read capability on the ephemeral // trust bundle so `SSL_CERT_FILE` etc. are actually openable after // the sandbox is applied. Only when interception is active. @@ -3096,6 +3273,7 @@ mod tests { tls_client_cert: None, tls_client_key: None, aws_auth: None, + spiffe: None, }, ); @@ -3435,6 +3613,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }); let credential_env_vars = vec![ ( diff --git a/crates/nono-cli/tests/spiffe_run.rs b/crates/nono-cli/tests/spiffe_run.rs new file mode 100644 index 000000000..94f86c3a3 --- /dev/null +++ b/crates/nono-cli/tests/spiffe_run.rs @@ -0,0 +1,285 @@ +//! End-to-end SPIFFE/SPIRE tests using the real `nono` binary. +//! +//! These tests only run when `SPIRE_AGENT_SOCKET` is set (the CI job sets it). +//! Without it every test returns immediately, so they are safe to include in +//! the normal test suite without requiring a running SPIRE agent. +//! +//! Each test: +//! 1. Binds a mock HTTP server on a random loopback port. +//! 2. Writes a hermetic profile pointing the SPIFFE credential at that port. +//! 3. Runs `nono run -- curl http://127.0.0.1:/` through the real binary. +//! 4. Asserts the mock server received the expected injected credential. +//! +//! Because the upstream is on loopback, nono removes it from NO_PROXY so curl +//! routes through the nono proxy, which fetches the SVID and injects the header. +#![allow(clippy::unwrap_used)] + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::{Arc, Mutex}; +use std::thread; + +// ─── Helpers shared with other integration tests ───────────────────────────── + +fn nono_bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_nono")) +} + +fn setup_isolated_home(prefix: &str) -> (tempfile::TempDir, PathBuf, PathBuf) { + let temp_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("test-artifacts"); + fs::create_dir_all(&temp_root).expect("create test-artifacts root"); + let tmp = tempfile::Builder::new() + .prefix(&format!("nono-spiffe-{prefix}-")) + .tempdir_in(&temp_root) + .expect("create tempdir"); + let home = tmp.path().join("home"); + let workspace = tmp.path().join("workspace"); + fs::create_dir_all(home.join(".config")).expect("create .config dir"); + fs::create_dir_all(home.join(".local").join("state")).expect("create state dir"); + fs::create_dir_all(&workspace).expect("create workspace dir"); + (tmp, home, workspace) +} + +fn run_nono(args: &[&str], home: &Path, cwd: &Path) -> Output { + nono_bin() + .args(args) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("XDG_STATE_HOME", home.join(".local").join("state")) + .env("NONO_NO_SAVE_PROMPT", "1") + .env_remove("NONO_DETACHED_LAUNCH") + .current_dir(cwd) + .output() + .expect("failed to run nono") +} + +fn write_profile(home: &Path, name: &str, json: &str) -> PathBuf { + let path = home.join(format!("{name}.json")); + fs::write(&path, json).expect("write profile"); + path +} + +/// Returns the SPIRE agent socket path, or `None` to skip the test. +fn live_socket() -> Option { + std::env::var("SPIRE_AGENT_SOCKET").ok() +} + +// ─── Mock HTTP server ───────────────────────────────────────────────────────── + +/// A minimal HTTP/1.1 server that accepts one connection, records request +/// headers, and returns 200 OK. Runs on a background thread. +struct MockHttpServer { + port: u16, + /// Headers captured from the first accepted request. + headers: Arc>>, +} + +impl MockHttpServer { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + let port = listener.local_addr().expect("local_addr").port(); + let headers: Arc>> = Arc::new(Mutex::new(Vec::new())); + let headers_bg = Arc::clone(&headers); + + thread::spawn(move || { + if let Ok((stream, _)) = listener.accept() { + let captured = read_request_headers(stream); + *headers_bg.lock().expect("lock") = captured; + } + }); + + Self { port, headers } + } + + /// Blocks until the background thread has captured request headers, or `timeout` elapses. + fn wait_for_headers(&self, timeout: std::time::Duration) -> Vec { + let deadline = std::time::Instant::now() + timeout; + loop { + { + let guard = self.headers.lock().expect("lock"); + if !guard.is_empty() { + return guard.clone(); + } + } + if std::time::Instant::now() > deadline { + return vec![]; + } + thread::sleep(std::time::Duration::from_millis(50)); + } + } +} + +fn read_request_headers(stream: TcpStream) -> Vec { + let mut reader = BufReader::new(&stream); + let mut headers = Vec::new(); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + let trimmed = line.trim_end_matches(['\r', '\n']).to_string(); + if trimmed.is_empty() { + break; // blank line = end of headers + } + headers.push(trimmed); + } + } + } + // Send a minimal HTTP response so curl exits cleanly. + let _ = (&stream).write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + headers +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +/// JWT-SVID: the nono proxy fetches a JWT-SVID and injects it as +/// `Authorization: Bearer ` into the upstream request. +#[test] +fn spiffe_jwt_credential_injected_end_to_end() { + let socket = match live_socket() { + Some(s) => s, + None => return, + }; + + let (_tmp, home, workspace) = setup_isolated_home("jwt-inject"); + let mock = MockHttpServer::start(); + let upstream = format!("http://127.0.0.1:{}", mock.port); + + // Credential name must be hyphen-free: it becomes MOCKAPI_BASE_URL / MOCKAPI_API_KEY + // in the child environment. The proxy routes by URL path prefix (/mockapi/...) and + // strips it before forwarding to the upstream. + let profile_path = write_profile( + &home, + "spiffe-jwt", + &format!( + r#"{{ + "meta": {{ "name": "spiffe-jwt-test" }}, + "network": {{ + "credentials": ["mockapi"], + "custom_credentials": {{ + "mockapi": {{ + "upstream": "{upstream}", + "spiffe": {{ + "type": "jwt", + "workload_api_socket": "{socket}", + "audience": ["https://test.nono"], + "inject_header": "Authorization" + }} + }} + }} + }} + }}"# + ), + ); + + // curl routes $MOCKAPI_BASE_URL through HTTP_PROXY (127.0.0.1 is removed from + // NO_PROXY because the upstream is loopback). The proxy normalises the absolute + // URL, matches the mockapi route, fetches a JWT-SVID, and injects it. + let output = run_nono( + &[ + "run", + "--profile", + profile_path.to_str().expect("profile path"), + "--no-rollback", + "--", + "sh", + "-c", + "curl --silent --show-error \"$MOCKAPI_BASE_URL/\"", + ], + &home, + &workspace, + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let headers = mock.wait_for_headers(std::time::Duration::from_secs(10)); + + assert!( + output.status.success(), + "nono run should succeed\nstdout: {stdout}\nstderr: {stderr}" + ); + + let auth_header = headers + .iter() + .find(|h| h.to_lowercase().starts_with("authorization:")); + + assert!( + auth_header.is_some(), + "mock upstream should have received an Authorization header\nheaders: {headers:?}\nstderr: {stderr}" + ); + + let auth = auth_header.unwrap(); + assert!( + auth.to_lowercase().contains("bearer "), + "Authorization header should be a Bearer token\ngot: {auth}\nstderr: {stderr}" + ); + + let token = auth.split_once(' ').map(|x| x.1).unwrap_or("").trim(); + assert_eq!( + token.split('.').count(), + 3, + "Bearer value should be a JWT with three parts\ngot: {token}" + ); +} + +/// Catches profile-load or SVID-fetch failures that would be silent at request time. +#[test] +fn spiffe_jwt_proxy_starts_with_live_agent() { + let socket = match live_socket() { + Some(s) => s, + None => return, + }; + + let (_tmp, home, workspace) = setup_isolated_home("jwt-start"); + + let profile_path = write_profile( + &home, + "spiffe-jwt-start", + &format!( + r#"{{ + "meta": {{ "name": "spiffe-jwt-start-test" }}, + "network": {{ + "credentials": ["mockapi"], + "custom_credentials": {{ + "mockapi": {{ + "upstream": "https://test.nono", + "spiffe": {{ + "type": "jwt", + "workload_api_socket": "{socket}", + "audience": ["https://test.nono"], + "inject_header": "Authorization" + }} + }} + }} + }} + }}"# + ), + ); + + let output = run_nono( + &[ + "run", + "--profile", + profile_path.to_str().expect("profile path"), + "--no-rollback", + "--", + "true", + ], + &home, + &workspace, + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "nono run should start cleanly with a live SPIRE agent\nstdout: {stdout}\nstderr: {stderr}" + ); +} diff --git a/crates/nono-proxy/Cargo.toml b/crates/nono-proxy/Cargo.toml index 06620dfb9..41cc62297 100644 --- a/crates/nono-proxy/Cargo.toml +++ b/crates/nono-proxy/Cargo.toml @@ -69,6 +69,10 @@ rustls-native-certs = "0.8" # directly to set not_before/not_after. time = { version = "0.3", default-features = false, features = ["std"] } +# SPIFFE Workload API client. Aliased to avoid colliding with any future `spiffe` +# naming in the workspace. +spiffe-workload = { package = "spiffe", version = "0.16", features = ["jwt-source"] } + [dev-dependencies] tempfile.workspace = true tokio = { version = "1", features = ["test-util", "rt-multi-thread", "macros"] } diff --git a/crates/nono-proxy/src/audit.rs b/crates/nono-proxy/src/audit.rs index ca069ecff..5bc385f8d 100644 --- a/crates/nono-proxy/src/audit.rs +++ b/crates/nono-proxy/src/audit.rs @@ -1,6 +1,5 @@ //! Audit logging for proxy requests. //! -//! Logs all proxy requests with structured fields via `tracing`. //! Sensitive data (authorization headers, tokens, request bodies) //! is never included in audit logs. @@ -45,6 +44,7 @@ pub struct EventContext<'a> { pub endpoint_policy_rule: Option<&'a str>, pub approval_backend: Option<&'a str>, pub upstream: Option<&'a str>, + pub spiffe_context: Option, } impl std::fmt::Display for ProxyMode { @@ -58,13 +58,11 @@ impl std::fmt::Display for ProxyMode { } } -/// Create a shared in-memory audit log. #[must_use] pub fn new_audit_log() -> SharedAuditLog { Arc::new(Mutex::new(Vec::new())) } -/// Drain all network audit events collected so far. #[must_use] pub fn drain_audit_events(audit_log: &SharedAuditLog) -> Vec { match audit_log.lock() { @@ -134,7 +132,6 @@ fn push_event(audit_log: Option<&SharedAuditLog>, event: NetworkAuditEvent) { } } -/// Log an allowed proxy request. pub fn log_allowed( audit_log: Option<&SharedAuditLog>, mode: ProxyMode, @@ -181,6 +178,7 @@ pub fn log_allowed( credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: ctx.spiffe_context.clone(), target: host.to_string(), upstream: ctx.upstream.map(str::to_string), port: Some(port), @@ -192,7 +190,6 @@ pub fn log_allowed( ); } -/// Log a denied proxy request. pub fn log_denied( audit_log: Option<&SharedAuditLog>, mode: ProxyMode, @@ -239,6 +236,7 @@ pub fn log_denied( credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: ctx.spiffe_context.clone(), target: host.to_string(), upstream: ctx.upstream.map(str::to_string), port: Some(port), @@ -250,11 +248,8 @@ pub fn log_denied( ); } -/// Log an L7 request that the proxy decoded (reverse proxy or intercepted CONNECT). -/// -/// Used for both `Reverse` and `ConnectIntercept` modes. `External` and -/// `Connect` (transparent tunnel) modes have no L7 visibility and use -/// `log_allowed`/`log_denied` instead. +/// Used for `Reverse` and `ConnectIntercept` modes. `External` and `Connect` +/// (transparent tunnel) modes have no L7 visibility and use `log_allowed`/`log_denied`. pub fn log_l7_request( audit_log: Option<&SharedAuditLog>, mode: ProxyMode, @@ -302,6 +297,7 @@ pub fn log_l7_request( credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: ctx.spiffe_context.clone(), target: target.to_string(), upstream: ctx.upstream.map(str::to_string), port: None, @@ -313,7 +309,7 @@ pub fn log_l7_request( ); } -/// Log an L7 endpoint-policy decision before the upstream request is made. +/// Logs an endpoint-policy decision (before the upstream request). #[allow(clippy::too_many_arguments)] pub fn log_l7_policy_decision( audit_log: Option<&SharedAuditLog>, @@ -368,6 +364,7 @@ pub fn log_l7_policy_decision( credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: ctx.spiffe_context.clone(), target: target.to_string(), upstream: ctx.upstream.map(str::to_string), port, @@ -379,7 +376,6 @@ pub fn log_l7_policy_decision( ); } -/// Structured details for a command-backed credential capture audit event. #[derive(Debug, Clone)] pub struct CredentialCaptureAudit<'a> { pub route_id: &'a str, @@ -405,9 +401,8 @@ pub struct CredentialCaptureAudit<'a> { pub reason: Option<&'a str>, } -/// Log a command-backed credential capture event. The captured credential value -/// is never recorded; only command metadata, timing, byte counts, and redacted -/// diagnostics are persisted. +/// The captured credential value is never recorded — only command metadata, +/// timing, byte counts, and redacted stderr are persisted. pub fn log_credential_capture( audit_log: Option<&SharedAuditLog>, mode: ProxyMode, @@ -455,6 +450,7 @@ pub fn log_credential_capture( credential_capture_header_names: event.header_names.map(<[String]>::to_vec), credential_capture_stdin_mode: event.stdin_mode.map(str::to_string), credential_capture_interactive: event.interactive, + spiffe_context: None, target: event.request_host.to_string(), upstream: event.upstream.map(str::to_string), port: event.request_port, @@ -466,31 +462,6 @@ pub fn log_credential_capture( ); } -/// Compatibility shim for the previous `log_reverse_proxy` API. New code -/// should call [`log_l7_request`] directly with the appropriate -/// [`ProxyMode`] instead. -#[deprecated(since = "0.46.0", note = "use log_l7_request with ProxyMode::Reverse")] -pub fn log_reverse_proxy( - audit_log: Option<&SharedAuditLog>, - service: &str, - method: &str, - path: &str, - status: u16, -) { - log_l7_request( - audit_log, - ProxyMode::Reverse, - &EventContext { - route_id: Some(service), - ..EventContext::default() - }, - service, - method, - path, - status, - ); -} - #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { diff --git a/crates/nono-proxy/src/auth.rs b/crates/nono-proxy/src/auth.rs new file mode 100644 index 000000000..891ea6744 --- /dev/null +++ b/crates/nono-proxy/src/auth.rs @@ -0,0 +1,137 @@ +// Adding a new auth mechanism: add a variant here and a corresponding handler +// branch in reverse.rs (handle_spiffe_route / handle_oauth2_like). + +use crate::error::{ProxyError, Result}; +use std::sync::Arc; +use zeroize::Zeroizing; + +/// Auth material resolved for a single upstream request. +pub enum UpstreamAuthMaterial { + /// Token to inject into a request header. `credential_format` is a template + /// where `{}` is replaced by the token (e.g. `Bearer {}`). + BearerToken { + header: String, + token: Zeroizing, + workload_spiffe_id: String, + /// Format applied to the token when building the header value. + credential_format: String, + }, +} + +impl UpstreamAuthMaterial { + pub fn spiffe_audit_context(&self) -> nono::undo::SpiffeAuditContext { + let UpstreamAuthMaterial::BearerToken { + workload_spiffe_id, + token, + .. + } = self; + nono::undo::SpiffeAuditContext { + trust_domain: extract_trust_domain(workload_spiffe_id), + workload_spiffe_id: workload_spiffe_id.clone(), + svid_type: "jwt".to_string(), + source: "spire-workload-api".to_string(), + upstream_spiffe_id: None, + delegation: crate::spiffe::delegation_from_jwt(token.as_str()), + } + } +} + +/// A live credential source that can produce [`UpstreamAuthMaterial`] on demand. +/// +/// Built once at proxy startup (fail-closed if the source is unreachable) and +/// consulted on every upstream request. Each variant owns its own cache and +/// refresh logic. +pub enum ManagedUpstreamAuth { + /// JWT-SVID fetched from the SPIRE Workload API and injected as a bearer token. + SpiffeJwt(Arc), +} + +impl ManagedUpstreamAuth { + /// Acquire the material needed for one upstream request. + #[must_use = "dropping credential material without using it wastes an SVID fetch"] + pub async fn acquire(&self) -> Result { + match self { + ManagedUpstreamAuth::SpiffeJwt(src) => { + let (token, spiffe_id) = src + .fetch_token(&src.audience) + .await + .map_err(|e| ProxyError::Credential(e.to_string()))?; + let fmt = crate::config::resolved_credential_format( + &src.inject_header, + src.credential_format.as_deref(), + ); + Ok(UpstreamAuthMaterial::BearerToken { + header: src.inject_header.clone(), + token, + workload_spiffe_id: spiffe_id, + credential_format: fmt, + }) + } + } + } + + pub fn audit_mechanism(&self) -> nono::undo::NetworkAuditAuthMechanism { + match self { + ManagedUpstreamAuth::SpiffeJwt(_) => { + nono::undo::NetworkAuditAuthMechanism::SpiffeJwtBearer + } + } + } + + pub fn audit_injection_mode(&self) -> Option { + match self { + ManagedUpstreamAuth::SpiffeJwt(_) => { + Some(nono::undo::NetworkAuditInjectionMode::SpiffeJwt) + } + } + } +} + +/// `spiffe://prod.example/workload` → `"prod.example"`. +/// Returns `""` and logs a warning for malformed IDs. +pub fn extract_trust_domain(spiffe_id: &str) -> String { + match spiffe_id + .strip_prefix("spiffe://") + .and_then(|s| s.split('/').next()) + { + Some(domain) => domain.to_string(), + None => { + tracing::warn!( + "extract_trust_domain: malformed SPIFFE ID (missing spiffe:// prefix): \ + audit trust_domain will be empty" + ); + String::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_trust_domain_valid() { + assert_eq!( + extract_trust_domain("spiffe://prod.example/workload"), + "prod.example" + ); + } + + #[test] + fn extract_trust_domain_no_path() { + assert_eq!( + extract_trust_domain("spiffe://prod.example"), + "prod.example" + ); + } + + #[test] + fn extract_trust_domain_invalid() { + assert_eq!(extract_trust_domain("not-a-spiffe-id"), ""); + } + + #[test] + fn extract_trust_domain_empty() { + assert_eq!(extract_trust_domain(""), ""); + } +} diff --git a/crates/nono-proxy/src/config.rs b/crates/nono-proxy/src/config.rs index 5f07e4c02..829b98432 100644 --- a/crates/nono-proxy/src/config.rs +++ b/crates/nono-proxy/src/config.rs @@ -456,6 +456,46 @@ pub struct RouteConfig { /// credentials. Mutually exclusive with `credential_key` and `oauth2`. #[serde(default)] pub aws_auth: Option, + + /// SPIFFE/SPIRE workload identity auth. Mutually exclusive with `credential_key`, `oauth2`, and `aws_auth`. + /// See `SpiffeAuthConfig` for JWT-SVID options. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spiffe: Option, +} + +/// SPIFFE/SPIRE auth for an upstream route. +/// +/// Currently only `type: "jwt"` is supported — JWT-SVID injected as a bearer +/// token into the configured header. +/// +/// The sandboxed process makes a plain request with no credentials; nono fetches +/// the SVID from the SPIRE Workload API and handles everything. The SPIRE +/// operator registers the workload entry and configures the agent — nono's +/// config is just the socket path and what to request. +/// +/// Future variant: `type: "x509"` — mTLS with an X.509-SVID (client cert +/// presented during TLS handshake, with atomic rotation). Planned once the +/// TLS-intercept CONNECT path is wired to support it for HTTPS upstreams. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SpiffeAuthConfig { + /// JWT-SVID injected as a bearer token. Tokens refresh before expiry via the agent cache. + Jwt { + /// Path to the SPIRE agent Unix domain socket. + workload_api_socket: String, + /// Audience(s) for the minted JWT-SVID. + audience: Vec, + /// Header to inject the JWT into (default: `Authorization`). + #[serde(default = "default_inject_header")] + inject_header: String, + /// Format string; `{}` is replaced by the token. Default: `Bearer {}`. + #[serde(default)] + credential_format: Option, + /// Select a specific SVID by SPIFFE ID when the Workload API returns multiple SVIDs for + /// the same workload. When absent, nono uses the first SVID returned by the agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + svid_hint: Option, + }, } /// Optional proxy-side overrides for credential injection shape. @@ -833,7 +873,8 @@ fn endpoint_allowed(rules: &[EndpointRule], method: &str, path: &str) -> bool { /// Percent-decoding prevents bypass via encoded characters (e.g., /// `/api/%70rojects` evading a rule for `/api/projects/*`). fn normalize_path(path: &str) -> String { - // Strip query string + // Strip query string before percent-decoding: a literal %3F must not be + // treated as a query delimiter, so the split must precede the decode. let path = path.split('?').next().unwrap_or(path); // Percent-decode to prevent bypass via encoded segments. @@ -915,15 +956,34 @@ fn default_auth_scheme() -> String { /// The agent never sees client_id or client_secret — only a phantom token. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OAuth2Config { - /// Token endpoint URL (e.g., "https://auth.example.com/oauth/token") pub token_url: String, - /// Client ID — plain value or credential reference (env://, file://, op://) + /// Mutually exclusive with `client_assertion`. + #[serde(default)] pub client_id: String, - /// Client secret — credential reference (env://, file://, op://) + /// Mutually exclusive with `client_assertion`. + #[serde(default)] pub client_secret: String, - /// OAuth2 scopes (space-separated). Empty = no scope parameter sent. #[serde(default)] pub scope: String, + /// Use a SPIFFE JWT-SVID as the OAuth2 client assertion instead of client_id/secret. + /// Mutually exclusive with `client_id`/`client_secret`. + #[serde(default)] + pub client_assertion: Option, + /// Extra parameters merged into the token exchange POST body verbatim. + #[serde(default)] + pub extra_params: std::collections::HashMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientAssertionConfig { + SpiffeJwt { + workload_api_socket: String, + audience: Vec, + /// Select a specific SVID by SPIFFE ID when the Workload API returns multiple. + #[serde(default, skip_serializing_if = "Option::is_none")] + svid_hint: Option, + }, } /// AWS SigV4 signing configuration for a credential route. @@ -1569,4 +1629,38 @@ mod tests { assert!(aws.region.is_none()); assert!(aws.service.is_none()); } + + #[test] + fn test_spiffe_jwt_config_roundtrip() { + let json = r#"{ + "prefix": "inventory", + "upstream": "https://inventory.internal.example", + "spiffe": { + "type": "jwt", + "workload_api_socket": "/run/spire/sockets/agent.sock", + "audience": ["inventory.internal.example"], + "inject_header": "Authorization", + "credential_format": "Bearer {}" + } + }"#; + let route: RouteConfig = serde_json::from_str(json).unwrap(); + let SpiffeAuthConfig::Jwt { + workload_api_socket, + audience, + inject_header, + credential_format, + .. + } = route.spiffe.unwrap(); + assert_eq!(workload_api_socket, "/run/spire/sockets/agent.sock"); + assert_eq!(audience, vec!["inventory.internal.example"]); + assert_eq!(inject_header, "Authorization"); + assert_eq!(credential_format.as_deref(), Some("Bearer {}")); + } + + #[test] + fn test_spiffe_absent_by_default() { + let json = r#"{"prefix": "openai", "upstream": "https://api.openai.com"}"#; + let route: RouteConfig = serde_json::from_str(json).unwrap(); + assert!(route.spiffe.is_none()); + } } diff --git a/crates/nono-proxy/src/credential.rs b/crates/nono-proxy/src/credential.rs index 9976be4a3..7016ec13c 100644 --- a/crates/nono-proxy/src/credential.rs +++ b/crates/nono-proxy/src/credential.rs @@ -14,7 +14,7 @@ use crate::capture::CredentialCaptureMaterial; use crate::config::{InjectMode, RouteConfig}; use crate::diagnostic::{ProxyDiagnostic, ProxyDiagnosticCode}; use crate::error::{ProxyError, Result}; -use crate::oauth2::{OAuth2ExchangeConfig, TokenCache}; +use crate::oauth2::{OAuth2ExchangeConfig, SpiffeAssertionTokenCache, TokenCache}; use base64::Engine; use std::collections::HashMap; use tokio_rustls::TlsConnector; @@ -152,10 +152,19 @@ impl std::fmt::Debug for LoadedCredential { /// An OAuth2 route entry: token cache + upstream URL. #[derive(Debug)] pub struct OAuth2Route { - /// Token cache for automatic refresh pub cache: TokenCache, - /// Upstream URL (e.g., "https://api.example.com") pub upstream: String, + /// Header the client sends the phantom token in (e.g. `x-api-key`, `Authorization`). + pub inject_header: String, +} + +/// An OAuth2 jwt-bearer route backed by a SPIFFE JWT-SVID assertion. +#[derive(Debug)] +pub struct SpiffeAssertionRoute { + pub cache: SpiffeAssertionTokenCache, + pub upstream: String, + /// Header the client sends the phantom token in (e.g. `x-api-key`, `Authorization`). + pub inject_header: String, } /// Result of loading credentials at proxy startup. @@ -181,10 +190,10 @@ pub struct CredentialStore { credentials: HashMap, /// Map from route prefix to lazy command-backed credential config. cmd_routes: HashMap, - /// Map from route prefix to OAuth2 route (token cache + upstream) oauth2_routes: HashMap, /// Map from route prefix to AWS SigV4 route (region, service, provider). aws_routes: AwsRouteTable, + spiffe_assertion_routes: HashMap, } impl CredentialStore { @@ -213,6 +222,7 @@ impl CredentialStore { let mut cmd_routes = HashMap::new(); let mut oauth2_routes = HashMap::new(); let mut aws_routes = AwsRouteTable::empty(); + let mut spiffe_assertion_routes = HashMap::new(); let mut diagnostics = Vec::new(); for route in routes { @@ -352,65 +362,150 @@ impl CredentialStore { continue; } - // OAuth2 client_credentials path + // OAuth2 path if let Some(ref oauth2) = route.oauth2 { - debug!( - "Loading OAuth2 credential for route prefix: {}", - route.prefix - ); - - let Some(client_id) = load_oauth_keystore_ref( - &mut diagnostics, - &route.prefix, - &oauth2.client_id, - "OAuth2 client_id", - ProxyDiagnosticCode::OAuthClientIdUnavailable, - )? - else { - continue; - }; - - let Some(client_secret) = load_oauth_keystore_ref( - &mut diagnostics, - &route.prefix, - &oauth2.client_secret, - "OAuth2 client_secret", - ProxyDiagnosticCode::OAuthClientSecretUnavailable, - )? - else { - continue; - }; - - let config = OAuth2ExchangeConfig { - token_url: oauth2.token_url.clone(), - client_id, - client_secret, - scope: oauth2.scope.clone(), - }; + if let Some(ref assertion) = oauth2.client_assertion { + // jwt-bearer grant via SPIFFE JWT-SVID + use crate::config::ClientAssertionConfig; + let ClientAssertionConfig::SpiffeJwt { + workload_api_socket, + audience, + svid_hint, + } = assertion; + debug!( + "Loading SPIFFE assertion OAuth2 credential for route: {}", + route.prefix + ); - match TokenCache::new(config, tls_connector.clone()).await { - Ok(cache) => { - oauth2_routes.insert( - route.prefix.clone(), - OAuth2Route { - cache, - upstream: route.upstream.clone(), - }, - ); + let jwt_source = match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + crate::spiffe::SpiffeJwtSource::connect( + workload_api_socket, + audience.clone(), + "Authorization".to_string(), + None, + svid_hint.as_deref(), + ), + ) + }) { + Ok(s) => std::sync::Arc::new(s), + Err(e) => { + let message = format!( + "SPIFFE JWT source failed for route '{}': {e}. \ + Requests on this route will be denied.", + route.prefix + ); + warn!("{message}"); + diagnostics.push(ProxyDiagnostic::warning( + ProxyDiagnosticCode::OAuthTokenExchangeFailed, + &route.prefix, + message, + )); + continue; + } + }; + + let extra_params: Vec<(String, String)> = oauth2 + .extra_params + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(SpiffeAssertionTokenCache::new( + oauth2.token_url.clone(), + jwt_source, + audience.clone(), + extra_params, + tls_connector.clone(), + )) + }) { + Ok(cache) => { + spiffe_assertion_routes.insert( + normalized_prefix.clone(), + SpiffeAssertionRoute { + cache, + upstream: route.upstream.clone(), + inject_header: route.inject_header.clone(), + }, + ); + } + Err(e) => { + let message = format!( + "SPIFFE assertion token exchange failed for route '{}': {e}. \ + Requests on this route will be denied.", + route.prefix + ); + warn!("{message}"); + diagnostics.push(ProxyDiagnostic::warning( + ProxyDiagnosticCode::OAuthTokenExchangeFailed, + &route.prefix, + message, + )); + continue; + } } - Err(e) => { - let message = format!( - "OAuth2 token exchange failed for route '{}': {e}. \ - Managed-credential requests on this route will be denied.", - route.prefix - ); - warn!("{message}"); - diagnostics.push(ProxyDiagnostic::warning( - ProxyDiagnosticCode::OAuthTokenExchangeFailed, - &route.prefix, - message, - )); + } else { + // client_credentials grant + debug!( + "Loading OAuth2 credential for route prefix: {}", + route.prefix + ); + + let Some(client_id) = load_oauth_keystore_ref( + &mut diagnostics, + &route.prefix, + &oauth2.client_id, + "OAuth2 client_id", + ProxyDiagnosticCode::OAuthClientIdUnavailable, + )? + else { continue; + }; + + let Some(client_secret) = load_oauth_keystore_ref( + &mut diagnostics, + &route.prefix, + &oauth2.client_secret, + "OAuth2 client_secret", + ProxyDiagnosticCode::OAuthClientSecretUnavailable, + )? + else { + continue; + }; + + let config = OAuth2ExchangeConfig { + token_url: oauth2.token_url.clone(), + client_id, + client_secret, + scope: oauth2.scope.clone(), + }; + + match TokenCache::new(config, tls_connector.clone()).await { + Ok(cache) => { + oauth2_routes.insert( + normalized_prefix.clone(), + OAuth2Route { + cache, + upstream: route.upstream.clone(), + inject_header: route.inject_header.clone(), + }, + ); + } + Err(e) => { + let message = format!( + "OAuth2 token exchange failed for route '{}': {e}. \ + Managed-credential requests on this route will be denied.", + route.prefix + ); + warn!("{message}"); + diagnostics.push(ProxyDiagnostic::warning( + ProxyDiagnosticCode::OAuthTokenExchangeFailed, + &route.prefix, + message, + )); + continue; + } } } } else if let Some(ref aws_auth) = route.aws_auth { @@ -477,6 +572,7 @@ impl CredentialStore { credentials, cmd_routes, oauth2_routes, + spiffe_assertion_routes, aws_routes, }, diagnostics, @@ -505,6 +601,7 @@ impl CredentialStore { cmd_routes: HashMap::new(), oauth2_routes: HashMap::new(), aws_routes: AwsRouteTable::empty(), + spiffe_assertion_routes: HashMap::new(), } } @@ -520,12 +617,16 @@ impl CredentialStore { self.cmd_routes.get(prefix) } - /// Get an OAuth2 route (token cache + upstream) for a route prefix, if configured. #[must_use] pub fn get_oauth2(&self, prefix: &str) -> Option<&OAuth2Route> { self.oauth2_routes.get(prefix) } + #[must_use] + pub fn get_spiffe_assertion(&self, prefix: &str) -> Option<&SpiffeAssertionRoute> { + self.spiffe_assertion_routes.get(prefix) + } + /// Returns `Some(&AwsRoute)` if an AWS SigV4 route is configured for the /// given prefix, `None` otherwise. #[must_use] @@ -533,32 +634,31 @@ impl CredentialStore { self.aws_routes.get(prefix) } - /// Check if any credentials (static, command-backed, OAuth2, or AWS) are loaded. #[must_use] pub fn is_empty(&self) -> bool { self.credentials.is_empty() && self.cmd_routes.is_empty() && self.oauth2_routes.is_empty() + && self.spiffe_assertion_routes.is_empty() && self.aws_routes.is_empty() } - /// Number of loaded credentials (static + OAuth2 + AWS). #[must_use] pub fn len(&self) -> usize { self.credentials.len() + self.cmd_routes.len() + self.oauth2_routes.len() + + self.spiffe_assertion_routes.len() + self.aws_routes.len() } - /// Returns the set of route prefixes that have loaded credentials - /// (static keystore, OAuth2, and AWS routes). #[must_use] pub fn loaded_prefixes(&self) -> std::collections::HashSet { self.credentials .keys() .chain(self.cmd_routes.keys()) .chain(self.oauth2_routes.keys()) + .chain(self.spiffe_assertion_routes.keys()) .chain(self.aws_routes.keys()) .cloned() .collect() @@ -882,8 +982,11 @@ mod tests { client_id: client_id.to_string(), client_secret: client_secret.to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }), aws_auth: None, + spiffe: None, } } @@ -909,6 +1012,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; let outcome = CredentialStore::load_with_diagnostics(&routes, &tls) .await @@ -1028,6 +1132,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).await; assert!(outcome.is_ok()); @@ -1069,6 +1174,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; let store = CredentialStore::load_with_diagnostics(&routes, &tls) .await @@ -1098,6 +1204,7 @@ mod tests { OAuth2Route { cache, upstream: "https://api.example.com".to_string(), + inject_header: "Authorization".to_string(), }, ); @@ -1106,6 +1213,7 @@ mod tests { cmd_routes: HashMap::new(), oauth2_routes, aws_routes: AwsRouteTable::empty(), + spiffe_assertion_routes: HashMap::new(), }; assert!( @@ -1128,6 +1236,7 @@ mod tests { OAuth2Route { cache, upstream: "https://api.example.com".to_string(), + inject_header: "Authorization".to_string(), }, ); @@ -1136,6 +1245,7 @@ mod tests { cmd_routes: HashMap::new(), oauth2_routes, aws_routes: AwsRouteTable::empty(), + spiffe_assertion_routes: HashMap::new(), }; let prefixes = store.loaded_prefixes(); @@ -1168,6 +1278,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; let store = CredentialStore::load_with_diagnostics(&routes, &tls) .await @@ -1204,6 +1315,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; let store = CredentialStore::load_with_diagnostics(&routes, &tls) .await @@ -1249,8 +1361,11 @@ mod tests { client_id: "env://TEST_OAUTH2_CLIENT_ID".to_string(), client_secret: "env://TEST_OAUTH2_CLIENT_SECRET".to_string(), scope: String::new(), + client_assertion: None, + extra_params: Default::default(), }), aws_auth: None, + spiffe: None, }]; let outcome = CredentialStore::load_with_diagnostics(&routes, &tls).await; diff --git a/crates/nono-proxy/src/lib.rs b/crates/nono-proxy/src/lib.rs index adf9cb847..cd4d3083a 100644 --- a/crates/nono-proxy/src/lib.rs +++ b/crates/nono-proxy/src/lib.rs @@ -19,6 +19,7 @@ pub mod approval; pub mod audit; +pub mod auth; pub mod aws; pub mod capture; pub mod config; @@ -35,6 +36,7 @@ pub mod pool; pub mod reverse; pub mod route; pub mod server; +pub mod spiffe; pub mod tls_intercept; pub mod token; diff --git a/crates/nono-proxy/src/oauth2.rs b/crates/nono-proxy/src/oauth2.rs index 2fcd2fd0e..5a5a64774 100644 --- a/crates/nono-proxy/src/oauth2.rs +++ b/crates/nono-proxy/src/oauth2.rs @@ -203,7 +203,23 @@ async fn exchange_token( let scheme = parsed.scheme(); let is_https = match scheme { "https" => true, - "http" => false, + "http" => { + // HTTP is only permitted for loopback addresses (testing / local + // mock servers). Sending a JWT-SVID or client_secret over plaintext + // HTTP to a non-loopback host exposes the credential on the network. + let is_loopback = parsed.host_str().is_some_and(|h| { + h == "localhost" + || h.parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if !is_loopback { + return Err(ProxyError::OAuth2Exchange(format!( + "token_url '{}' must use https:// (http:// is only permitted for loopback addresses)", + config.token_url + ))); + } + false + } other => { return Err(ProxyError::OAuth2Exchange(format!( "unsupported scheme '{}' in token_url", @@ -405,6 +421,278 @@ fn parse_token_response(json: &str) -> Result<(Zeroizing, Duration)> { )) } +// ──────────────────────────────────────────────────────────────────────────── +// SPIFFE JWT-SVID client assertion token cache +// ──────────────────────────────────────────────────────────────────────────── + +/// Like [`TokenCache`] but uses a SPIFFE JWT-SVID as the OAuth2 `client_assertion` +/// instead of a client_id/secret pair. Exchanges via `jwt-bearer` grant type. +pub struct SpiffeAssertionTokenCache { + token: Arc>, + jwt_source: Arc, + token_url: String, + assertion_audience: Vec, + extra_params: Vec<(String, String)>, + tls_connector: TlsConnector, + pub workload_spiffe_id: String, +} + +impl std::fmt::Debug for SpiffeAssertionTokenCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpiffeAssertionTokenCache") + .field("token_url", &self.token_url) + .finish() + } +} + +impl SpiffeAssertionTokenCache { + pub async fn new( + token_url: String, + jwt_source: Arc, + assertion_audience: Vec, + extra_params: Vec<(String, String)>, + tls_connector: TlsConnector, + ) -> Result { + let (access_token, expires_in, workload_spiffe_id) = exchange_jwt_assertion( + &token_url, + &jwt_source, + &assertion_audience, + &extra_params, + &tls_connector, + ) + .await?; + + Ok(Self { + token: Arc::new(RwLock::new(CachedToken { + access_token, + expires_at: Instant::now() + expires_in, + })), + jwt_source, + token_url, + assertion_audience, + extra_params, + tls_connector, + workload_spiffe_id, + }) + } + + pub async fn get_or_refresh(&self) -> Result> { + { + let guard = self.token.read().await; + if Instant::now() + Duration::from_secs(EXPIRY_BUFFER_SECS) < guard.expires_at { + return Ok(guard.access_token.clone()); + } + } + + let mut guard = self.token.write().await; + if Instant::now() + Duration::from_secs(EXPIRY_BUFFER_SECS) < guard.expires_at { + return Ok(guard.access_token.clone()); + } + + match exchange_jwt_assertion( + &self.token_url, + &self.jwt_source, + &self.assertion_audience, + &self.extra_params, + &self.tls_connector, + ) + .await + { + Ok((new_token, expires_in, _)) => { + debug!( + "OAuth2 jwt-bearer token refreshed, expires in {}s", + expires_in.as_secs() + ); + guard.access_token = new_token; + guard.expires_at = Instant::now() + expires_in; + Ok(guard.access_token.clone()) + } + // SVID fetch failed — workload identity is gone; fail the request. + Err(e @ ProxyError::Credential(_)) => Err(e), + // Token exchange failed (network/auth) — use stale token if available. + Err(e) => { + warn!( + "OAuth2 jwt-bearer token refresh failed, returning stale token: {}", + e + ); + Ok(guard.access_token.clone()) + } + } + } +} + +async fn exchange_jwt_assertion( + token_url: &str, + jwt_source: &crate::spiffe::SpiffeJwtSource, + audience: &[String], + extra_params: &[(String, String)], + tls_connector: &TlsConnector, +) -> Result<(Zeroizing, Duration, String)> { + let (assertion, spiffe_id) = jwt_source.fetch_token(audience).await?; + + let mut body = Zeroizing::new(format!( + "grant_type={}&client_assertion_type={}&assertion={}", + urlencoding::encode("urn:ietf:params:oauth:grant-type:jwt-bearer"), + urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + urlencoding::encode(assertion.as_str()), + )); + for (k, v) in extra_params { + body.push_str(&format!( + "&{}={}", + urlencoding::encode(k), + urlencoding::encode(v) + )); + } + + let parsed = url::Url::parse(token_url) + .map_err(|e| ProxyError::OAuth2Exchange(format!("invalid token_url '{token_url}': {e}")))?; + + let scheme = parsed.scheme(); + let is_https = match scheme { + "https" => true, + "http" => { + let is_loopback = parsed.host_str().is_some_and(|h| { + h == "localhost" + || h.parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if !is_loopback { + return Err(ProxyError::OAuth2Exchange(format!( + "token_url '{token_url}' must use https:// (http:// only allowed for loopback)" + ))); + } + false + } + other => { + return Err(ProxyError::OAuth2Exchange(format!( + "unsupported scheme '{other}' in token_url" + ))); + } + }; + + let host = parsed + .host_str() + .ok_or_else(|| { + ProxyError::OAuth2Exchange(format!("missing host in token_url '{token_url}'")) + })? + .to_string(); + let port = parsed.port().unwrap_or(if is_https { 443 } else { 80 }); + let path = parsed.path().to_string(); + + let request = Zeroizing::new(format!( + "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nAccept: application/json\r\nConnection: close\r\n\r\n{}", + path, + host, + body.len(), + body.as_str() + )); + + let addr = format!("{}:{}", host, port); + let response_bytes = tokio::time::timeout(EXCHANGE_TIMEOUT, async { + let tcp = TcpStream::connect(&addr) + .await + .map_err(|e| ProxyError::OAuth2Exchange(format!("TCP connect to {addr}: {e}")))?; + + async fn send_and_read( + stream: &mut S, + request: &[u8], + host: &str, + ) -> Result> { + stream + .write_all(request) + .await + .map_err(|e| ProxyError::OAuth2Exchange(format!("write to {host}: {e}")))?; + stream + .flush() + .await + .map_err(|e| ProxyError::OAuth2Exchange(format!("flush to {host}: {e}")))?; + read_http_response(stream).await + } + + if is_https { + let server_name = + rustls::pki_types::ServerName::try_from(host.clone()).map_err(|_| { + ProxyError::OAuth2Exchange(format!("invalid TLS server name: {host}")) + })?; + let mut tls = tls_connector.connect(server_name, tcp).await.map_err(|e| { + ProxyError::OAuth2Exchange(format!("TLS handshake with {host}: {e}")) + })?; + send_and_read(&mut tls, request.as_bytes(), &host).await + } else { + let mut tcp = tcp; + send_and_read(&mut tcp, request.as_bytes(), &host).await + } + }) + .await + .map_err(|_| ProxyError::OAuth2Exchange(format!("token exchange with {addr} timed out")))??; + + let response_str = String::from_utf8(response_bytes) + .map_err(|_| ProxyError::OAuth2Exchange("non-UTF-8 token response".to_string()))?; + let body_start = response_str + .find("\r\n\r\n") + .map(|i| i + 4) + .or_else(|| response_str.find("\n\n").map(|i| i + 2)) + .ok_or_else(|| ProxyError::OAuth2Exchange("malformed HTTP response".to_string()))?; + let status = parse_status_code(response_str.lines().next().unwrap_or("")); + let raw_body = &response_str[body_start..]; + let is_chunked = response_str[..body_start].lines().any(|l| { + l.to_ascii_lowercase() + .contains("transfer-encoding: chunked") + }); + let body = if is_chunked { + decode_chunked(raw_body) + } else { + raw_body.to_string() + }; + if !(200..300).contains(&status) { + let preview: String = body.chars().take(200).collect(); + return Err(ProxyError::OAuth2Exchange(format!( + "token endpoint returned HTTP {status}: {preview}" + ))); + } + let (token, expires) = parse_token_response(&body)?; + Ok((token, expires, spiffe_id)) +} + +/// Decode an HTTP/1.1 chunked-encoded body into a plain string. +fn decode_chunked(body: &str) -> String { + let mut out = String::new(); + let mut rest = body; + loop { + // Each chunk: "\r\n\r\n" + let end = rest + .find('\r') + .or_else(|| rest.find('\n')) + .unwrap_or(rest.len()); + let size_str = rest[..end].trim(); + if size_str.is_empty() { + break; + } + let size = usize::from_str_radix(size_str, 16).unwrap_or(0); + if size == 0 { + break; + } + let after_crlf = end + + if rest[end..].starts_with("\r\n") { + 2 + } else { + 1 + }; + if after_crlf + size > rest.len() { + out.push_str(&rest[after_crlf..]); + break; + } + out.push_str(&rest[after_crlf..after_crlf + size]); + rest = &rest[after_crlf + size..]; + if rest.starts_with("\r\n") { + rest = &rest[2..]; + } else if rest.starts_with('\n') { + rest = &rest[1..]; + } + } + out +} + // ──────────────────────────────────────────────────────────────────────────── // Tests // ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/nono-proxy/src/reverse.rs b/crates/nono-proxy/src/reverse.rs index b52047006..23da7602f 100644 --- a/crates/nono-proxy/src/reverse.rs +++ b/crates/nono-proxy/src/reverse.rs @@ -20,7 +20,8 @@ use crate::config::{EndpointPolicyOutcome, InjectMode}; use crate::credential::{CmdCredentialRoute, CredentialStore, LoadedCredential}; use crate::error::{ProxyError, Result}; use crate::filter::ProxyFilter; -use crate::forward::UpstreamScheme; +use crate::forward; +use crate::forward::{AuditCtx, UpstreamScheme, UpstreamSpec, UpstreamStrategy}; use crate::route::RouteStore; use crate::token; use std::net::SocketAddr; @@ -131,11 +132,20 @@ pub async fn handle_reverse_proxy( ctx: &ReverseProxyCtx<'_>, buffered_body: &[u8], ) -> Result<()> { - // Parse method, path, and HTTP version let (method, path, version) = parse_request_line(first_line)?; debug!("Reverse proxy: {} {}", method, path); - // Extract service prefix from path (e.g., "/openai/v1/chat" -> ("openai", "/v1/chat")) + // HTTP proxy clients send absolute URLs; strip scheme+authority so + // parse_service_prefix sees a relative path. + let path = if path.starts_with("http://") || path.starts_with("https://") { + path.strip_prefix("http://") + .or_else(|| path.strip_prefix("https://")) + .and_then(|s| s.find('/').map(|i| s[i..].to_string())) + .unwrap_or_else(|| "/".to_string()) + } else { + path + }; + let (service, upstream_path) = parse_service_prefix(&path)?; let route = ctx .route_store @@ -146,6 +156,7 @@ pub async fn handle_reverse_proxy( let static_cred = ctx.credential_store.get(&service); let cmd_route = ctx.credential_store.get_cmd(&service); let oauth2_route = ctx.credential_store.get_oauth2(&service); + let spiffe_assertion_route = ctx.credential_store.get_spiffe_assertion(&service); let aws_route = ctx.credential_store.get_aws(&service); let managed_ctx = static_cred .map(|cred| { @@ -171,6 +182,7 @@ pub async fn handle_reverse_proxy( injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2), ..audit::EventContext::default() }); + let has_spiffe = route.has_spiffe_source(); let route_ctx = managed_ctx .clone() .or_else(|| oauth2_ctx.clone()) @@ -183,8 +195,9 @@ pub async fn handle_reverse_proxy( let cmd_available = cmd_route.is_some() && ctx.credential_capture_backend.is_some(); if route.missing_managed_credential( static_cred.is_some() || cmd_available, - oauth2_route.is_some(), + oauth2_route.is_some() || spiffe_assertion_route.is_some(), aws_route.is_some(), + has_spiffe, ) { let reason = format!( "managed credential unavailable for service '{}': route is configured for proxy-supplied auth", @@ -244,6 +257,22 @@ pub async fn handle_reverse_proxy( .await; } + if let Some(assertion_route) = spiffe_assertion_route { + return handle_spiffe_assertion_credential( + assertion_route, + route, + &service, + &upstream_path, + &method, + &version, + stream, + remaining_header, + buffered_body, + ctx, + ) + .await; + } + // AWS SigV4 signing is not yet implemented. Return 501 so the caller // knows the route exists but is not functional. This branch will be // replaced with real SigV4 signing in a follow-up. @@ -252,6 +281,22 @@ pub async fn handle_reverse_proxy( return Ok(()); } + if has_spiffe { + return handle_spiffe_route( + route, + &service, + &method, + &upstream_path, + &version, + remaining_header, + buffered_body, + stream, + ctx, + &route_ctx, + ) + .await; + } + let cred = static_cred; // Authenticate the request. Every reverse proxy request must prove @@ -553,6 +598,284 @@ pub async fn handle_reverse_proxy( Ok(()) } +/// Handle a reverse-proxy request for a SPIFFE-authenticated route. +/// +/// Validate the localhost auth boundary for SPIFFE reverse-proxy requests. +/// +/// Accepts `Proxy-Authorization` (forward-proxy style via `HTTP_PROXY`) or +/// `Authorization: Bearer ` (SDK / `*_BASE_URL` style). +fn validate_reverse_local_auth( + remaining_header: &[u8], + upstream_path: &str, + session_token: &Zeroizing, +) -> Result<()> { + if token::validate_proxy_auth(remaining_header, session_token).is_ok() { + return Ok(()); + } + // Try Authorization: Bearer first (standard pattern). + if validate_phantom_token_for_mode( + &crate::config::InjectMode::Header, + remaining_header, + upstream_path, + "Authorization", + None, + None, + session_token, + ) + .is_ok() + { + return Ok(()); + } + // Also accept x-api-key: for clients (e.g. claude CLI) that send + // the API key directly without a Bearer prefix. + validate_phantom_token_for_mode( + &crate::config::InjectMode::Header, + remaining_header, + upstream_path, + "x-api-key", + None, + None, + session_token, + ) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_spiffe_route( + route: &crate::route::LoadedRoute, + service: &str, + method: &str, + upstream_path: &str, + version: &str, + remaining_header: &[u8], + buffered_body: &[u8], + stream: &mut TcpStream, + ctx: &ReverseProxyCtx<'_>, + route_ctx: &audit::EventContext<'_>, +) -> Result<()> { + // Validate the local session token before doing anything with the SPIFFE source. + if let Err(e) = validate_reverse_local_auth(remaining_header, upstream_path, ctx.session_token) + { + let deny_ctx = audit::EventContext { + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed), + denial_category: Some(nono::undo::NetworkAuditDenialCategory::AuthenticationFailed), + ..route_ctx.clone() + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Reverse, + &deny_ctx, + service, + 0, + &e.to_string(), + ); + send_error(stream, 407, "Proxy Authentication Required").await?; + return Ok(()); + } + + let auth = route + .managed_auth + .as_ref() + .ok_or_else(|| ProxyError::Credential("no managed auth on SPIFFE route".into())); + let material = match auth { + Ok(auth) => match auth.acquire().await { + Ok(m) => m, + Err(e) => { + warn!("SPIFFE credential unavailable: {}", e); + let deny_ctx = audit::EventContext { + auth_mechanism: route.managed_auth_mechanism.clone(), + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed), + denial_category: Some( + nono::undo::NetworkAuditDenialCategory::ManagedCredentialUnavailable, + ), + ..route_ctx.clone() + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Reverse, + &deny_ctx, + service, + 0, + &e.to_string(), + ); + send_error(stream, 503, "Service Unavailable").await?; + return Ok(()); + } + }, + Err(e) => { + warn!("SPIFFE credential unavailable: {}", e); + let deny_ctx = audit::EventContext { + auth_mechanism: route.managed_auth_mechanism.clone(), + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed), + denial_category: Some( + nono::undo::NetworkAuditDenialCategory::ManagedCredentialUnavailable, + ), + ..route_ctx.clone() + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Reverse, + &deny_ctx, + service, + 0, + &e.to_string(), + ); + send_error(stream, 503, "Service Unavailable").await?; + return Ok(()); + } + }; + + let crate::auth::UpstreamAuthMaterial::BearerToken { + ref header, + ref token, + ref credential_format, + .. + } = material; + let inject_header = Some(header.clone()); + let inject_value = Some(Zeroizing::new( + credential_format.replace("{}", token.as_str()), + )); + let spiffe_ctx = material.spiffe_audit_context(); + let success_ctx = audit::EventContext { + route_id: Some(service), + auth_mechanism: route.managed_auth_mechanism.clone(), + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded), + managed_credential_active: Some(true), + injection_mode: route.managed_injection_mode.clone(), + spiffe_context: Some(spiffe_ctx), + ..audit::EventContext::default() + }; + + let upstream_url = format!("{}{}", route.upstream.trim_end_matches('/'), upstream_path); + debug!("SPIFFE forward to upstream: {} {}", method, upstream_url); + let (upstream_scheme, upstream_host, upstream_port, upstream_path_full) = + parse_upstream_url(&upstream_url)?; + let check = ctx.filter.check_host(&upstream_host, upstream_port).await?; + if !check.result.is_allowed() { + let reason = check.result.reason(); + warn!("Upstream host denied by filter: {}", reason); + send_error(stream, 403, "Forbidden").await?; + let deny_ctx = audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied), + ..route_ctx.clone() + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Reverse, + &deny_ctx, + service, + 0, + &reason, + ); + return Ok(()); + } + if let Err(reason) = + validate_http_upstream_target(upstream_scheme, &upstream_host, &check.resolved_addrs) + { + warn!("{}", reason); + send_error(stream, 502, "Bad Gateway").await?; + let deny_ctx = audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed), + ..route_ctx.clone() + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Reverse, + &deny_ctx, + service, + 0, + &reason, + ); + return Ok(()); + } + + let inject_header_str = inject_header.as_deref().unwrap_or(""); + let filtered_headers = filter_headers_multi( + remaining_header, + &[inject_header_str, "Authorization", "x-api-key"], + ); + let content_length = extract_content_length(remaining_header); + let body = match read_request_body(stream, content_length, buffered_body).await? { + Some(body) => body, + None => return Ok(()), + }; + + let upstream_authority = format_host_header(upstream_scheme, &upstream_host, upstream_port); + let mut request = Zeroizing::new(format!( + "{} {} {}\r\nHost: {}\r\n", + method, upstream_path_full, version, upstream_authority + )); + + // Inject the managed credential (SPIFFE JWT, etc.). + if let (Some(value), Some(header)) = (&inject_value, &inject_header) { + request.push_str(header); + request.push_str(": "); + request.push_str(value.as_str()); + request.push_str("\r\n"); + } + + for (name, value) in &filtered_headers { + // Reject headers with CRLF in name or value — prevents a sandboxed + // process from injecting extra headers into the upstream request. + if name.contains(['\r', '\n']) || value.contains(['\r', '\n']) { + warn!("dropping header with CRLF in name or value: {:?}", name); + continue; + } + request.push_str(name); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + + request.push_str("Connection: close\r\n"); + if !body.is_empty() { + request.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + request.push_str("\r\n"); + + let default_connector = route + .tls_connector + .as_ref() + .unwrap_or(ctx.tls_connector) + .clone(); + let connector = &default_connector; + let upstream_spec = UpstreamSpec { + scheme: upstream_scheme, + host: &upstream_host, + port: upstream_port, + strategy: UpstreamStrategy::Direct { + resolved_addrs: &check.resolved_addrs, + }, + tls_connector: connector, + }; + let audit_ctx = AuditCtx { + log: ctx.audit_log, + mode: audit::ProxyMode::Reverse, + event_ctx: success_ctx.clone(), + target: service, + method, + path: upstream_path, + }; + if let Err(e) = + forward::forward_request(stream, request.as_bytes(), &body, upstream_spec, audit_ctx).await + { + warn!("SPIFFE upstream connection failed: {}", e); + send_error(stream, 502, "Bad Gateway").await?; + let deny_ctx = audit::EventContext { + denial_category: Some(nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed), + ..success_ctx + }; + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::Reverse, + &deny_ctx, + service, + 0, + &e.to_string(), + ); + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn capture_cmd_credential( cmd: &CmdCredentialRoute, @@ -968,8 +1291,8 @@ fn endpoint_approval_request_id(service: &str) -> String { /// session token via the `Authorization: Bearer ` header, which is /// validated and then replaced with the real OAuth2 access token. #[allow(clippy::too_many_arguments)] -async fn handle_oauth2_credential( - oauth2_route: &crate::credential::OAuth2Route, +async fn handle_spiffe_assertion_credential( + assertion_route: &crate::credential::SpiffeAssertionRoute, route: &crate::route::LoadedRoute, service: &str, upstream_path: &str, @@ -980,22 +1303,67 @@ async fn handle_oauth2_credential( buffered_body: &[u8], ctx: &ReverseProxyCtx<'_>, ) -> Result<()> { - // Get (possibly refreshed) OAuth2 access token - let access_token = oauth2_route.cache.get_or_refresh().await; + let access_token = assertion_route.cache.get_or_refresh().await?; + let id = &assertion_route.cache.workload_spiffe_id; + let spiffe_ctx = nono::undo::SpiffeAuditContext { + trust_domain: crate::auth::extract_trust_domain(id), + workload_spiffe_id: id.clone(), + svid_type: "jwt".to_string(), + source: "spire-workload-api".to_string(), + upstream_spiffe_id: None, + delegation: None, + }; + handle_oauth2_like( + access_token, + Some(spiffe_ctx), + &assertion_route.upstream, + &assertion_route.inject_header, + route, + service, + upstream_path, + method, + stream, + remaining_header, + buffered_body, + ctx, + ) + .await +} - // Validate session token from Authorization header (phantom token pattern). - // OAuth2 routes still require the agent to authenticate with the session - // token — this prevents unauthorized access to the token-exchanged credential. - // Skipped only when `require_auth` is disabled (`nono proxy --no-auth`). - if ctx.require_auth - && let Err(e) = validate_phantom_token(remaining_header, "Authorization", ctx.session_token) - { +#[allow(clippy::too_many_arguments)] +async fn handle_oauth2_like( + access_token: zeroize::Zeroizing, + spiffe_context: Option, + upstream: &str, + inject_header: &str, + route: &crate::route::LoadedRoute, + service: &str, + upstream_path: &str, + method: &str, + stream: &mut TcpStream, + remaining_header: &[u8], + buffered_body: &[u8], + ctx: &ReverseProxyCtx<'_>, +) -> Result<()> { + if let Err(e) = validate_phantom_token(remaining_header, inject_header, ctx.session_token) { + let (auth_mechanism, injection_mode) = if spiffe_context.is_some() { + ( + nono::undo::NetworkAuditAuthMechanism::SpiffeOAuthAssertion, + nono::undo::NetworkAuditInjectionMode::OAuth2, + ) + } else { + ( + nono::undo::NetworkAuditAuthMechanism::PhantomHeader, + nono::undo::NetworkAuditInjectionMode::OAuth2, + ) + }; let deny_ctx = audit::EventContext { route_id: Some(service), - auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::PhantomHeader), + auth_mechanism: Some(auth_mechanism), auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed), managed_credential_active: Some(true), - injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2), + injection_mode: Some(injection_mode), + spiffe_context: spiffe_context.clone(), denial_category: Some(nono::undo::NetworkAuditDenialCategory::AuthenticationFailed), ..audit::EventContext::default() }; @@ -1011,36 +1379,12 @@ async fn handle_oauth2_credential( return Ok(()); } - let upstream_url = format!( - "{}{}", - oauth2_route.upstream.trim_end_matches('/'), - upstream_path - ); - debug!("OAuth2 forwarding to upstream: {} {}", method, upstream_url); - + let upstream_url = format!("{}{}", upstream.trim_end_matches('/'), upstream_path); let (upstream_scheme, upstream_host, upstream_port, upstream_path_full) = parse_upstream_url(&upstream_url)?; - // DNS resolve + host check via the filter let check = ctx.filter.check_host(&upstream_host, upstream_port).await?; if !check.result.is_allowed() { - let reason = check.result.reason(); - warn!("Upstream host denied by filter: {}", reason); send_error(stream, 403, "Forbidden").await?; - let route_ctx = audit::EventContext { - route_id: Some(service), - managed_credential_active: Some(true), - injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2), - denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied), - ..audit::EventContext::default() - }; - audit::log_denied( - ctx.audit_log, - audit::ProxyMode::Reverse, - &route_ctx, - service, - 0, - &reason, - ); return Ok(()); } if let Err(reason) = @@ -1048,36 +1392,21 @@ async fn handle_oauth2_credential( { warn!("{}", reason); send_error(stream, 502, "Bad Gateway").await?; - let route_ctx = audit::EventContext { - route_id: Some(service), - managed_credential_active: Some(true), - injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2), - denial_category: Some(nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed), - ..audit::EventContext::default() - }; - audit::log_denied( - ctx.audit_log, - audit::ProxyMode::Reverse, - &route_ctx, - service, - 0, - &reason, - ); return Ok(()); } - // Collect remaining request headers, stripping the client-supplied - // Authorization header that carries the phantom token. - let filtered_headers = filter_headers(remaining_header, "Authorization"); + // Strip the phantom header (whatever the client used) and any auth headers + // the client may have sent — we inject the real OAuth Bearer token below. + let filtered_headers = filter_headers_multi( + remaining_header, + &[inject_header, "Authorization", "x-api-key"], + ); let content_length = extract_content_length(remaining_header); - - // Read request body let body = match read_request_body(stream, content_length, buffered_body).await? { - Some(body) => body, + Some(b) => b, None => return Ok(()), }; - // Build upstream request with Bearer token injection let upstream_authority = format_host_header(upstream_scheme, &upstream_host, upstream_port); let scheme_str = match upstream_scheme { UpstreamScheme::Https => "https", @@ -1087,37 +1416,45 @@ async fn handle_oauth2_credential( "{}://{}{}", scheme_str, upstream_authority, upstream_path_full ); + let bearer_value = zeroize::Zeroizing::new(format!("Bearer {}", access_token.as_str())); - let bearer_value = Zeroizing::new(format!("Bearer {}", access_token.as_str())); let mut req_builder = http::Request::builder() .method(method) .uri(&uri) .header("Host", &upstream_authority) .header("Authorization", bearer_value.as_str()); - for (name, value) in &filtered_headers { req_builder = req_builder.header(name.as_str(), value.as_str()); } - let req = req_builder .body(http_body_util::Full::new(bytes::Bytes::from(body))) - .map_err(|e| ProxyError::HttpParse(format!("request build error: {}", e)))?; + .map_err(|e| ProxyError::HttpParse(format!("request build error: {e}")))?; let tls_config = route .tls_client_config .as_ref() .unwrap_or(ctx.default_tls_config); - ctx.upstream_pool .pin_host(&upstream_host, &check.resolved_addrs); + let (auth_mechanism, injection_mode) = if spiffe_context.is_some() { + ( + nono::undo::NetworkAuditAuthMechanism::SpiffeOAuthAssertion, + nono::undo::NetworkAuditInjectionMode::OAuth2, + ) + } else { + ( + nono::undo::NetworkAuditAuthMechanism::PhantomHeader, + nono::undo::NetworkAuditInjectionMode::OAuth2, + ) + }; let success_event = audit::EventContext { route_id: Some(service), - auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::PhantomHeader), + auth_mechanism: Some(auth_mechanism), auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded), managed_credential_active: Some(true), - injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2), - denial_category: None, + injection_mode: Some(injection_mode), + spiffe_context, ..audit::EventContext::default() }; @@ -1154,6 +1491,38 @@ async fn handle_oauth2_credential( Ok(()) } +#[allow(clippy::too_many_arguments)] +async fn handle_oauth2_credential( + oauth2_route: &crate::credential::OAuth2Route, + route: &crate::route::LoadedRoute, + service: &str, + upstream_path: &str, + method: &str, + _version: &str, + stream: &mut TcpStream, + remaining_header: &[u8], + buffered_body: &[u8], + ctx: &ReverseProxyCtx<'_>, +) -> Result<()> { + let access_token = oauth2_route.cache.get_or_refresh().await; + handle_oauth2_like( + access_token, + None, + &oauth2_route.upstream, + &oauth2_route.inject_header, + route, + service, + upstream_path, + method, + stream, + remaining_header, + buffered_body, + ctx, + ) + .await +} + +#[allow(clippy::too_many_arguments)] /// Read request body from the client stream with size limit. /// /// `buffered_body` contains bytes the BufReader read ahead beyond headers. @@ -1310,6 +1679,10 @@ fn validate_phantom_token( let header_name_lower = header_name.to_lowercase(); for line in header_str.lines() { + // RFC 7230: folded headers are obsolete; reject to prevent parser divergence. + if line.starts_with(' ') || line.starts_with('\t') { + return Err(ProxyError::InvalidToken); + } let lower = line.to_lowercase(); if lower.starts_with(&format!("{}:", header_name_lower)) { let value = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or(""); @@ -1350,6 +1723,33 @@ fn validate_phantom_token( /// the phantom token that must not be forwarded alongside the real credential). /// When `cred_header` is empty (no-credential route), all other headers /// including `Authorization` are passed through to the upstream. +pub(crate) fn filter_headers_multi(header_bytes: &[u8], strip: &[&str]) -> Vec<(String, String)> { + let prefixes: Vec = strip + .iter() + .filter(|h| !h.is_empty()) + .map(|h| format!("{}:", h.to_lowercase())) + .collect(); + let header_str = std::str::from_utf8(header_bytes).unwrap_or(""); + let mut headers = Vec::new(); + for line in header_str.lines() { + let lower = line.to_lowercase(); + if lower.starts_with("host:") + || lower.starts_with("content-length:") + || lower.starts_with("connection:") + || lower.starts_with("proxy-connection:") + || lower.starts_with("proxy-authorization:") + || prefixes.iter().any(|p| lower.starts_with(p.as_str())) + || line.trim().is_empty() + { + continue; + } + if let Some((name, value)) = line.split_once(':') { + headers.push((name.trim().to_string(), value.trim().to_string())); + } + } + headers +} + pub(crate) fn filter_headers(header_bytes: &[u8], cred_header: &str) -> Vec<(String, String)> { let header_str = std::str::from_utf8(header_bytes).unwrap_or(""); let cred_header_lower = if cred_header.is_empty() { @@ -1364,6 +1764,7 @@ pub(crate) fn filter_headers(header_bytes: &[u8], cred_header: &str) -> Vec<(Str if lower.starts_with("host:") || lower.starts_with("content-length:") || lower.starts_with("connection:") + || lower.starts_with("proxy-connection:") || lower.starts_with("proxy-authorization:") || (!cred_header_lower.is_empty() && lower.starts_with(&cred_header_lower)) || line.trim().is_empty() diff --git a/crates/nono-proxy/src/route.rs b/crates/nono-proxy/src/route.rs index 9012b20ae..f62db04b1 100644 --- a/crates/nono-proxy/src/route.rs +++ b/crates/nono-proxy/src/route.rs @@ -10,7 +10,7 @@ //! (inject mode, header name/value, raw secret). Both stores are keyed by the //! normalised route prefix and are consulted independently by the proxy handlers. -use crate::config::{CompiledEndpointPolicy, CompiledEndpointRules, RouteConfig}; +use crate::config::{CompiledEndpointPolicy, CompiledEndpointRules, RouteConfig, SpiffeAuthConfig}; use crate::error::{ProxyError, Result}; use nono::undo::{NetworkAuditAuthMechanism, NetworkAuditInjectionMode}; use rustls::pki_types::pem::PemObject; @@ -77,12 +77,17 @@ pub struct LoadedRoute { /// Audit injection mode implied by the managed credential configuration. pub managed_injection_mode: Option, + + /// Live SPIFFE auth source for this route, if configured. + /// Either X.509 mTLS or JWT bearer, chosen at load time from the route's + /// `spiffe` config block. `None` for non-SPIFFE routes. + pub managed_auth: Option>, } impl std::fmt::Debug for LoadedRoute { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LoadedRoute") - .field("upstream", &self.upstream) + let mut s = f.debug_struct("LoadedRoute"); + s.field("upstream", &self.upstream) .field("upstream_host_port", &self.upstream_host_port) .field("endpoint_rules", &self.endpoint_rules) .field("endpoint_policy", &self.endpoint_policy) @@ -93,12 +98,21 @@ impl std::fmt::Debug for LoadedRoute { &self.requires_managed_credential, ) .field("managed_auth_mechanism", &self.managed_auth_mechanism) - .field("managed_injection_mode", &self.managed_injection_mode) - .finish() + .field("managed_injection_mode", &self.managed_injection_mode); + let managed_auth_type = self.managed_auth.as_ref().map(|a| match a.as_ref() { + crate::auth::ManagedUpstreamAuth::SpiffeJwt(_) => "spiffe_jwt", + }); + s.field("managed_auth_type", &managed_auth_type); + s.finish() } } fn auth_mechanism_for_route(route: &RouteConfig) -> Option { + if let Some(spiffe) = &route.spiffe { + let SpiffeAuthConfig::Jwt { .. } = spiffe; + return Some(NetworkAuditAuthMechanism::SpiffeJwtBearer); + } + if route.oauth2.is_some() { return Some(NetworkAuditAuthMechanism::PhantomHeader); } @@ -127,6 +141,11 @@ fn auth_mechanism_for_route(route: &RouteConfig) -> Option Option { + if let Some(spiffe) = &route.spiffe { + let SpiffeAuthConfig::Jwt { .. } = spiffe; + return Some(NetworkAuditInjectionMode::SpiffeJwt); + } + if route.oauth2.is_some() { return Some(NetworkAuditInjectionMode::OAuth2); } @@ -164,7 +183,7 @@ impl RouteStore { /// Each route's endpoint rules are compiled at startup so the hot path /// does a regex match, not a glob compile. Routes with a `tls_ca` field /// get a per-route TLS connector built from the custom CA certificate. - pub fn load(routes: &[RouteConfig]) -> Result { + pub async fn load(routes: &[RouteConfig]) -> Result { let mut loaded = HashMap::new(); let base_root_store = build_base_root_store(); @@ -215,9 +234,61 @@ impl RouteStore { // they exist to provide a `*_BASE_URL` env var or appear in // `route_upstream_hosts()` — and CONNECT to those still gets // blocked with 403 (the "force SDK cooperation" path). + // Managed credential sources are mutually exclusive. + let managed_count = [ + route.spiffe.is_some(), + route.credential_key.is_some(), + route.oauth2.is_some(), + route.aws_auth.is_some(), + ] + .iter() + .filter(|&&v| v) + .count(); + if managed_count > 1 { + return Err(ProxyError::Config(format!( + "route '{}': `spiffe`, `credential_key`, `oauth2`, and `aws_auth` are mutually exclusive", + normalized_prefix + ))); + } + + // Connect to the SPIRE Workload API for SPIFFE routes. This blocks + // startup (fail-closed) if the socket is unreachable within the + // initial_sync_timeout configured on the source builder. + let managed_auth: Option> = + if let Some(SpiffeAuthConfig::Jwt { + workload_api_socket, + audience, + inject_header, + credential_format, + svid_hint, + }) = &route.spiffe + { + debug!( + "Connecting SPIFFE JWT source for route '{}'", + normalized_prefix + ); + let src = crate::spiffe::SpiffeJwtSource::connect( + workload_api_socket, + audience.clone(), + inject_header.clone(), + credential_format.clone(), + svid_hint.as_deref(), + ) + .await + .map_err(|e| { + ProxyError::Config(format!("route '{}': {e}", normalized_prefix)) + })?; + Some(Arc::new(crate::auth::ManagedUpstreamAuth::SpiffeJwt( + Arc::new(src), + ))) + } else { + None + }; + let requires_managed_credential = route.credential_key.is_some() || route.oauth2.is_some() - || route.aws_auth.is_some(); + || route.aws_auth.is_some() + || route.spiffe.is_some(); let requires_intercept = requires_managed_credential || !endpoint_policy.allows_all_without_l7(); let managed_auth_mechanism = auth_mechanism_for_route(route); @@ -237,6 +308,7 @@ impl RouteStore { requires_managed_credential, managed_auth_mechanism, managed_injection_mode, + managed_auth, }, ); } @@ -341,6 +413,66 @@ impl RouteStore { .filter_map(|route| route.upstream_host_port.clone()) .collect() } + + /// Route prefixes with successfully loaded SPIFFE sources. + #[must_use] + pub fn spiffe_loaded_prefixes(&self) -> std::collections::HashSet { + self.routes + .iter() + .filter(|(_, route)| route.has_spiffe_source()) + .map(|(prefix, _)| prefix.clone()) + .collect() + } + + /// Whether any managed-credential route targets a loopback upstream host. + /// + /// When true, loopback must not appear in `NO_PROXY` so traffic to the + /// credential upstream goes through the proxy (for SPIFFE injection etc.). + #[must_use] + pub fn has_managed_loopback_upstream(&self) -> bool { + self.routes.iter().any(|(_, route)| { + if !(route.requires_managed_credential || route.requires_intercept) { + return false; + } + route + .upstream_host_port + .as_deref() + .is_some_and(is_loopback_host_port) + }) + } +} + +/// Whether any configured route targets a loopback upstream that must traverse +/// the proxy (managed credentials, SPIFFE, or TLS-intercepted custom upstreams). +#[must_use] +pub fn config_has_loopback_proxy_route(routes: &[RouteConfig]) -> bool { + routes.iter().any(|route| { + let loopback = + extract_host_port(&route.upstream).is_some_and(|hp| is_loopback_host_port(&hp)); + if !loopback { + return false; + } + route.spiffe.is_some() + || route.credential_key.is_some() + || route.oauth2.is_some() + || route.aws_auth.is_some() + || route.tls_ca.is_some() + }) +} + +pub(crate) fn is_loopback_host_port(host_port: &str) -> bool { + let host = host_port + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(host_port); + let bare = host.trim_start_matches('[').trim_end_matches(']'); + host == "localhost" + || bare + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + || bare + .parse::() + .is_ok_and(|ip| ip.is_loopback()) } /// Outcome of route selection for an intercepted request. @@ -446,6 +578,12 @@ pub(crate) fn select_route<'a>( } impl LoadedRoute { + /// Whether this route has a live SPIFFE Workload API source configured. + #[must_use] + pub fn has_spiffe_source(&self) -> bool { + self.managed_auth.is_some() + } + /// Whether this route is configured to require a proxy-managed credential /// but the credential material is currently unavailable. #[must_use] @@ -454,8 +592,13 @@ impl LoadedRoute { has_static_credential: bool, has_oauth2: bool, has_aws: bool, + has_spiffe: bool, ) -> bool { - self.requires_managed_credential && !has_static_credential && !has_oauth2 && !has_aws + self.requires_managed_credential + && !has_static_credential + && !has_oauth2 + && !has_aws + && !has_spiffe } } @@ -556,6 +699,44 @@ fn build_base_root_store() -> rustls::RootCertStore { store } +/// Add PEM CA certificate(s) from `ca_path` into `root_store`. +pub(crate) fn add_ca_file_to_store( + root_store: &mut rustls::RootCertStore, + ca_path: &str, +) -> Result<()> { + let ca_path = std::path::Path::new(ca_path); + let ca_pem = read_pem_file(ca_path, "CA certificate")?; + + let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref()) + .collect::, _>>() + .map_err(|e| { + ProxyError::Config(format!( + "failed to parse CA certificate '{}': {}", + ca_path.display(), + e + )) + })?; + + if certs.is_empty() { + return Err(ProxyError::Config(format!( + "CA certificate file '{}' contains no valid PEM certificates", + ca_path.display() + ))); + } + + for cert in certs { + root_store.add(cert).map_err(|e| { + ProxyError::Config(format!( + "invalid CA certificate in '{}': {}", + ca_path.display(), + e + )) + })?; + } + + Ok(()) +} + /// Build a per-route `TlsConnector`, optionally adding a custom CA /// and/or mTLS client certificate on top of `base_root_store`. /// Returns both the connector and the underlying `Arc` so the @@ -568,37 +749,8 @@ fn build_tls_connector( ) -> Result<(tokio_rustls::TlsConnector, Arc)> { let mut root_store = base_root_store.clone(); - // Add custom CA if provided if let Some(ca_path) = ca_path { - let ca_path = std::path::Path::new(ca_path); - let ca_pem = read_pem_file(ca_path, "CA certificate")?; - - let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref()) - .collect::, _>>() - .map_err(|e| { - ProxyError::Config(format!( - "failed to parse CA certificate '{}': {}", - ca_path.display(), - e - )) - })?; - - if certs.is_empty() { - return Err(ProxyError::Config(format!( - "CA certificate file '{}' contains no valid PEM certificates", - ca_path.display() - ))); - } - - for cert in certs { - root_store.add(cert).map_err(|e| { - ProxyError::Config(format!( - "invalid CA certificate in '{}': {}", - ca_path.display(), - e - )) - })?; - } + add_ca_file_to_store(&mut root_store, ca_path)?; } let builder = rustls::ClientConfig::builder_with_provider(Arc::new( @@ -712,8 +864,8 @@ mod tests { assert!(store.get("openai").is_none()); } - #[test] - fn test_load_routes_without_credentials() { + #[tokio::test] + async fn test_load_routes_without_credentials() { // Routes without credential_key should still be loaded into RouteStore let routes = vec![RouteConfig { prefix: "/openai".to_string(), @@ -743,9 +895,10 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); assert_eq!(store.len(), 1); let route = store.get("openai").unwrap(); @@ -763,8 +916,8 @@ mod tests { ); } - #[test] - fn test_load_routes_normalises_prefix() { + #[tokio::test] + async fn test_load_routes_normalises_prefix() { let routes = vec![RouteConfig { prefix: "/anthropic/".to_string(), upstream: "https://api.anthropic.com".to_string(), @@ -784,15 +937,16 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); assert!(store.get("anthropic").is_some()); assert!(store.get("/anthropic/").is_none()); } - #[test] - fn test_is_route_upstream() { + #[tokio::test] + async fn test_is_route_upstream() { let routes = vec![RouteConfig { prefix: "openai".to_string(), upstream: "https://api.openai.com".to_string(), @@ -812,15 +966,16 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); assert!(store.is_route_upstream("api.openai.com:443")); assert!(!store.is_route_upstream("github.com:443")); } - #[test] - fn test_route_upstream_hosts() { + #[tokio::test] + async fn test_route_upstream_hosts() { let routes = vec![ RouteConfig { prefix: "openai".to_string(), @@ -841,6 +996,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, RouteConfig { prefix: "anthropic".to_string(), @@ -861,10 +1017,11 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, ]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); let hosts = store.route_upstream_hosts(); assert!(hosts.contains("api.openai.com:443")); assert!(hosts.contains("api.anthropic.com:443")); @@ -949,6 +1106,7 @@ mod tests { requires_managed_credential: false, managed_auth_mechanism: None, managed_injection_mode: None, + managed_auth: None, }; let debug_output = format!("{:?}", route); assert!(debug_output.contains("api.openai.com")); @@ -959,8 +1117,8 @@ mod tests { assert!(debug_output.contains("managed_injection_mode")); } - #[test] - fn test_requires_intercept_credential_only() { + #[tokio::test] + async fn test_requires_intercept_credential_only() { let routes = vec![RouteConfig { prefix: "openai".to_string(), upstream: "https://api.openai.com".to_string(), @@ -980,8 +1138,9 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); let hit = store.lookup_by_upstream("api.openai.com:443").unwrap(); assert!(store.has_intercept_route("api.openai.com:443")); assert!(hit.1.requires_managed_credential); @@ -996,8 +1155,8 @@ mod tests { assert!(!store.has_intercept_route("api.example.com:443")); } - #[test] - fn test_requires_intercept_wildcard_credential_upstream() { + #[tokio::test] + async fn test_requires_intercept_wildcard_credential_upstream() { let routes = vec![RouteConfig { prefix: "internal_api".to_string(), upstream: "https://*.dev.example.net".to_string(), @@ -1017,8 +1176,9 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); assert!(store.is_route_upstream("api.admin.dev.example.net:443")); assert!(store.has_intercept_route("api.admin.dev.example.net:443")); @@ -1036,8 +1196,8 @@ mod tests { assert!(!store.has_intercept_route("api.admin.dev.example.net:8443")); } - #[test] - fn test_requires_intercept_endpoint_rules_only() { + #[tokio::test] + async fn test_requires_intercept_endpoint_rules_only() { // L7-only route (no credential): rules alone are enough to require // interception. let routes = vec![RouteConfig { @@ -1062,8 +1222,9 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); let hit = store .lookup_by_upstream("internal.example.com:443") .unwrap(); @@ -1071,8 +1232,8 @@ mod tests { assert!(!hit.1.requires_managed_credential); } - #[test] - fn test_requires_intercept_declarative_only() { + #[tokio::test] + async fn test_requires_intercept_declarative_only() { // No credential, no rules — purely declarative route. CONNECT to // this upstream still gets the existing 403 (not intercepted). let routes = vec![RouteConfig { @@ -1094,14 +1255,15 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); assert!(store.is_route_upstream("aliased.example.com:443")); assert!(!store.has_intercept_route("aliased.example.com:443")); } - #[test] - fn test_missing_managed_credential_policy() { + #[tokio::test] + async fn test_missing_managed_credential_policy() { let managed = LoadedRoute { upstream: "https://api.openai.com".to_string(), upstream_host_port: Some("api.openai.com:443".to_string()), @@ -1114,11 +1276,13 @@ mod tests { requires_managed_credential: true, managed_auth_mechanism: Some(NetworkAuditAuthMechanism::PhantomHeader), managed_injection_mode: Some(NetworkAuditInjectionMode::Header), + managed_auth: None, }; - assert!(managed.missing_managed_credential(false, false, false)); - assert!(!managed.missing_managed_credential(true, false, false)); - assert!(!managed.missing_managed_credential(false, true, false)); - assert!(!managed.missing_managed_credential(false, false, true)); + assert!(managed.missing_managed_credential(false, false, false, false)); + assert!(!managed.missing_managed_credential(true, false, false, false)); + assert!(!managed.missing_managed_credential(false, true, false, false)); + assert!(!managed.missing_managed_credential(false, false, true, false)); + assert!(!managed.missing_managed_credential(false, false, false, true)); let l7_only = LoadedRoute { upstream: "https://internal.example.com".to_string(), @@ -1132,12 +1296,13 @@ mod tests { requires_managed_credential: false, managed_auth_mechanism: None, managed_injection_mode: None, + managed_auth: None, }; - assert!(!l7_only.missing_managed_credential(false, false, false)); + assert!(!l7_only.missing_managed_credential(false, false, false, false)); } - #[test] - fn test_lookup_by_upstream_returns_prefix() { + #[tokio::test] + async fn test_lookup_by_upstream_returns_prefix() { let routes = vec![RouteConfig { prefix: "openai".to_string(), upstream: "https://api.openai.com".to_string(), @@ -1157,8 +1322,9 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); let hit = store.lookup_by_upstream("api.openai.com:443").unwrap(); assert_eq!(hit.0, "openai"); assert!(hit.1.requires_intercept); @@ -1166,8 +1332,8 @@ mod tests { assert!(store.lookup_by_upstream("api.example.com:443").is_none()); } - #[test] - fn test_lookup_all_by_upstream_returns_multiple_routes() { + #[tokio::test] + async fn test_lookup_all_by_upstream_returns_multiple_routes() { let routes = vec![ RouteConfig { prefix: "github_org_a".to_string(), @@ -1191,6 +1357,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, RouteConfig { prefix: "github_org_b".to_string(), @@ -1214,9 +1381,10 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, ]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); let all = store.lookup_all_by_upstream("github.com:443"); assert_eq!(all.len(), 2, "both routes share the same upstream"); @@ -1265,8 +1433,8 @@ mod tests { /// 1 match → inject that route's credential /// 0 matches → passthrough (no credential injected) /// 2+ matches → ambiguous (hard-deny 403) - #[test] - fn test_route_selection_multi_org_profile() { + #[tokio::test] + async fn test_route_selection_multi_org_profile() { // Helper to build a route with the given prefix and endpoint path. fn gh_route(prefix: &str, env: &str, path: &str) -> RouteConfig { RouteConfig { @@ -1291,6 +1459,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, } } @@ -1299,7 +1468,7 @@ mod tests { gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"), gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"), ]; - let store = RouteStore::load(&routes).unwrap(); + let store = RouteStore::load(&routes).await.unwrap(); let candidates = store.lookup_all_by_upstream("github.com:443"); assert_eq!(candidates.len(), 2); @@ -1330,7 +1499,7 @@ mod tests { gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"), gh_route("github_https_all", "GH_TOKEN_A", "/**"), ]; - let store2 = RouteStore::load(&routes_with_catchall).unwrap(); + let store2 = RouteStore::load(&routes_with_catchall).await.unwrap(); let candidates2 = store2.lookup_all_by_upstream("github.com:443"); assert_eq!(candidates2.len(), 3); @@ -1349,8 +1518,8 @@ mod tests { /// A credential-less `_ep_` authorization route (from `allow_domain` with /// endpoints) must not shadow a credential catch-all on a path the `_ep_` /// route authorizes — the token has to be injected, not silently dropped. - #[test] - fn test_route_selection_credential_catchall_not_shadowed() { + #[tokio::test] + async fn test_route_selection_credential_catchall_not_shadowed() { // `_ep_` endpoint-authorization route: no credential, scoped to /org/**. let ep_route = RouteConfig { prefix: "_ep_github.com".to_string(), @@ -1371,7 +1540,7 @@ mod tests { ..Default::default() }; - let store = RouteStore::load(&[ep_route, cred_route]).unwrap(); + let store = RouteStore::load(&[ep_route, cred_route]).await.unwrap(); let candidates = store.lookup_all_by_upstream("github.com:443"); assert_eq!(candidates.len(), 2); @@ -1393,8 +1562,8 @@ mod tests { /// Two credential catch-alls for the same upstream are ambiguous: the proxy /// must not silently pick one to inject, just as with overlapping endpoint /// credential routes. - #[test] - fn test_route_selection_dual_credential_catchall_is_ambiguous() { + #[tokio::test] + async fn test_route_selection_dual_credential_catchall_is_ambiguous() { let cred_a = RouteConfig { prefix: "github_a".to_string(), upstream: "https://github.com".to_string(), @@ -1412,7 +1581,7 @@ mod tests { ..Default::default() }; - let store = RouteStore::load(&[cred_a, cred_b]).unwrap(); + let store = RouteStore::load(&[cred_a, cred_b]).await.unwrap(); let candidates = store.lookup_all_by_upstream("github.com:443"); assert_eq!(candidates.len(), 2); @@ -1673,8 +1842,8 @@ h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ assert!(err.contains("client key"), "unexpected error: {}", err); } - #[test] - fn test_route_store_loads_mtls_route() { + #[tokio::test] + async fn test_route_store_loads_mtls_route() { // Verify RouteStore.load() builds a TLS connector when tls_client_cert/key are set. let dir = tempfile::tempdir().unwrap(); let cert_path = dir.path().join("client.crt"); @@ -1701,9 +1870,12 @@ h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ tls_client_key: Some(key_path.to_str().unwrap().to_string()), oauth2: None, aws_auth: None, + spiffe: None, }]; - let store = RouteStore::load(&routes).expect("should load mTLS route"); + let store = RouteStore::load(&routes) + .await + .expect("should load mTLS route"); let route = store.get("k8s").unwrap(); assert!( route.tls_connector.is_some(), diff --git a/crates/nono-proxy/src/server.rs b/crates/nono-proxy/src/server.rs index 51e8490a0..a0deee597 100644 --- a/crates/nono-proxy/src/server.rs +++ b/crates/nono-proxy/src/server.rs @@ -202,6 +202,9 @@ pub struct ProxyHandle { /// Non-credential allowed hosts that should bypass the proxy (NO_PROXY). /// Computed at startup: `allowed_hosts` minus credential upstream hosts. no_proxy_hosts: Vec, + /// When true, loopback must not appear in `NO_PROXY` because a managed + /// credential route targets a loopback upstream host. + managed_loopback_upstream: bool, /// Path to the TLS-intercept trust bundle written at startup, when /// interception is active. The CLI passes this path to the sandboxed /// child via env vars (`SSL_CERT_FILE` etc.) and grants a Landlock / @@ -358,6 +361,7 @@ impl ProxyHandle { && group.iter().any(|r| { r.credential_key.is_some() || r.oauth2.is_some() + || r.spiffe.is_some() || !r.endpoint_rules.is_empty() || r.endpoint_policy.is_some() }) { @@ -409,6 +413,13 @@ impl ProxyHandle { } else { "creds: oauth2 ✗ (token exchange failed)".to_string() } + } else if route.spiffe.is_some() { + let resolved = self.loaded_routes.contains(prefix); + if resolved { + "creds: spiffe ✓".to_string() + } else { + "creds: spiffe ✗ (Workload API unavailable)".to_string() + } } else { "creds: none".to_string() } @@ -430,10 +441,14 @@ impl ProxyHandle { pub fn env_vars(&self) -> Vec<(String, String)> { let proxy_url = format!("http://nono:{}@127.0.0.1:{}", *self.token, self.port); - // Build NO_PROXY: always include loopback, plus non-credential - // allowed hosts. Credential upstreams are excluded so their traffic - // goes through the reverse proxy for L7 filtering + injection. + // Build NO_PROXY: include loopback unless a managed credential route + // targets a loopback upstream (those must traverse the proxy). Also add + // non-credential allowed hosts. Credential upstreams are excluded so + // their traffic goes through the reverse proxy for L7 filtering + injection. let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + if self.managed_loopback_upstream { + no_proxy_parts.retain(|h| h != "localhost" && h != "127.0.0.1"); + } for host in &self.no_proxy_hosts { // Strip port for NO_PROXY (most HTTP clients match on hostname). // Handle IPv6 brackets: "[::1]:443" → "[::1]", "host:443" → "host" @@ -532,6 +547,22 @@ impl ProxyHandle { let api_key_name = cred_key.to_uppercase(); vars.push((api_key_name, self.token.to_string())); } + } else if route.spiffe.is_some() { + // SPIFFE routes use the same phantom token pattern for SDK-style + // `*_BASE_URL` clients even though upstream auth is SPIFFE. + let api_key_name = format!("{}_API_KEY", prefix.to_uppercase()); + vars.push((api_key_name, self.token.to_string())); + } else if route + .oauth2 + .as_ref() + .and_then(|o| o.client_assertion.as_ref()) + .is_some() + { + // OAuth2 jwt-bearer assertion routes need the same phantom token + // pattern — the proxy validates session integrity before injecting + // the exchanged access token, so the child process must present it. + let api_key_name = format!("{}_API_KEY", prefix.to_uppercase()); + vars.push((api_key_name, self.token.to_string())); } } vars @@ -610,6 +641,11 @@ struct ProxyState { /// Per-host HTTP/2 capability cache. Populated by pre-flight probes so /// the inbound acceptor only advertises h2 when the upstream supports it. h2_cache: Arc, + /// Actual bound port (OS-assigned when config.bind_port is 0). + /// Used by `handle_forward_http` to detect requests targeting the proxy + /// itself (absolute-form `http://127.0.0.1:{bound_port}/…`) and re-route + /// them to the reverse-proxy credential-injection path. + bound_port: u16, } struct CompositeNonceResolver { @@ -705,7 +741,7 @@ pub async fn start_with_nonce_resolver( let route_store = if config.routes.is_empty() { RouteStore::empty() } else { - RouteStore::load(&config.routes)? + RouteStore::load(&config.routes).await? }; let oauth_capture_store = OAuthCaptureStore::load_with_persistence( &config.oauth_capture, @@ -770,7 +806,17 @@ pub async fn start_with_nonce_resolver( CredentialStore::load_with_diagnostics(&config.routes, &tls_connector).await?; (outcome.store, outcome.diagnostics) }; - let loaded_routes = credential_store.loaded_prefixes(); + let mut loaded_routes = credential_store.loaded_prefixes(); + loaded_routes.extend(route_store.spiffe_loaded_prefixes()); + let config_loopback_upstream = crate::route::config_has_loopback_proxy_route(&config.routes); + let managed_loopback_upstream = + route_store.has_managed_loopback_upstream() || config_loopback_upstream; + if config_loopback_upstream && !route_store.has_managed_loopback_upstream() { + debug!( + "NO_PROXY: clearing loopback via config route match ({} route(s))", + config.routes.len() + ); + } // Build filter. Strict mode treats an empty allowlist as deny-all. let filter = if config.strict_filter { @@ -936,6 +982,7 @@ pub async fn start_with_nonce_resolver( cert_cache, enable_h2, h2_cache: UpstreamH2Cache::new(), + bound_port: port, }); // Spawn accept loop as a task within the current runtime. @@ -950,6 +997,7 @@ pub async fn start_with_nonce_resolver( shutdown_tx, loaded_routes, no_proxy_hosts, + managed_loopback_upstream, intercept_ca_path, intercept_ca_env_vars, diagnostics: proxy_diagnostics, @@ -1552,6 +1600,40 @@ async fn handle_forward_http( // 2. Parse host+port and run the host filter (DNS resolution + SSRF guard). let (host, port) = parse_non_connect_target(first_line)?; + + // Self-request: the absolute URL targets the proxy's own address. + // + // When `MOCKAPI_BASE_URL = http://127.0.0.1:{proxy_port}/mockapi` and + // loopback is absent from NO_PROXY (because a managed-credential route + // has a loopback upstream), HTTP_PROXY-aware clients such as curl send + // the request in absolute-form to the proxy. Without this check the + // request would enter the transparent forward path and try to connect to + // the proxy itself (a self-loop), bypassing credential injection. + // + // Detect the pattern and delegate to `handle_reverse_proxy`, which + // already strips the scheme+authority from absolute-form URLs before + // routing by path prefix, so credential injection (including SPIFFE JWT) + // works correctly. + if port == state.bound_port + && (host == "127.0.0.1" || host == "localhost" || host == "[::1]" || host == "::1") + { + let ctx = reverse::ReverseProxyCtx { + route_store: &state.route_store, + credential_store: &state.credential_store, + session_token: &state.session_token, + require_auth: state.config.require_auth, + filter: &state.filter, + tls_connector: &state.tls_connector, + default_tls_config: &state.default_tls_config, + upstream_pool: &state.upstream_pool, + audit_log: Some(&state.audit_log), + approval_backends: state.approval_backends.clone(), + credential_capture_backend: state.credential_capture_backend.clone(), + }; + return reverse::handle_reverse_proxy(first_line, stream, header_bytes, &ctx, buffered) + .await; + } + let check = state.filter.check_host(&host, port).await?; if !check.result.is_allowed() { let reason = check.result.reason(); @@ -1794,6 +1876,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, } } @@ -1956,6 +2039,7 @@ mod tests { shutdown_tx, loaded_routes: std::collections::HashSet::new(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -1998,6 +2082,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], intercept_ca_dir: Some(dir.path().to_path_buf()), intercept_ca_env_vars: { @@ -2074,6 +2159,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], intercept_ca_dir: Some(dir.path().to_path_buf()), ..Default::default() @@ -2157,6 +2243,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], intercept_ca_dir: Some(missing_dir), ..Default::default() @@ -2205,6 +2292,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, crate::config::RouteConfig { prefix: "alias".to_string(), @@ -2225,6 +2313,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, ], intercept_ca_dir: Some(dir.path().to_path_buf()), @@ -2285,6 +2374,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, // Synthetic endpoint-authorization route for the same upstream. crate::config::RouteConfig { @@ -2306,6 +2396,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, ], intercept_ca_dir: Some(dir.path().to_path_buf()), @@ -2366,6 +2457,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, // `_ep_` route on a concrete subdomain covered by the wildcard. crate::config::RouteConfig { @@ -2387,6 +2479,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, ], intercept_ca_dir: Some(dir.path().to_path_buf()), @@ -2437,6 +2530,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }; let config = ProxyConfig { routes: vec![ @@ -2485,6 +2579,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }; let config = ProxyConfig { routes: vec![ @@ -2560,6 +2655,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2586,6 +2682,7 @@ mod tests { shutdown_tx, loaded_routes: ["openai".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -2610,6 +2707,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2645,6 +2743,7 @@ mod tests { shutdown_tx, loaded_routes: ["openai".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -2669,6 +2768,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2709,6 +2809,7 @@ mod tests { // Only "openai" was loaded; "github" credential was unavailable loaded_routes: ["openai".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -2734,6 +2835,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, crate::config::RouteConfig { prefix: "github".to_string(), @@ -2754,6 +2856,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }, ], ..Default::default() @@ -2781,6 +2884,61 @@ mod tests { ); } + #[test] + fn test_proxy_credential_env_vars_injects_spiffe_phantom_token() { + let (shutdown_tx, _) = tokio::sync::watch::channel(false); + let handle = ProxyHandle { + port: 12345, + token: Zeroizing::new("session_token".to_string()), + audit_log: audit::new_audit_log(), + shutdown_tx, + loaded_routes: ["myapi".to_string()].into_iter().collect(), + no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, + intercept_ca_path: None, + intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), + diagnostics: vec![], + }; + let config = ProxyConfig { + routes: vec![crate::config::RouteConfig { + prefix: "myapi".to_string(), + upstream: "https://api.internal.corp".to_string(), + credential_key: None, + inject_mode: crate::config::InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: None, + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + endpoint_policy: None, + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + oauth2: None, + aws_auth: None, + spiffe: Some(crate::config::SpiffeAuthConfig::Jwt { + workload_api_socket: "/tmp/spire.sock".to_string(), + audience: vec!["api.internal.corp".to_string()], + inject_header: "Authorization".to_string(), + credential_format: None, + svid_hint: None, + }), + }], + ..Default::default() + }; + + let vars = handle.credential_env_vars(&config); + let api_key = vars.iter().find(|(k, _)| k == "MYAPI_API_KEY"); + assert!( + api_key.is_some(), + "SPIFFE route should inject phantom API key" + ); + assert_eq!(api_key.unwrap().1, "session_token"); + } + #[test] fn test_proxy_credential_env_vars_strips_slashes() { // When prefix includes leading/trailing slashes, the env var name @@ -2795,6 +2953,7 @@ mod tests { shutdown_tx, loaded_routes: std::collections::HashSet::new(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -2821,6 +2980,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2857,6 +3017,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2889,6 +3050,7 @@ mod tests { shutdown_tx: shutdown_tx.clone(), loaded_routes: ["anthropic".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -2913,6 +3075,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2934,6 +3097,7 @@ mod tests { shutdown_tx: shutdown_tx2, loaded_routes: ["anthropic".to_string()].into_iter().collect(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -2958,6 +3122,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; @@ -2983,6 +3148,7 @@ mod tests { "nats.internal:4222".to_string(), "opencode.internal:4096".to_string(), ], + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -3014,6 +3180,7 @@ mod tests { shutdown_tx, loaded_routes: std::collections::HashSet::new(), no_proxy_hosts: Vec::new(), + managed_loopback_upstream: false, intercept_ca_path: None, intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), diagnostics: vec![], @@ -3027,6 +3194,30 @@ mod tests { ); } + #[test] + fn test_no_proxy_omits_loopback_for_managed_loopback_upstream() { + let (shutdown_tx, _) = tokio::sync::watch::channel(false); + let handle = ProxyHandle { + port: 12345, + token: Zeroizing::new("test_token".to_string()), + audit_log: audit::new_audit_log(), + shutdown_tx, + loaded_routes: std::collections::HashSet::new(), + no_proxy_hosts: Vec::new(), + managed_loopback_upstream: true, + intercept_ca_path: None, + intercept_ca_env_vars: crate::config::default_intercept_ca_env_vars(), + diagnostics: vec![], + }; + + let vars = handle.env_vars(); + let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap(); + assert_eq!( + no_proxy.1, "", + "loopback credential upstreams must traverse the proxy" + ); + } + #[tokio::test] async fn test_no_proxy_empty_without_direct_connect_ports() { // When direct_connect_ports is empty (no --allow-connect-port), @@ -3151,6 +3342,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], intercept_ca_dir: Some(dir.path().to_path_buf()), ..Default::default() @@ -3491,6 +3683,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..ProxyConfig::default() }; @@ -3703,6 +3896,7 @@ mod tests { tls_client_key: None, oauth2: None, aws_auth: None, + spiffe: None, }], ..Default::default() }; diff --git a/crates/nono-proxy/src/spiffe.rs b/crates/nono-proxy/src/spiffe.rs new file mode 100644 index 000000000..d2a429139 --- /dev/null +++ b/crates/nono-proxy/src/spiffe.rs @@ -0,0 +1,192 @@ +//! SPIFFE/SPIRE Workload API credential sources. +//! +//! - [`SpiffeJwtSource`] — fetches JWT-SVIDs on demand, refreshes before expiry. +//! +//! X.509-SVID support (mTLS connector with atomic rotation) is planned as a +//! future `SpiffeAuthConfig` variant once the TLS-intercept CONNECT path is +//! wired to support it for HTTPS upstreams. +//! +//! SVID private key material is never written to disk or logged. + +use crate::error::{ProxyError, Result}; +use base64::Engine as _; +use spiffe_workload::{JwtSource, SpiffeId}; +use std::sync::Arc; +use tracing::{debug, warn}; +use zeroize::Zeroizing; + +// Warn when a freshly-fetched JWT-SVID has fewer than this many seconds left — +// indicates SPIRE JWT TTL is too short. Refresh is per-request via the agent cache. +const JWT_REFRESH_SECS: i64 = 60; + +/// JWT-SVID source backed by the SPIRE Workload API. +/// +/// Tokens are fetched on demand via the agent cache; a warning is logged when +/// fewer than `JWT_REFRESH_SECS` remain (see `fetch_token`). +pub struct SpiffeJwtSource { + inner: Arc, + pub audience: Vec, + pub inject_header: String, + pub credential_format: Option, + pub svid_hint: Option, +} + +impl std::fmt::Debug for SpiffeJwtSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpiffeJwtSource").finish() + } +} + +impl SpiffeJwtSource { + /// Fails closed if the socket is unreachable. + pub async fn connect( + socket_path: &str, + audience: Vec, + inject_header: String, + credential_format: Option, + svid_hint: Option<&str>, + ) -> Result { + let endpoint = format!("unix:{socket_path}"); + let source = JwtSource::builder() + .endpoint(&endpoint) + .initial_sync_timeout(std::time::Duration::from_secs(10)) + .build() + .await + .map_err(|e| { + ProxyError::Config(format!( + "SPIFFE JWT source failed to connect to '{socket_path}': {e}" + )) + })?; + + let svid_hint = svid_hint + .map(|h| { + SpiffeId::new(h).map_err(|e| { + ProxyError::Config(format!("invalid svid_hint SPIFFE ID '{h}': {e}")) + }) + }) + .transpose()?; + + debug!("SPIFFE JWT source connected to {}", socket_path); + Ok(Self { + inner: Arc::new(source), + audience, + inject_header, + credential_format, + svid_hint, + }) + } + + /// Returns `(token, spiffe_id)` for the requested audience. + pub async fn fetch_token(&self, audience: &[String]) -> Result<(Zeroizing, String)> { + let svid = self + .inner + .fetch_jwt_svid_with_id(audience.iter().map(String::as_str), self.svid_hint.as_ref()) + .await + .map_err(|e| ProxyError::Credential(format!("SPIFFE JWT-SVID fetch failed: {e}")))?; + + let now_ts = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(d) => i64::try_from(d.as_secs()).unwrap_or(i64::MAX), + Err(e) => i64::try_from(e.duration().as_secs()) + .map(|s| -s) + .unwrap_or(i64::MIN), + }; + let exp_ts = svid.expiry().unix_timestamp(); + let remaining = exp_ts - now_ts; + + if remaining <= 0 { + return Err(ProxyError::Credential(format!( + "SPIFFE JWT-SVID has already expired ({}s ago); \ + check SPIRE JWT TTL and system clock skew", + -remaining + ))); + } + + check_nbf(svid.token(), now_ts)?; + + if remaining < JWT_REFRESH_SECS { + warn!( + "SPIFFE JWT-SVID expires in {}s, below the {}s refresh threshold; \ + check SPIRE JWT TTL configuration", + remaining, JWT_REFRESH_SECS + ); + } + + let spiffe_id = svid.spiffe_id().to_string(); + Ok((Zeroizing::new(svid.token().to_string()), spiffe_id)) + } +} + +/// Rejects a JWT whose `nbf` claim is in the future. +/// +/// SPIRE does not expose `nbf` via a dedicated SDK accessor, so we parse the +/// raw base64url payload. If the token has no `nbf` claim we pass — SPIRE +/// typically omits it when `nbf == iat`. +fn check_nbf(token: &str, now_ts: i64) -> crate::error::Result<()> { + let Some(payload_b64) = token.split('.').nth(1) else { + return Ok(()); + }; + let Ok(decoded) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) else { + return Ok(()); + }; + let Ok(claims) = serde_json::from_slice::(&decoded) else { + return Ok(()); + }; + if let Some(nbf) = claims.get("nbf").and_then(|v| v.as_i64()) + && now_ts < nbf + { + return Err(ProxyError::Credential(format!( + "SPIFFE JWT-SVID is not yet valid (nbf={nbf}, now={now_ts}); \ + check system clock skew" + ))); + } + Ok(()) +} + +/// Parses the `act` claim from a JWT-SVID payload for audit context. +/// +/// SPIRE has already verified the signature; this is JSON parsing only. +/// Returns `None` when no `act` claim is present. +pub fn delegation_from_jwt(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + + let act = claims.get("act")?; + let authorized_by = act.get("sub")?.as_str()?.to_string(); + let on_behalf_of = claims + .get("sub") + .and_then(|s| s.as_str()) + .map(str::to_string); + + let mut depth: u32 = 1; + let mut cursor = act; + while let Some(nested) = cursor.get("act") { + depth = depth.saturating_add(1); + cursor = nested; + } + + Some(nono::undo::SpiffeDelegationContext { + authorized_by, + on_behalf_of, + chain_depth: depth, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use spiffe_workload::JwtSource; + + #[tokio::test] + async fn test_jwt_source_fails_closed_on_missing_socket() { + let endpoint = "unix:/tmp/nono-test-nonexistent-spire-agent.sock"; + let result = JwtSource::builder() + .endpoint(endpoint) + .initial_sync_timeout(std::time::Duration::from_secs(1)) + .build() + .await; + assert!(result.is_err(), "should fail when socket does not exist"); + } +} diff --git a/crates/nono-proxy/src/tls_intercept/h2_forward.rs b/crates/nono-proxy/src/tls_intercept/h2_forward.rs index 246289f16..2562f98b1 100644 --- a/crates/nono-proxy/src/tls_intercept/h2_forward.rs +++ b/crates/nono-proxy/src/tls_intercept/h2_forward.rs @@ -43,6 +43,9 @@ struct SharedH2Ctx { approval_backends: Option, credential_capture_backend: Option>, nonce_resolver: Option>, + /// Managed upstream auth source shared across all streams on this connection. + /// `None` for non-SPIFFE upstreams. + managed_auth: Option>, } /// Accept an h2 connection from the client, open an h2 connection to the @@ -76,8 +79,30 @@ where return Ok(()); } - // Open upstream TLS with h2 ALPN. - let upstream_tls = open_upstream_h2(ctx, &check.resolved_addrs).await?; + // Look up SPIFFE routes for this upstream to determine the correct TLS + // connector and JWT injection configuration. + let host_port = format!("{}:{}", ctx.host, ctx.port); + let spiffe_routes: Vec<(_, &crate::route::LoadedRoute)> = + ctx.route_store.lookup_all_by_upstream(&host_port); + + // Find the first route with a ManagedUpstreamAuth source. All routes on the + // same upstream share the same SPIRE socket, so using the first match is correct. + let managed_auth: Option> = spiffe_routes + .iter() + .find_map(|(_, route)| route.managed_auth.as_ref().map(Arc::clone)); + + // No per-connection SPIFFE mTLS setup — X.509-SVID support is planned for + // a future PR once the TLS-intercept CONNECT path is fully wired. + let spiffe_connector: Option = None; + + // Use the SPIFFE mTLS connector when available, otherwise fall back to the + // default h2 TLS connector. + let upstream_tls = open_upstream_h2( + ctx, + &check.resolved_addrs, + spiffe_connector.as_ref().unwrap_or(ctx.tls_connector_h2), + ) + .await?; let (h2_client, h2_conn) = h2::client::Builder::new() .max_send_buffer_size(1024 * 1024) @@ -109,6 +134,7 @@ where approval_backends: ctx.approval_backends.clone(), credential_capture_backend: ctx.credential_capture_backend.clone(), nonce_resolver: ctx.nonce_resolver.clone(), + managed_auth, }; let mut tasks = tokio::task::JoinSet::new(); @@ -170,16 +196,19 @@ where } /// Open upstream TLS with h2 ALPN. +/// +/// `tls_connector` is the connector to use for the upstream TLS handshake. async fn open_upstream_h2( ctx: &InterceptCtx<'_>, resolved_addrs: &[SocketAddr], + tls_connector: &tokio_rustls::TlsConnector, ) -> Result> { let upstream_spec = UpstreamSpec { scheme: UpstreamScheme::Https, host: ctx.host, port: ctx.port, strategy: handle::select_upstream_strategy(&ctx.upstream_proxy, resolved_addrs), - tls_connector: ctx.tls_connector_h2, + tls_connector, }; let tcp = forward::open_tcp_upstream(&upstream_spec).await?; let server_name = @@ -189,7 +218,7 @@ async fn open_upstream_h2( reason: "invalid server name for TLS".to_string(), } })?; - ctx.tls_connector_h2 + tls_connector .connect(server_name, tcp) .await .map_err(|e| ProxyError::UpstreamConnect { @@ -234,6 +263,9 @@ async fn handle_h2_stream( // legacy `endpoint_rules` API here would silently bypass endpoint_policy on // gRPC traffic. let method_str = method.as_str().to_string(); + + // Audit context is populated per-stream when credentials are acquired. + let mut spiffe_audit_ctx: Option = None; let selected = match handle::select_intercept_route( &ctx.route_store, &ctx.host, @@ -279,6 +311,32 @@ async fn handle_h2_stream( }; let cred = resolved.as_ref().map(|c| c.as_ref()); + // SPIFFE assertion route: fetch access token, fail hard if SVID is gone. + let spiffe_assertion_route = service.and_then(|s| ctx.credential_store.get_spiffe_assertion(s)); + let spiffe_assertion_token = if let Some(assertion_route) = spiffe_assertion_route { + match assertion_route.cache.get_or_refresh().await { + Ok(token) => { + let id = &assertion_route.cache.workload_spiffe_id; + spiffe_audit_ctx = Some(nono::undo::SpiffeAuditContext { + trust_domain: crate::auth::extract_trust_domain(id), + workload_spiffe_id: id.clone(), + svid_type: "jwt".to_string(), + source: "spire-workload-api".to_string(), + upstream_spiffe_id: None, + delegation: None, + }); + Some(token) + } + Err(e) => { + warn!("h2_forward: SPIFFE assertion token unavailable: {}", e); + send_h2_error(&mut respond, 503)?; + return Ok(()); + } + } + } else { + None + }; + // Build transformed path (credential injection into path/query if needed). // Shared with the HTTP/1.1 path so URL-mode injection cannot diverge. let transformed_path = reverse::transform_path_for_credential(cred, &path)?; @@ -288,6 +346,12 @@ async fn handle_h2_stream( // computed by the shared helper so the h2 path strips/injects exactly the // same headers as HTTP/1.1 — including any `extra_headers`. let injected_header_names = reverse::injected_credential_header_names(cred); + // Strip the managed-auth inject header so a client-supplied copy cannot + // survive alongside the injected value. + let spiffe_inject_header_lower: Option = + ctx.managed_auth.as_ref().map(|auth| match auth.as_ref() { + crate::auth::ManagedUpstreamAuth::SpiffeJwt(src) => src.inject_header.to_lowercase(), + }); // Resolve tool-sandbox broker nonces (`nono_<64hex>`) in forwarded header // values, mirroring the HTTP/1.1 path. Without this, an h2/gRPC request that // carries a broker nonce in a header would forward the raw nonce upstream @@ -297,8 +361,18 @@ async fn handle_h2_stream( for (name, value) in request.headers() { let name_lower = name.as_str().to_lowercase(); // Skip hop-by-hop and connection-specific headers. - if name_lower == "host" || name_lower == "connection" || name_lower == "proxy-authorization" - { + // RFC 7540 §8.1.2.2: strip hop-by-hop headers before h2 forwarding. + if matches!( + name_lower.as_str(), + "host" + | "connection" + | "proxy-authorization" + | "te" + | "transfer-encoding" + | "upgrade" + | "keep-alive" + | "trailer" + ) { continue; } // Skip any header the credential will inject (primary + extra), so a @@ -306,6 +380,18 @@ async fn handle_h2_stream( if injected_header_names.contains(&name_lower) { continue; } + // Strip the SPIFFE JWT inject header so a client-supplied copy cannot + // survive alongside the bearer token we inject below. + if spiffe_inject_header_lower + .as_deref() + .is_some_and(|h| h == name_lower) + { + continue; + } + // Strip authorization when the assertion route will inject its own Bearer. + if spiffe_assertion_token.is_some() && name_lower == "authorization" { + continue; + } // Substitute a broker nonce for the real credential when present and // admitted; otherwise forward the value unchanged (fail-closed: the // upstream rejects a raw nonce, never sees a silently-wrong credential). @@ -349,6 +435,46 @@ async fn handle_h2_stream( } } + // Inject managed credential (SpiffeJwt) per stream. + // On fetch failure, send 503 — forwarding an unsigned request would bypass the auth boundary. + if let Some(auth) = &ctx.managed_auth { + match auth.acquire().await { + Ok( + ref material @ crate::auth::UpstreamAuthMaterial::BearerToken { + ref header, + ref token, + ref credential_format, + .. + }, + ) => { + let value = + zeroize::Zeroizing::new(credential_format.replace("{}", token.as_str())); + if let Ok(val) = HeaderValue::from_str(value.as_str()) + && let Ok(name) = http::header::HeaderName::from_bytes(header.as_bytes()) + { + upstream_headers.insert(name, val); + } + spiffe_audit_ctx = Some(material.spiffe_audit_context()); + } + Err(e) => { + warn!( + "h2_forward: managed credential fetch failed for {}:{}: {}", + ctx.host, ctx.port, e + ); + send_h2_error(&mut respond, 503)?; + return Ok(()); + } + } + } + + // Inject SPIFFE assertion Bearer token (OAuth2 jwt-bearer exchange result). + if let Some(token) = spiffe_assertion_token { + let bearer = zeroize::Zeroizing::new(format!("Bearer {}", token.as_str())); + if let Ok(val) = HeaderValue::from_str(bearer.as_str()) { + upstream_headers.insert(http::header::AUTHORIZATION, val); + } + } + // Build upstream h2 request. let uri = format!("https://{}:{}{}", ctx.host, ctx.port, transformed_path); let mut upstream_req = Request::builder().method(method.clone()).uri(&uri); @@ -441,6 +567,7 @@ async fn handle_h2_stream( injection_mode: cred .map(|c| reverse::audit_injection_mode_for_inject_mode(&c.inject_mode)), denial_category: None, + spiffe_context: spiffe_audit_ctx, ..audit::EventContext::default() }, &ctx.host, @@ -636,8 +763,9 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let credential_store = CredentialStore::load_with_diagnostics(&routes, tls_connector) .await .unwrap() @@ -692,7 +820,7 @@ mod tests { } /// Build a RouteStore with a single route pointing at `host:port`. - fn make_route_store(host: &str, port: u16, rules: Vec) -> RouteStore { + async fn make_route_store(host: &str, port: u16, rules: Vec) -> RouteStore { let routes = vec![RouteConfig { prefix: "test-svc".to_string(), upstream: format!("https://{}:{}", host, port), @@ -712,8 +840,9 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }]; - RouteStore::load(&routes).unwrap() + RouteStore::load(&routes).await.unwrap() } /// Build a CredentialStore with a test credential. @@ -954,7 +1083,8 @@ mod tests { method: "POST".to_string(), path: "/v1/chat/completions".to_string(), }], - ); + ) + .await; let credential_store = make_credential_store("sk-test-secret-key"); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -1294,7 +1424,8 @@ mod tests { method: "POST".to_string(), path: "/v1/chat/completions".to_string(), }], - ); + ) + .await; // Credential carries a second managed header beyond Authorization. let credential_store = make_credential_store_with_extra("sk-test-secret-key", "x-api-key", "managed-key"); @@ -1430,8 +1561,9 @@ mod tests { service: Some("bedrock".to_string()), }), endpoint_policy: None, + spiffe: None, }]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); // Set fake AWS credential env vars so the default chain succeeds and // load_with_diagnostics inserts the AwsRoute. The mutex lock is dropped // before the await point (holding a MutexGuard across an await is a @@ -1528,7 +1660,7 @@ mod tests { let ca = Arc::new(EphemeralCa::generate().unwrap()); let (upstream_port, rx) = spawn_mock_h2_upstream_echo(&ca).await; - let route_store = make_route_store("localhost", upstream_port, vec![]); + let route_store = make_route_store("localhost", upstream_port, vec![]).await; let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -1814,6 +1946,7 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }, RouteConfig { prefix: "svc-b".to_string(), @@ -1837,9 +1970,10 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }, ]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -1917,7 +2051,8 @@ mod tests { method: "POST".to_string(), path: "/v1/chat/completions".to_string(), }], - ); + ) + .await; let credential_store = make_credential_store("sk-should-not-appear"); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -1987,7 +2122,7 @@ mod tests { /// Build a RouteStore modeling an endpoint-only restriction route (no /// credential_key), as produced by `_ep_` routes from allow_domain. - fn make_endpoint_only_route_store( + async fn make_endpoint_only_route_store( host: &str, port: u16, rules: Vec, @@ -2011,8 +2146,9 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }]; - RouteStore::load(&routes).unwrap() + RouteStore::load(&routes).await.unwrap() } #[tokio::test] @@ -2029,7 +2165,8 @@ mod tests { method: "GET".to_string(), path: "/repos/my-org/**".to_string(), }], - ); + ) + .await; let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -2150,8 +2287,9 @@ mod tests { timeout_secs: None, }], }), + spiffe: None, }]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -2234,7 +2372,8 @@ mod tests { method: "GET".to_string(), path: "/repos/my-org/**".to_string(), }], - ); + ) + .await; let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -2338,6 +2477,7 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }, // Endpoint-only restriction (_ep_ route) RouteConfig { @@ -2368,9 +2508,10 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, }, ]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let credential_store = make_credential_store("gh-secret"); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -2457,7 +2598,8 @@ mod tests { method: "POST".to_string(), path: "/v1/chat/completions".to_string(), }], - ); + ) + .await; // No managed credential — the secret arrives purely via nonce resolution. let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); @@ -2557,7 +2699,8 @@ mod tests { method: "POST".to_string(), path: "/v1/chat/completions".to_string(), }], - ); + ) + .await; let credential_store = CredentialStore::empty(); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); @@ -2659,7 +2802,8 @@ mod tests { method: "POST".to_string(), path: "/pkg.Svc/BidiStream".to_string(), }], - ); + ) + .await; let credential_store = make_credential_store("sk-test-secret-key"); let cert_cache = Arc::new(CertCache::new(Arc::clone(&ca))); let tls_connector = h2_tls_connector_trusting(ca.cert_pem()); diff --git a/crates/nono-proxy/src/tls_intercept/handle.rs b/crates/nono-proxy/src/tls_intercept/handle.rs index f1917c094..600c5d462 100644 --- a/crates/nono-proxy/src/tls_intercept/handle.rs +++ b/crates/nono-proxy/src/tls_intercept/handle.rs @@ -803,13 +803,16 @@ pub(crate) async fn resolve_managed_credential<'a>( let static_cred = service.and_then(|s| credential_store.get(s)); let cmd_route = service.and_then(|s| credential_store.get_cmd(s)); let oauth2_route = service.and_then(|s| credential_store.get_oauth2(s)); + let spiffe_assertion_route = service.and_then(|s| credential_store.get_spiffe_assertion(s)); let aws_route = service.and_then(|s| credential_store.get_aws(s)); + let has_spiffe = route.is_some_and(|rt| rt.has_spiffe_source()); if let Some(rt) = route && rt.missing_managed_credential( static_cred.is_some() || (cmd_route.is_some() && credential_capture_backend.is_some()), - oauth2_route.is_some(), + oauth2_route.is_some() || spiffe_assertion_route.is_some(), aws_route.is_some(), + has_spiffe, ) { let svc = service.unwrap_or("unknown"); @@ -949,9 +952,19 @@ where let service: Option<&str> = selected.map(|(s, _)| s); let route: Option<&crate::route::LoadedRoute> = selected.map(|(_, r)| r); + // SPIFFE routes bypass the normal credential resolution path entirely and + // use mTLS / JWT-SVID auth instead of injected headers. + if route.is_some_and(|rt| rt.has_spiffe_source()) + && let (Some(svc), Some(rt)) = (service, route) + { + return handle_spiffe_intercept_request(tls_stream, ctx, &req, svc, rt, &method, &path) + .await; + } + // OAuth2 presence only affects the audit `managed_credential_active` flag // on this path; injection is not performed for intercepted requests. let oauth2_route = service.and_then(|s| ctx.credential_store.get_oauth2(s)); + let spiffe_assertion_route = service.and_then(|s| ctx.credential_store.get_spiffe_assertion(s)); // Early branch: AWS SigV4 path is completely self-contained. Must be // checked before calling resolve_managed_credential, which still carries @@ -1011,6 +1024,21 @@ where None => return Ok(()), }; + // If there's a SPIFFE assertion route, fetch the access token now. + // Fail the request if the SVID is revoked (Credential error); use stale on transient failures. + let spiffe_bearer = if let Some(assertion_route) = spiffe_assertion_route { + match assertion_route.cache.get_or_refresh().await { + Ok(token) => Some(token), + Err(e) => { + warn!("tls_intercept: SPIFFE assertion token unavailable: {}", e); + reverse::send_error_generic(tls_stream, 503, "Service Unavailable").await?; + return Ok(()); + } + } + } else { + None + }; + // --- Read body (Content-Length only; chunked is rare in API requests // and matches the existing reverse-proxy contract). --- let strip_header = cred.map(|c| c.proxy_header_name.as_str()).unwrap_or(""); @@ -1039,6 +1067,8 @@ where )); if let Some(cred) = cred { reverse::inject_credential_for_mode(cred, &mut request); + } else if let Some(token) = &spiffe_bearer { + request.push_str(&format!("Authorization: Bearer {}\r\n", token.as_str())); } let injected_header_names = reverse::injected_credential_header_names(cred); let nonce_consumer = service.map(|s| format!("proxy.{s}")); @@ -1077,12 +1107,47 @@ where strategy, tls_connector: connector, }; + let spiffe_audit_ctx = spiffe_bearer.as_ref().and_then(|_| { + spiffe_assertion_route.map(|r| { + let id = &r.cache.workload_spiffe_id; + let trust_domain = crate::auth::extract_trust_domain(id); + nono::undo::SpiffeAuditContext { + workload_spiffe_id: id.clone(), + trust_domain, + svid_type: "jwt".to_string(), + source: "spire-workload-api".to_string(), + upstream_spiffe_id: None, + delegation: None, + } + }) + }); let event_ctx = audit::EventContext { route_id: service, - auth_mechanism: cred.map(|c| reverse::auth_mechanism_for_inject_mode(&c.proxy_inject_mode)), - auth_outcome: cred.map(|_| nono::undo::NetworkAuditAuthOutcome::Succeeded), - managed_credential_active: Some(cred.is_some() || oauth2_route.is_some()), - injection_mode: cred.map(|c| reverse::audit_injection_mode_for_inject_mode(&c.inject_mode)), + auth_mechanism: cred + .map(|c| reverse::auth_mechanism_for_inject_mode(&c.proxy_inject_mode)) + .or_else(|| { + spiffe_bearer + .as_ref() + .map(|_| nono::undo::NetworkAuditAuthMechanism::SpiffeJwtBearer) + }), + auth_outcome: cred + .map(|_| nono::undo::NetworkAuditAuthOutcome::Succeeded) + .or_else(|| { + spiffe_bearer + .as_ref() + .map(|_| nono::undo::NetworkAuditAuthOutcome::Succeeded) + }), + managed_credential_active: Some( + cred.is_some() || oauth2_route.is_some() || spiffe_bearer.is_some(), + ), + injection_mode: cred + .map(|c| reverse::audit_injection_mode_for_inject_mode(&c.inject_mode)) + .or_else(|| { + spiffe_bearer + .as_ref() + .map(|_| nono::undo::NetworkAuditInjectionMode::SpiffeJwt) + }), + spiffe_context: spiffe_audit_ctx, denial_category: None, ..audit::EventContext::default() }; @@ -1150,6 +1215,198 @@ where Ok(()) } +#[allow(clippy::too_many_arguments)] +async fn handle_spiffe_intercept_request( + tls_stream: &mut S, + ctx: &InterceptCtx<'_>, + req: &ParsedRequest, + service: &str, + route: &crate::route::LoadedRoute, + method: &str, + path: &str, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let auth_result = route.managed_auth.as_ref().ok_or_else(|| { + crate::error::ProxyError::Credential("no managed auth on SPIFFE route".into()) + }); + let material = match auth_result { + Ok(auth) => match auth.acquire().await { + Ok(m) => m, + Err(e) => { + let reason = e.to_string(); + warn!("tls_intercept: SPIFFE credential unavailable: {}", reason); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + route_id: Some(service), + auth_mechanism: route.managed_auth_mechanism.clone(), + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed), + managed_credential_active: Some(false), + injection_mode: route.managed_injection_mode.clone(), + denial_category: Some( + nono::undo::NetworkAuditDenialCategory::ManagedCredentialUnavailable, + ), + ..audit::EventContext::default() + }, + ctx.host, + ctx.port, + &reason, + ); + reverse::send_error_generic(tls_stream, 503, "Service Unavailable").await?; + return Ok(()); + } + }, + Err(e) => { + let reason = e.to_string(); + warn!("tls_intercept: SPIFFE credential unavailable: {}", reason); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + route_id: Some(service), + auth_mechanism: route.managed_auth_mechanism.clone(), + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed), + managed_credential_active: Some(false), + injection_mode: route.managed_injection_mode.clone(), + denial_category: Some( + nono::undo::NetworkAuditDenialCategory::ManagedCredentialUnavailable, + ), + ..audit::EventContext::default() + }, + ctx.host, + ctx.port, + &reason, + ); + reverse::send_error_generic(tls_stream, 503, "Service Unavailable").await?; + return Ok(()); + } + }; + + let spiffe_ctx = material.spiffe_audit_context(); + let event_ctx = audit::EventContext { + route_id: Some(service), + auth_mechanism: route.managed_auth_mechanism.clone(), + auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded), + managed_credential_active: Some(true), + injection_mode: route.managed_injection_mode.clone(), + spiffe_context: Some(spiffe_ctx), + ..audit::EventContext::default() + }; + + let resolved_addrs = match resolve_upstream_or_deny( + tls_stream, + ctx, + audit::EventContext { + route_id: Some(service), + managed_credential_active: Some(true), + injection_mode: route.managed_injection_mode.clone(), + ..audit::EventContext::default() + }, + ) + .await? + { + Some(addrs) => addrs, + None => return Ok(()), + }; + + let crate::auth::UpstreamAuthMaterial::BearerToken { + ref header, + ref token, + ref credential_format, + .. + } = material; + let inject_header = Some(header.clone()); + let inject_value = Some(credential_format.replace("{}", token.as_str())); + let tls_connector_owned: Option = None; + + let strip_header = inject_header.as_deref().unwrap_or(""); + let filtered_headers = reverse::filter_headers(&req.header_bytes, strip_header); + let content_length = reverse::extract_content_length(&req.header_bytes); + let body = match reverse::read_request_body(tls_stream, content_length, &req.buffered).await? { + Some(b) => b, + None => return Ok(()), + }; + + let upstream_authority = reverse::format_host_header(UpstreamScheme::Https, ctx.host, ctx.port); + let mut request = Zeroizing::new(format!( + "{} {} {}\r\nHost: {}\r\n", + method, path, req.version, upstream_authority + )); + if let (Some(value), Some(header)) = (&inject_value, &inject_header) { + request.push_str(&format!("{}: {}\r\n", header, value)); + } + for (name, value) in &filtered_headers { + if let Some(header) = &inject_header + && name.eq_ignore_ascii_case(header) + { + continue; + } + request.push_str(&format!("{}: {}\r\n", name, value)); + } + request.push_str("Connection: close\r\n"); + if !body.is_empty() { + request.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + request.push_str("\r\n"); + + let default_connector; + let connector = if let Some(ref owned) = tls_connector_owned { + owned + } else { + default_connector = route + .tls_connector + .as_ref() + .unwrap_or(ctx.tls_connector) + .clone(); + &default_connector + }; + let strategy = select_upstream_strategy(&ctx.upstream_proxy, &resolved_addrs); + let upstream_spec = UpstreamSpec { + scheme: UpstreamScheme::Https, + host: ctx.host, + port: ctx.port, + strategy, + tls_connector: connector, + }; + let audit_ctx = AuditCtx { + log: ctx.audit_log, + mode: audit::ProxyMode::ConnectIntercept, + event_ctx: event_ctx.clone(), + target: ctx.host, + method, + path, + }; + if let Err(e) = forward::forward_request( + tls_stream, + request.as_bytes(), + &body, + upstream_spec, + audit_ctx, + ) + .await + { + warn!("tls_intercept: SPIFFE upstream forwarding failed: {}", e); + audit::log_denied( + ctx.audit_log, + audit::ProxyMode::ConnectIntercept, + &audit::EventContext { + denial_category: Some( + nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed, + ), + ..event_ctx + }, + ctx.host, + ctx.port, + &e.to_string(), + ); + let _ = reverse::send_error_generic(tls_stream, 502, "Bad Gateway").await; + } + Ok(()) +} + /// Scan a header value for a tool-sandbox broker nonce (`nono_<64hex>`) and, /// if one is found and `resolver` admits `consumer`, return the header value /// with the nonce replaced by the real credential bytes (UTF-8). @@ -1456,8 +1713,8 @@ mod tests { } } - #[test] - fn h2_tls_connector_for_target_uses_custom_route_config() { + #[tokio::test] + async fn h2_tls_connector_for_target_uses_custom_route_config() { let ca = crate::tls_intercept::ca::EphemeralCa::generate().unwrap(); let dir = tempfile::tempdir().unwrap(); let ca_path = dir.path().join("upstream-ca.pem"); @@ -1469,7 +1726,7 @@ mod tests { 9443, Some(ca_path.to_string_lossy().as_ref()), )]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let default_connector = test_default_h2_connector(); let (_connector, key) = @@ -1482,8 +1739,8 @@ mod tests { ); } - #[test] - fn h2_tls_connector_for_target_accepts_matching_custom_route_configs() { + #[tokio::test] + async fn h2_tls_connector_for_target_accepts_matching_custom_route_configs() { let ca = crate::tls_intercept::ca::EphemeralCa::generate().unwrap(); let dir = tempfile::tempdir().unwrap(); let ca_path = dir.path().join("upstream-ca.pem"); @@ -1494,7 +1751,7 @@ mod tests { route_config("custom-a", "localhost", 9443, Some(ca_path.as_ref())), route_config("custom-b", "localhost", 9443, Some(ca_path.as_ref())), ]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let default_connector = test_default_h2_connector(); let (_connector, key) = @@ -1507,8 +1764,8 @@ mod tests { ); } - #[test] - fn h2_tls_connector_for_target_rejects_mixed_route_configs() { + #[tokio::test] + async fn h2_tls_connector_for_target_rejects_mixed_route_configs() { let ca = crate::tls_intercept::ca::EphemeralCa::generate().unwrap(); let dir = tempfile::tempdir().unwrap(); let ca_path = dir.path().join("upstream-ca.pem"); @@ -1523,7 +1780,7 @@ mod tests { Some(ca_path.to_string_lossy().as_ref()), ), ]; - let route_store = RouteStore::load(&routes).unwrap(); + let route_store = RouteStore::load(&routes).await.unwrap(); let default_connector = test_default_h2_connector(); assert!( @@ -1566,6 +1823,7 @@ mod tests { oauth2: None, aws_auth: None, endpoint_policy: None, + spiffe: None, } } diff --git a/crates/nono-proxy/tests/spiffe_integration.rs b/crates/nono-proxy/tests/spiffe_integration.rs new file mode 100644 index 000000000..894b61831 --- /dev/null +++ b/crates/nono-proxy/tests/spiffe_integration.rs @@ -0,0 +1,188 @@ +//! SPIFFE/SPIRE proxy integration tests. +//! +//! Fail-closed tests run everywhere (no SPIRE needed). +//! Live tests run only when SPIRE_AGENT_SOCKET is set — the CI job sets it. +#![allow(clippy::unwrap_used)] + +use nono_proxy::config::{ProxyConfig, RouteConfig, SpiffeAuthConfig}; +use nono_proxy::server; +use nono_proxy::spiffe::SpiffeJwtSource; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +fn make_jwt_route(socket: &str) -> RouteConfig { + RouteConfig { + prefix: "testapi".to_string(), + upstream: "http://127.0.0.1:1".to_string(), + credential_key: None, + inject_mode: nono_proxy::config::InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: None, + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + endpoint_policy: None, + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + oauth2: None, + aws_auth: None, + spiffe: Some(SpiffeAuthConfig::Jwt { + workload_api_socket: socket.to_string(), + audience: vec!["test-audience".to_string()], + inject_header: "Authorization".to_string(), + credential_format: None, + svid_hint: None, + }), + } +} + +/// Returns the SPIRE agent socket path, or `None` if `SPIRE_AGENT_SOCKET` is unset. +fn live_socket() -> Option { + std::env::var("SPIRE_AGENT_SOCKET").ok() +} + +fn live_audience() -> Vec { + let td = std::env::var("SPIRE_TRUST_DOMAIN").unwrap_or_else(|_| "test.nono".to_string()); + vec![format!("spiffe://{td}")] +} + +fn live_expected_spiffe_id() -> String { + std::env::var("SPIRE_WORKLOAD_SPIFFE_ID") + .unwrap_or_else(|_| "spiffe://test.nono/nono-proxy".to_string()) +} + +// ─── Fail-closed tests (no SPIRE needed) ──────────────────────────────────── + +#[tokio::test] +async fn test_spiffe_jwt_fails_closed_on_missing_socket() { + let route = make_jwt_route("/tmp/nono-test-nonexistent-spire-socket.sock"); + let result = server::start(ProxyConfig { + routes: vec![route], + ..ProxyConfig::default() + }) + .await; + let err = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + err.contains("SPIFFE") || err.contains("spiffe") || err.contains("socket"), + "unexpected error: {}", + err + ); +} + +// ─── Live tests (require SPIRE_AGENT_SOCKET) ──────────────────────────────── + +#[tokio::test] +async fn test_spiffe_jwt_live_fetch() { + let socket = match live_socket() { + Some(s) => s, + None => return, + }; + let audience = live_audience(); + let expected_id = live_expected_spiffe_id(); + + let src = SpiffeJwtSource::connect( + &socket, + audience.clone(), + "Authorization".to_string(), + None, + None, + ) + .await + .expect("should connect to live SPIRE agent"); + + let (token, spiffe_id) = src + .fetch_token(&audience) + .await + .expect("should fetch JWT-SVID from live agent"); + + let parts: Vec<&str> = token.as_str().split('.').collect(); + assert_eq!(parts.len(), 3, "JWT-SVID should have three parts"); + assert!(!parts[0].is_empty() && !parts[1].is_empty() && !parts[2].is_empty()); + + assert_eq!( + spiffe_id, expected_id, + "workload SPIFFE ID should match the registered entry" + ); +} + +#[tokio::test] +async fn test_spiffe_jwt_live_delegation_none_on_plain_svid() { + let socket = match live_socket() { + Some(s) => s, + None => return, + }; + let audience = live_audience(); + + let src = SpiffeJwtSource::connect( + &socket, + audience.clone(), + "Authorization".to_string(), + None, + None, + ) + .await + .expect("should connect to live SPIRE agent"); + + let (token, _) = src + .fetch_token(&audience) + .await + .expect("should fetch JWT-SVID"); + + // A plain SPIRE JWT-SVID has no `act` claim, so delegation must be None. + let delegation = nono_proxy::spiffe::delegation_from_jwt(token.as_str()); + assert!( + delegation.is_none(), + "plain SPIRE JWT-SVID should not have delegation context" + ); +} + +#[tokio::test] +async fn test_spiffe_jwt_live_proxy_startup() { + let socket = match live_socket() { + Some(s) => s, + None => return, + }; + let audience = live_audience(); + + let route = RouteConfig { + prefix: "testapi".to_string(), + upstream: "http://127.0.0.1:1".to_string(), + credential_key: None, + inject_mode: nono_proxy::config::InjectMode::Header, + inject_header: "Authorization".to_string(), + credential_format: None, + path_pattern: None, + path_replacement: None, + query_param_name: None, + proxy: None, + env_var: None, + endpoint_rules: vec![], + endpoint_policy: None, + tls_ca: None, + tls_client_cert: None, + tls_client_key: None, + oauth2: None, + aws_auth: None, + spiffe: Some(SpiffeAuthConfig::Jwt { + workload_api_socket: socket, + audience, + inject_header: "Authorization".to_string(), + credential_format: None, + svid_hint: None, + }), + }; + let result = server::start(ProxyConfig { + routes: vec![route], + ..ProxyConfig::default() + }) + .await; + assert!( + result.is_ok(), + "proxy should start with a live SPIRE agent: {:?}", + result.err() + ); +} diff --git a/crates/nono/src/audit.rs b/crates/nono/src/audit.rs index 35060d6ec..caf64561a 100644 --- a/crates/nono/src/audit.rs +++ b/crates/nono/src/audit.rs @@ -1579,6 +1579,7 @@ mod tests { credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: None, target: "api.example.com".to_string(), upstream: None, port: Some(443), @@ -1770,6 +1771,7 @@ mod tests { credential_capture_header_names: None, credential_capture_stdin_mode: None, credential_capture_interactive: None, + spiffe_context: None, target: "example.com".to_string(), upstream: None, port: Some(443), diff --git a/crates/nono/src/undo/types.rs b/crates/nono/src/undo/types.rs index 0742ce251..17232adf4 100644 --- a/crates/nono/src/undo/types.rs +++ b/crates/nono/src/undo/types.rs @@ -218,6 +218,11 @@ pub enum NetworkAuditAuthMechanism { PhantomPath, /// Phantom token carried in a query parameter PhantomQuery, + /// SPIFFE JWT-SVID injected as a bearer token directly into an upstream request header + SpiffeJwtBearer, + /// SPIFFE JWT-SVID used as an OAuth2 `client_assertion` (RFC 7523) to exchange + /// for an access token, which is then injected into the upstream request + SpiffeOAuthAssertion, } /// Outcome of proxy-side authentication or phantom-token validation. @@ -239,6 +244,43 @@ pub enum NetworkAuditInjectionMode { QueryParam, BasicAuth, OAuth2, + /// SPIFFE JWT-SVID injected directly as a bearer header value + SpiffeJwt, +} + +/// SPIFFE workload identity context attached to a network audit event when a +/// SPIFFE auth route was active. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiffeAuditContext { + /// SPIFFE ID of the nono-proxy workload (`spiffe://trust-domain/path`) + pub workload_spiffe_id: String, + pub trust_domain: String, + /// SVID type used: `"jwt"`. + pub svid_type: String, + /// Identity source: always `"spire-workload-api"` for SPIRE-issued SVIDs. + pub source: String, + /// SPIFFE ID of the upstream peer if verified out-of-band. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_spiffe_id: Option, + /// Delegation chain from the JWT `act` claim, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegation: Option, +} + +/// Delegation context extracted from the `act` claim of a JWT-SVID. +/// +/// RFC 8693 token exchange allows a chain of delegations: a human authorizes a +/// session, that session authorizes an agent, and so on. `chain_depth = 1` means +/// the token was issued directly on behalf of the `authorized_by` identity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpiffeDelegationContext { + /// `act.sub`: the identity that delegated authority to this session. + pub authorized_by: String, + /// `sub`: the root identity in the chain (the original principal), if present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_behalf_of: Option, + /// Number of delegation hops. 1 = directly authorized by `authorized_by`. + pub chain_depth: u32, } /// Structured category for denied proxy events. @@ -334,6 +376,9 @@ pub struct NetworkAuditEvent { /// Whether the capture entry requested interactive affordances. #[serde(default, skip_serializing_if = "Option::is_none")] pub credential_capture_interactive: Option, + /// SPIFFE workload identity context when a SPIFFE auth route was active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spiffe_context: Option, /// Hostname or logical service target (for reverse proxy events) pub target: String, /// Upstream URL for route-scoped L7 events, without credentials. diff --git a/docs/cli/features/spiffe.mdx b/docs/cli/features/spiffe.mdx new file mode 100644 index 000000000..f3f3bd3d4 --- /dev/null +++ b/docs/cli/features/spiffe.mdx @@ -0,0 +1,188 @@ +--- +title: SPIFFE / SPIRE Workload Identity +description: Authenticate outbound requests using SPIFFE SVIDs — no secrets in your profile, no credentials visible to the sandboxed process. +--- + +nono integrates with the [SPIFFE Workload API](https://spiffe.io) to authenticate outbound API +calls using workload identity. Instead of storing a secret in a keystore or profile, nono contacts +the local SPIRE agent socket at startup, obtains a credential, and handles injection automatically. +The sandboxed process makes a plain HTTP request and is never aware a credential was used. + +JWT-SVIDs are currently supported. nono fetches a short-lived token per request and injects it +as a bearer header; refresh is handled by the SPIRE agent cache. + +Most of the operational work lives on the SPIRE side: running the server and agent, writing +attestation policies, and registering workload entries. nono's profile config is just a socket +path and what to request. + +--- + +## Profile configuration + +There are two separate configuration surfaces depending on what the upstream expects: + +- **`spiffe` block** — for upstreams that accept a JWT-SVID directly as a bearer token. +- **`auth.client_assertion`** — for OAuth2 token endpoints that accept a JWT-SVID as a + [`urn:ietf:params:oauth:grant-type:jwt-bearer`](https://www.rfc-editor.org/rfc/rfc7523) assertion + and return a short-lived OAuth2 access token in exchange. + +### JWT-SVID (direct bearer injection) + +Add a `spiffe` block to any custom credential route. It is mutually exclusive with `credential_key`, +`auth`, and `aws_auth`. + +```json +{ + "network": { + "credentials": ["my-api"], + "custom_credentials": { + "my-api": { + "upstream": "https://api.internal.example.com", + "spiffe": { + "type": "jwt", + "workload_api_socket": "/var/run/spire/sockets/agent.sock", + "audience": ["https://api.internal.example.com"], + "inject_header": "Authorization" + } + } + } + } +} +``` + +nono injects the token as `Authorization: Bearer `. Change `inject_header` to target a +different header, or set `credential_format` to change the value format (`{}` is replaced by +the token). + +### JWT-SVID as an OAuth2 client assertion + +Some upstreams (including Anthropic's API) accept a JWT-SVID as an OAuth2 client assertion via +the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type. The upstream issues a short-lived +OAuth2 access token in exchange. nono handles the full exchange and injects the resulting access +token — not the raw JWT-SVID — into upstream requests. + +Use `auth.client_assertion` instead of the `spiffe` block for this flow: + +```json +{ + "network": { + "credentials": ["my-api"], + "custom_credentials": { + "my-api": { + "upstream": "https://api.example.com", + "auth": { + "token_url": "https://api.example.com/oauth/token", + "client_assertion": { + "type": "spiffe_jwt", + "workload_api_socket": "/var/run/spire/sockets/agent.sock", + "audience": ["https://api.example.com"] + } + } + } + } + } +} +``` + +When `client_assertion` is set, `client_id` and `client_secret` are not required — the JWT-SVID +is the credential. Any extra parameters required by the token endpoint (such as federation rule IDs +or workspace identifiers) can be passed via `auth.extra_params`: + +```json +"auth": { + "token_url": "https://api.example.com/oauth/token", + "client_assertion": { ... }, + "extra_params": { + "federation_rule_id": "fdrl_...", + "organization_id": "..." + } +} +``` + +The difference between this and the `spiffe.type: jwt` block is the intermediary step: here nono +exchanges the JWT-SVID for an OAuth2 token and caches it until near expiry; with `spiffe.type: jwt` +the raw JWT-SVID is injected directly with no exchange. + +--- + +## Options reference + +### `spiffe` block + +| Field | Description | +|---|---| +| `workload_api_socket` | Path to the SPIRE agent socket | +| `audience` | Audience(s) for the minted JWT-SVID | +| `inject_header` | Header to inject into (default: `Authorization`) | +| `credential_format` | Format string; `{}` replaced by the token (default: `Bearer {}`) | +| `svid_hint` | Select a specific SVID when the Workload API returns multiple | + +`svid_hint` is useful when the SPIRE agent issues more than one SVID for the same workload — +for example, one for an internal service mesh and one for an external API. Set it to the SPIFFE ID +of the SVID you want nono to use; if absent, nono uses the first SVID returned by the agent. + +### `auth.client_assertion` block + +| Field | Description | +|---|---| +| `type` | Must be `"spiffe_jwt"` | +| `workload_api_socket` | Path to the SPIRE agent socket | +| `audience` | Audience(s) for the JWT-SVID presented to the token endpoint | +| `svid_hint` | Select a specific SVID when the Workload API returns multiple | + +--- + +## Socket path by platform + +The SPIRE agent socket path varies by platform and installation: + +| Platform | Typical path | +|---|---| +| Linux | `/var/run/spire/sockets/agent.sock` | +| macOS (local install) | `/tmp/spire-agent/public/api.sock` | +| Kubernetes (SPIFFE CSI driver) | `/run/spiffe/sockets/agent.sock` | + +Set `workload_api_socket` to match wherever your agent is listening. nono fails at startup if the +socket is unreachable, so misconfigured paths are caught immediately. + +--- + +## What nono handles + +- Connecting to the Workload API on startup — fails immediately if the socket is unreachable +- Fetching and injecting the right credential (JWT-SVID bearer or OAuth2 access token via client assertion) +- Background SVID rotation without process restart or credential interruption +- OAuth2 token caching and refresh for `client_assertion` flows +- Zeroing JWT token memory on drop + +## What the SPIRE operator handles + +Everything else. nono's interface to SPIRE is the Workload API socket — it has no knowledge of +how attestation works, what selectors are in use, or what environment it is running in. The same +profile works anywhere SPIFFE runs: + +| Environment | Attestation method | +|---|---| +| Linux / macOS | `unix:uid` — kernel `SO_PEERCRED` | +| Kubernetes | pod UID, namespace, service account labels | +| AWS EC2 | instance identity document | +| GCP / Azure | instance metadata | + +Only `workload_api_socket` changes between environments. + +The operator's responsibilities are: + +1. Run the SPIRE server and agent +2. Register a workload entry mapping nono's platform identity to a SPIFFE ID: + ```bash + spire-server entry create \ + -spiffeID "spiffe://example.com/nono-supervisor" \ + -selector "unix:uid:$(id -u)" + ``` +3. For JWT routes: configure an OIDC discovery provider so upstreams can verify tokens without + any knowledge of SPIRE +4. For `client_assertion` flows: configure a federation rule or JWT verification policy on the + token endpoint so it accepts the workload's SPIFFE ID + +nono (the supervisor process) is the registered workload — not the process inside the sandbox. +The sandboxed process cannot reach the SPIRE socket and has no credential visibility. diff --git a/docs/docs.json b/docs/docs.json index 5538a1029..fce2da6cc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,7 +72,8 @@ "group": "Secrets", "pages": [ "cli/features/credential-injection", - "cli/features/sandboxed-oauth-logins" + "cli/features/sandboxed-oauth-logins", + "cli/features/spiffe" ] }, { diff --git a/scripts/spiffe-mock-server.py b/scripts/spiffe-mock-server.py new file mode 100644 index 000000000..6c2337ecd --- /dev/null +++ b/scripts/spiffe-mock-server.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +Mock upstream server that validates SPIFFE JWT-SVIDs. + +Fetches JWKS from the SPIRE OIDC discovery endpoint, validates the +Authorization: Bearer token on every request, and returns the parsed +claims as JSON so you can see exactly what nono injected. + +Usage: + pip install PyJWT cryptography requests + python3 scripts/spiffe-mock-server.py \ + --issuer ISS \ + --audience AUD \ + --port PORT +""" + +import argparse +import json +import time +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +try: + import jwt + import requests +except ImportError: + print("Missing deps. Run: pip install PyJWT cryptography requests") + sys.exit(1) + + +RESET = "\033[0m" +GREEN = "\033[32m" +RED = "\033[31m" +YELLOW = "\033[33m" +CYAN = "\033[36m" +BOLD = "\033[1m" + + +def fetch_jwks(issuer: str) -> dict: + discovery_url = f"{issuer}/.well-known/openid-configuration" + print(f"{CYAN}Fetching OIDC discovery: {discovery_url}{RESET}") + disco = requests.get(discovery_url, timeout=10) + disco.raise_for_status() + jwks_uri = disco.json()["jwks_uri"] + print(f"{CYAN}Fetching JWKS: {jwks_uri}{RESET}") + jwks_resp = requests.get(jwks_uri, timeout=10) + jwks_resp.raise_for_status() + keys = jwks_resp.json() + print(f"{GREEN}Loaded {len(keys.get('keys', []))} key(s) from JWKS{RESET}\n") + return keys + + +def make_handler(jwks: dict, audience: str, issuer: str): + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass # suppress default logging, we do our own + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + def _handle(self): + ts = time.strftime("%H:%M:%S") + print(f"{BOLD}──── {ts} {self.command} {self.path} ────{RESET}") + print(f" {CYAN}Headers:{RESET}") + for name, value in self.headers.items(): + print(f" {name}: {value}") + + # Reject requests that contain unexpected credential headers — a + # real upstream would do the same and this catches proxy leaks. + for forbidden in ("x-api-key", "proxy-authorization", "proxy-connection"): + if self.headers.get(forbidden): + self._reject(400, f"unexpected header forwarded: {forbidden}") + return + + auth = self.headers.get("Authorization", "") + if not auth.lower().startswith("bearer "): + self._reject(401, "missing or non-Bearer Authorization header") + return + + token = auth[len("bearer "):].strip() + print(f" Token : {token[:40]}...{token[-10:]}") + + try: + jwks_client = jwt.PyJWKClient.__new__(jwt.PyJWKClient) + signing_key = jwt.PyJWK.from_dict(self._pick_key(token, jwks)) + claims = jwt.decode( + token, + signing_key.key, + algorithms=["ES256", "RS256"], + audience=audience, + issuer=issuer, + ) + except jwt.ExpiredSignatureError: + self._reject(401, "token expired") + return + except jwt.InvalidAudienceError as e: + self._reject(401, f"audience mismatch: {e}") + return + except jwt.InvalidIssuerError as e: + self._reject(401, f"issuer mismatch: {e}") + return + except Exception as e: + self._reject(401, f"validation failed: {e}") + return + + print(f" {GREEN}✓ JWT valid{RESET}") + print(f" sub : {BOLD}{claims.get('sub')}{RESET}") + print(f" iss : {claims.get('iss')}") + print(f" aud : {claims.get('aud')}") + exp = claims.get('exp', 0) + ttl = max(0, exp - int(time.time())) + print(f" exp : {time.strftime('%H:%M:%S', time.gmtime(exp))} UTC ({ttl}s remaining)") + print() + + body = json.dumps({ + "status": "ok", + "message": "SPIFFE JWT-SVID validated successfully", + "claims": claims, + }, indent=2).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _pick_key(self, token: str, jwks: dict): + header = jwt.get_unverified_header(token) + kid = header.get("kid") + for key in jwks.get("keys", []): + if key.get("kid") == kid: + return key + # fall back to first key + return jwks["keys"][0] + + def _reject(self, code: int, reason: str): + print(f" {RED}✗ {reason}{RESET}\n") + body = json.dumps({"status": "error", "reason": reason}).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return Handler + + +def main(): + parser = argparse.ArgumentParser(description="SPIFFE JWT-SVID mock upstream") + parser.add_argument("--issuer", required=True, help="SPIRE JWT issuer URL") + parser.add_argument("--audience", required=True, help="Expected audience claim") + parser.add_argument("--port", type=int, default=8888) + args = parser.parse_args() + + jwks = fetch_jwks(args.issuer) + handler = make_handler(jwks, args.audience, args.issuer) + + server = HTTPServer(("127.0.0.1", args.port), handler) + print(f"{BOLD}Mock upstream listening on http://127.0.0.1:{args.port}{RESET}") + print(f" Issuer : {args.issuer}") + print(f" Audience : {args.audience}") + print(f"\nWaiting for requests...\n") + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + + +if __name__ == "__main__": + main() diff --git a/scripts/spire-test.sh b/scripts/spire-test.sh new file mode 100755 index 000000000..1f9895908 --- /dev/null +++ b/scripts/spire-test.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Run SPIFFE/SPIRE integration tests locally. +# +# Usage: +# ./scripts/spire-test.sh # download SPIRE if needed, run tests, clean up +# SPIRE_BIN=/usr/local/bin ./scripts/spire-test.sh # use binaries already on PATH +# +# The script is idempotent: if SPIRE binaries are already present in +# SPIRE_BIN (default: /tmp/nono-spire-bin) they are reused without +# re-downloading. The SPIRE data dir is always recreated fresh so tests +# start from a clean state. +# +# Requirements: curl, tar, cargo + +set -euo pipefail + +SPIRE_VERSION="1.9.6" +SPIRE_BIN="${SPIRE_BIN:-/tmp/nono-spire-bin}" +SPIRE_DATA="/tmp/nono-test-spire" +SPIRE_SERVER_SOCK="${SPIRE_DATA}/server.sock" +SPIRE_AGENT_SOCK="${SPIRE_DATA}/agent.sock" +TRUST_DOMAIN="test.nono" +WORKLOAD_SPIFFE_ID="spiffe://${TRUST_DOMAIN}/nono-proxy" + +SERVER_PID="" +AGENT_PID="" + +# ─── Cleanup ────────────────────────────────────────────────────────────────── + +cleanup() { + echo "" + echo "==> Cleaning up" + [ -n "$AGENT_PID" ] && kill "$AGENT_PID" 2>/dev/null || true + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true + rm -rf "$SPIRE_DATA" + echo " Done." +} +trap cleanup EXIT + +# ─── SPIRE binaries ─────────────────────────────────────────────────────────── + +ensure_spire() { + if command -v spire-server &>/dev/null && command -v spire-agent &>/dev/null; then + echo "==> Using SPIRE from PATH ($(command -v spire-server))" + return + fi + + if [ -x "${SPIRE_BIN}/bin/spire-server" ] && [ -x "${SPIRE_BIN}/bin/spire-agent" ]; then + echo "==> Using cached SPIRE in ${SPIRE_BIN}" + export PATH="${SPIRE_BIN}/bin:${PATH}" + return + fi + + echo "==> Downloading SPIRE ${SPIRE_VERSION}" + local os arch tarball + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch="$(uname -m)" + case "${arch}" in + x86_64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "Unsupported arch: ${arch}"; exit 1 ;; + esac + + tarball="spire-${SPIRE_VERSION}-${os}-${arch}-musl.tar.gz" + curl -fsSL \ + "https://github.com/spiffe/spire/releases/download/v${SPIRE_VERSION}/${tarball}" \ + -o /tmp/spire.tar.gz + mkdir -p "${SPIRE_BIN}" + tar -xzf /tmp/spire.tar.gz -C "${SPIRE_BIN}" --strip-components=1 + rm -f /tmp/spire.tar.gz + export PATH="${SPIRE_BIN}/bin:${PATH}" + echo " Installed to ${SPIRE_BIN}/bin" +} + +# ─── SPIRE setup ────────────────────────────────────────────────────────────── + +start_server() { + echo "==> Starting SPIRE server" + rm -rf "${SPIRE_DATA}" + mkdir -p "${SPIRE_DATA}/data/server" + spire-server run -config testdata/spire/server.conf & + SERVER_PID=$! + + local i=0 + while [ $i -lt 30 ]; do + [ -S "${SPIRE_SERVER_SOCK}" ] && break + sleep 0.5 + i=$((i + 1)) + done + [ -S "${SPIRE_SERVER_SOCK}" ] || { echo "SPIRE server did not start"; exit 1; } + echo " PID ${SERVER_PID}, socket ${SPIRE_SERVER_SOCK}" +} + +start_agent() { + echo "==> Starting SPIRE agent" + mkdir -p "${SPIRE_DATA}/data/agent" + + local raw token + raw="$(spire-server token generate \ + -socketPath "${SPIRE_SERVER_SOCK}" \ + -spiffeID "spiffe://${TRUST_DOMAIN}/agent")" + token="$(echo "${raw}" | grep -oE '[0-9a-f-]{36}')" + + spire-agent run -config testdata/spire/agent.conf -joinToken "${token}" & + AGENT_PID=$! + + local i=0 + while [ $i -lt 30 ]; do + [ -S "${SPIRE_AGENT_SOCK}" ] && break + sleep 0.5 + i=$((i + 1)) + done + [ -S "${SPIRE_AGENT_SOCK}" ] || { echo "SPIRE agent did not start"; exit 1; } + echo " PID ${AGENT_PID}, socket ${SPIRE_AGENT_SOCK}" +} + +register_workload() { + echo "==> Registering workload entry (uid $(id -u))" + spire-server entry create \ + -socketPath "${SPIRE_SERVER_SOCK}" \ + -spiffeID "${WORKLOAD_SPIFFE_ID}" \ + -parentID "spiffe://${TRUST_DOMAIN}/agent" \ + -selector "unix:uid:$(id -u)" + echo " ${WORKLOAD_SPIFFE_ID}" + + # Wait for the agent to sync the entry from the server before running tests. + # Without this, the first JWT fetch hits the agent before it has an identity + # cached, producing harmless "No identity issued" log noise. + echo "==> Waiting for agent to sync workload entry" + local i=0 + while [ $i -lt 20 ]; do + if spire-agent api fetch jwt \ + -socketPath "${SPIRE_AGENT_SOCK}" \ + -audience "warmup" 2>/dev/null | grep -q "spiffe://"; then + break + fi + sleep 0.5 + i=$((i + 1)) + done + echo " Ready" +} + +# ─── Tests ──────────────────────────────────────────────────────────────────── + +run_tests() { + local env_args=( + SPIRE_AGENT_SOCKET="${SPIRE_AGENT_SOCK}" + SPIRE_TRUST_DOMAIN="${TRUST_DOMAIN}" + SPIRE_WORKLOAD_SPIFFE_ID="${WORKLOAD_SPIFFE_ID}" + ) + + echo "" + echo "==> cargo test: nono-proxy spiffe_integration" + env "${env_args[@]}" \ + cargo test -p nono-proxy --test spiffe_integration -- --nocapture + + echo "" + echo "==> cargo test: nono-proxy vault_integration" + env "${env_args[@]}" \ + cargo test -p nono-proxy --test vault_integration -- --nocapture + + # Binary-level tests invoke the full nono sandbox. On macOS, Seatbelt + # returns EPERM when applied from a test context; these tests only run + # reliably on Linux (Landlock), which is what CI uses (ubuntu-latest). + if [ "$(uname -s)" = "Linux" ]; then + echo "" + echo "==> cargo test: nono-cli spiffe_run" + env "${env_args[@]}" \ + cargo test -p nono-cli --test spiffe_run -- --nocapture + else + echo "" + echo "==> Skipping nono-cli spiffe_run (macOS: binary-level sandbox tests run in CI on Linux)" + fi +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +cd "$(git rev-parse --show-toplevel)" + +ensure_spire +start_server +start_agent +register_workload +run_tests + +echo "" +echo "==> All SPIFFE/SPIRE tests passed" diff --git a/testdata/spire/agent.conf b/testdata/spire/agent.conf new file mode 100644 index 000000000..8360097ae --- /dev/null +++ b/testdata/spire/agent.conf @@ -0,0 +1,26 @@ +agent { + data_dir = "/tmp/nono-test-spire/data/agent" + log_level = "WARN" + trust_domain = "test.nono" + server_address = "127.0.0.1" + server_port = "18081" + socket_path = "/tmp/nono-test-spire/agent.sock" + + # Bootstrap trust from the server's bundle endpoint rather than a + # pre-distributed bundle — acceptable for ephemeral CI environments. + insecure_bootstrap = true +} + +plugins { + NodeAttestor "join_token" { + plugin_data {} + } + + KeyManager "memory" { + plugin_data {} + } + + WorkloadAttestor "unix" { + plugin_data {} + } +} diff --git a/testdata/spire/server.conf b/testdata/spire/server.conf new file mode 100644 index 000000000..c4e9244f7 --- /dev/null +++ b/testdata/spire/server.conf @@ -0,0 +1,29 @@ +server { + bind_address = "127.0.0.1" + bind_port = "18081" + socket_path = "/tmp/nono-test-spire/server.sock" + trust_domain = "test.nono" + data_dir = "/tmp/nono-test-spire/data/server" + log_level = "WARN" + + ca_ttl = "1h" + default_x509_svid_ttl = "1h" + default_jwt_svid_ttl = "5m" +} + +plugins { + DataStore "sql" { + plugin_data { + database_type = "sqlite3" + connection_string = "/tmp/nono-test-spire/data/server/datastore.sqlite3" + } + } + + KeyManager "memory" { + plugin_data {} + } + + NodeAttestor "join_token" { + plugin_data {} + } +}