From f4990f4db51611f9edf9db57bfec04dc106e9e8d Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Mon, 27 Apr 2026 01:34:32 +0800 Subject: [PATCH 1/6] feat(stage7): broker-server vertical slice + three-role docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #58 phase 1 — the credential broker that lets app developers run daemons against operator infrastructure without holding any AWS keys. Doc reframe (front-loaded per CEO-review Q3): - docs/dev-setup.md rewritten around three roles (app developer / operator / end user). Each role's setup is its own section. - docs/operator-runbook.md (new) — start, supervise, rotate, audit. Calls out v0.1 scope vs Stage 7 phase 2 (OIDC) vs Stage 8 (vault). New crate crates/agentkeys-broker-server/ (vertical slice per CEO-review Q1): - POST /v1/mint-aws-creds — bearer auth via backend's new /session/validate, sts:AssumeRole on operator's daemon key, returns 1h temp creds. Static-IAM path; assume-role-with-web-identity deferred to phase 2. - GET /healthz, /readyz — supervisor probes; readyz exercises backend reachability + sts:GetCallerIdentity. - SQLite audit log on every mint (sha256-hashed bearer tokens, wallet, outcome, sts session name) at $HOME/.agentkeys/broker/audit.sqlite. - Trait-abstracted StsClient with AwsStsClient + StubStsClient (test-stub feature) — testable without live AWS. Env-var config only. mock-server adds GET /session/validate so the broker validates tokens through the backend instead of duplicating session state. Broker stays stateless w.r.t. sessions; backend is single source of truth. agentkeys-daemon gains --broker-url / AGENTKEYS_BROKER_URL flag (consumer wiring lands in phase 2 alongside provisioner-script integration). Tests: 3 unit + 5 broker integration (mock-backend + stub STS) — full workspace cargo test passes 194/194, no regressions. Out of scope (explicit, deferred): - OIDC discovery / JWKS / AssumeRoleWithWebIdentity — phase 2 (gated on public-hosting prereq, docs/stage7-wip.md §1). - TS oidc-stub retirement — phase 2. - Provisioner-scripts AWS-cred consumer rewiring — phase 2. Refs #58. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1094 ++++++++++++++++- Cargo.toml | 1 + crates/agentkeys-broker-server/Cargo.toml | 42 + crates/agentkeys-broker-server/src/audit.rs | 124 ++ crates/agentkeys-broker-server/src/auth.rs | 55 + crates/agentkeys-broker-server/src/config.rs | 57 + crates/agentkeys-broker-server/src/error.rs | 50 + .../src/handlers/health.rs | 34 + .../src/handlers/mint.rs | 138 +++ .../src/handlers/mod.rs | 2 + crates/agentkeys-broker-server/src/lib.rs | 19 + crates/agentkeys-broker-server/src/main.rs | 56 + crates/agentkeys-broker-server/src/state.rs | 14 + crates/agentkeys-broker-server/src/sts.rs | 111 ++ .../tests/mint_flow.rs | 154 +++ crates/agentkeys-daemon/src/main.rs | 15 + .../src/handlers/session.rs | 18 + crates/agentkeys-mock-server/src/lib.rs | 1 + docs/dev-setup.md | 198 +-- docs/operator-runbook.md | 178 +++ 20 files changed, 2233 insertions(+), 128 deletions(-) create mode 100644 crates/agentkeys-broker-server/Cargo.toml create mode 100644 crates/agentkeys-broker-server/src/audit.rs create mode 100644 crates/agentkeys-broker-server/src/auth.rs create mode 100644 crates/agentkeys-broker-server/src/config.rs create mode 100644 crates/agentkeys-broker-server/src/error.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/health.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/mint.rs create mode 100644 crates/agentkeys-broker-server/src/handlers/mod.rs create mode 100644 crates/agentkeys-broker-server/src/lib.rs create mode 100644 crates/agentkeys-broker-server/src/main.rs create mode 100644 crates/agentkeys-broker-server/src/state.rs create mode 100644 crates/agentkeys-broker-server/src/sts.rs create mode 100644 crates/agentkeys-broker-server/tests/mint_flow.rs create mode 100644 docs/operator-runbook.md diff --git a/Cargo.lock b/Cargo.lock index 7403ff1..2dfcda8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,35 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "agentkeys-broker-server" +version = "0.1.0" +dependencies = [ + "agentkeys-broker-server", + "agentkeys-mock-server", + "agentkeys-types", + "anyhow", + "async-trait", + "aws-config", + "aws-credential-types", + "aws-sdk-sts", + "axum", + "clap", + "hex", + "http-body-util", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "tower 0.4.13", + "tracing", + "tracing-subscriber", ] [[package]] @@ -45,12 +73,12 @@ dependencies = [ "base64", "ciborium", "hex", - "hmac", + "hmac 0.12.1", "keyring", "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror", "tokio", @@ -110,14 +138,14 @@ dependencies = [ "clap", "ed25519-dalek", "hex", - "hmac", + "hmac 0.12.1", "http-body-util", "rand", "reqwest", "rusqlite", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", "tower 0.4.13", "tower-http 0.5.2", @@ -422,6 +450,388 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand 2.4.1", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand 2.4.1", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand 2.4.1", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand 2.4.1", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand 2.4.1", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand 2.4.1", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.7.9" @@ -432,10 +842,10 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit", @@ -465,8 +875,8 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -477,12 +887,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -510,6 +936,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -561,6 +996,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cbc" version = "0.1.2" @@ -577,6 +1022,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -619,7 +1066,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -663,6 +1110,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" version = "1.0.5" @@ -684,6 +1146,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -719,6 +1187,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -731,6 +1208,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -741,6 +1240,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -748,9 +1265,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -768,16 +1285,35 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derivative" version = "2.2.0" @@ -801,11 +1337,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -817,14 +1365,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der 0.6.1", + "elliptic-curve", + "rfc6979", + "signature 1.6.4", +] + [[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] @@ -837,7 +1403,33 @@ dependencies = [ "ed25519", "rand_core", "serde", - "sha2", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der 0.6.1", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8 0.9.0", + "rand_core", + "sec1", "subtle", "zeroize", ] @@ -953,6 +1545,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1010,6 +1612,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1120,6 +1728,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -1128,11 +1748,41 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1144,7 +1794,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1226,7 +1876,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -1235,7 +1885,27 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", ] [[package]] @@ -1248,6 +1918,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1255,7 +1936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1266,8 +1947,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1283,6 +1964,39 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1293,9 +2007,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1305,19 +2019,35 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.37", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -1329,7 +2059,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "native-tls", "tokio", @@ -1347,9 +2077,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", @@ -1541,6 +2271,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -1778,6 +2518,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-integer" version = "0.1.46" @@ -1884,6 +2630,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -1945,6 +2708,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -1956,14 +2725,24 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", +] + [[package]] name = "pkcs8" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] @@ -2011,6 +2790,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2088,6 +2873,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -2156,6 +2947,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2174,12 +2971,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -2204,6 +3001,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac 0.12.1", + "zeroize", +] + [[package]] name = "ring" version = "0.17.14" @@ -2265,7 +3073,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2281,19 +3089,44 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2303,12 +3136,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2341,6 +3185,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der 0.6.1", + "generic-array", + "pkcs8 0.9.0", + "subtle", + "zeroize", +] + [[package]] name = "secret-service" version = "3.1.0" @@ -2356,7 +3224,7 @@ dependencies = [ "once_cell", "rand", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -2486,8 +3354,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2497,8 +3365,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2526,6 +3405,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + [[package]] name = "signature" version = "2.2.0" @@ -2567,6 +3456,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -2574,7 +3473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -2712,6 +3611,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2760,13 +3689,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -2839,8 +3778,8 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.11.0", "bytes", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "pin-project-lite", "tower-layer", @@ -2856,8 +3795,8 @@ dependencies = [ "bitflags 2.11.0", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower 0.5.3", @@ -2921,6 +3860,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -2931,12 +3880,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -2992,6 +3944,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3004,6 +3962,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3022,6 +3990,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -3496,6 +4470,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 058becc..2364879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/agentkeys-daemon", "crates/agentkeys-mcp", "crates/agentkeys-provisioner", + "crates/agentkeys-broker-server", ] [workspace.dependencies] diff --git a/crates/agentkeys-broker-server/Cargo.toml b/crates/agentkeys-broker-server/Cargo.toml new file mode 100644 index 0000000..8b1cedc --- /dev/null +++ b/crates/agentkeys-broker-server/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "agentkeys-broker-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "agentkeys-broker-server" +path = "src/main.rs" + +[lib] +name = "agentkeys_broker_server" +path = "src/lib.rs" + +[dependencies] +agentkeys-types = { workspace = true } +axum = { version = "0.7", features = ["json"] } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } +rusqlite = { version = "0.31", features = ["bundled"] } +clap = { version = "4", features = ["derive", "env"] } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +sha2 = "0.10" +hex = "0.4" +aws-config = { version = "1", features = ["behavior-version-latest"] } +aws-credential-types = "1" +aws-sdk-sts = "1" + +[features] +default = [] +test-stub = [] + +[dev-dependencies] +agentkeys-broker-server = { path = ".", features = ["test-stub"] } +agentkeys-mock-server = { path = "../agentkeys-mock-server" } +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" diff --git a/crates/agentkeys-broker-server/src/audit.rs b/crates/agentkeys-broker-server/src/audit.rs new file mode 100644 index 0000000..046c019 --- /dev/null +++ b/crates/agentkeys-broker-server/src/audit.rs @@ -0,0 +1,124 @@ +use std::path::Path; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection}; +use sha2::{Digest, Sha256}; + +use crate::error::{BrokerError, BrokerResult}; + +pub struct AuditLog { + conn: Mutex, +} + +#[derive(Debug, Clone)] +pub struct MintRecord<'a> { + pub requester_token: &'a str, + pub requester_wallet: &'a str, + pub requested_role: &'a str, + pub session_duration_seconds: i32, + pub sts_session_name: &'a str, + pub outcome: MintOutcome, +} + +#[derive(Debug, Clone, Copy)] +pub enum MintOutcome { + Ok, + AuthFailed, + StsError, +} + +impl MintOutcome { + fn as_str(self) -> &'static str { + match self { + MintOutcome::Ok => "ok", + MintOutcome::AuthFailed => "auth_failed", + MintOutcome::StsError => "sts_error", + } + } +} + +impl AuditLog { + pub fn open(path: &Path) -> BrokerResult { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| BrokerError::AuditError(format!("create audit dir: {}", e)))?; + } + let conn = Connection::open(path) + .map_err(|e| BrokerError::AuditError(format!("open audit db: {}", e)))?; + let log = Self { conn: Mutex::new(conn) }; + log.init_schema()?; + Ok(log) + } + + pub fn open_in_memory() -> BrokerResult { + let conn = Connection::open_in_memory() + .map_err(|e| BrokerError::AuditError(format!("open in-memory audit db: {}", e)))?; + let log = Self { conn: Mutex::new(conn) }; + log.init_schema()?; + Ok(log) + } + + fn init_schema(&self) -> BrokerResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS mint_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + minted_at INTEGER NOT NULL, + requester_token TEXT NOT NULL, + requester_wallet TEXT NOT NULL, + requested_role TEXT NOT NULL, + session_duration_seconds INTEGER NOT NULL, + sts_session_name TEXT NOT NULL, + outcome TEXT NOT NULL, + outcome_detail TEXT + ); + CREATE INDEX IF NOT EXISTS idx_mint_log_minted_at ON mint_log(minted_at); + CREATE INDEX IF NOT EXISTS idx_mint_log_wallet ON mint_log(requester_wallet);", + ) + .map_err(|e| BrokerError::AuditError(format!("init schema: {}", e)))?; + Ok(()) + } + + pub fn record_mint(&self, record: MintRecord<'_>, detail: Option<&str>) -> BrokerResult<()> { + let conn = self.conn.lock().unwrap(); + let token_hash = hash_token(record.requester_token); + let now = now_secs(); + conn.execute( + "INSERT INTO mint_log + (minted_at, requester_token, requester_wallet, requested_role, + session_duration_seconds, sts_session_name, outcome, outcome_detail) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + now as i64, + token_hash, + record.requester_wallet, + record.requested_role, + record.session_duration_seconds, + record.sts_session_name, + record.outcome.as_str(), + detail, + ], + ) + .map_err(|e| BrokerError::AuditError(format!("insert mint: {}", e)))?; + Ok(()) + } + + pub fn count(&self) -> BrokerResult { + let conn = self.conn.lock().unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM mint_log", [], |row| row.get(0)) + .map_err(|e| BrokerError::AuditError(format!("count: {}", e)))?; + Ok(n) + } +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} diff --git a/crates/agentkeys-broker-server/src/auth.rs b/crates/agentkeys-broker-server/src/auth.rs new file mode 100644 index 0000000..3e5eec8 --- /dev/null +++ b/crates/agentkeys-broker-server/src/auth.rs @@ -0,0 +1,55 @@ +use crate::error::{BrokerError, BrokerResult}; + +#[derive(Debug, Clone)] +pub struct ValidatedSession { + pub wallet: String, +} + +pub fn extract_bearer_token(header: &str) -> Option<&str> { + header.strip_prefix("Bearer ") +} + +pub async fn validate_bearer_token( + http: &reqwest::Client, + backend_url: &str, + token: &str, +) -> BrokerResult { + let url = format!("{}/session/validate", backend_url.trim_end_matches('/')); + let response = http + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|e| BrokerError::BackendUnreachable(e.to_string()))?; + + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let body: serde_json::Value = response.json().await.unwrap_or(serde_json::Value::Null); + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("session not valid") + .to_string(); + return Err(BrokerError::Unauthorized(msg)); + } + if !status.is_success() { + return Err(BrokerError::BackendUnreachable(format!( + "backend returned {}", + status + ))); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|e| BrokerError::BackendUnreachable(format!("parse validate response: {}", e)))?; + let wallet = body + .get("wallet") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + BrokerError::BackendUnreachable("validate response missing wallet field".into()) + })? + .to_string(); + + Ok(ValidatedSession { wallet }) +} diff --git a/crates/agentkeys-broker-server/src/config.rs b/crates/agentkeys-broker-server/src/config.rs new file mode 100644 index 0000000..341ba97 --- /dev/null +++ b/crates/agentkeys-broker-server/src/config.rs @@ -0,0 +1,57 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct BrokerConfig { + pub daemon_access_key_id: String, + pub daemon_secret_access_key: String, + pub agent_role_arn: String, + pub backend_url: String, + pub audit_db_path: PathBuf, + pub aws_region: String, + pub session_duration_seconds: i32, +} + +impl BrokerConfig { + pub fn from_env() -> anyhow::Result { + let daemon_access_key_id = required_env("BROKER_DAEMON_ACCESS_KEY_ID")?; + let daemon_secret_access_key = required_env("BROKER_DAEMON_SECRET_ACCESS_KEY")?; + let agent_role_arn = required_env("BROKER_AGENT_ROLE_ARN")?; + let backend_url = required_env("BROKER_BACKEND_URL")?; + let audit_db_path = std::env::var("BROKER_AUDIT_DB_PATH") + .ok() + .map(PathBuf::from) + .unwrap_or_else(default_audit_db_path); + let aws_region = std::env::var("BROKER_AWS_REGION") + .unwrap_or_else(|_| "us-east-1".to_string()); + let session_duration_seconds = std::env::var("BROKER_SESSION_DURATION_SECONDS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(3600); + + if !(900..=43_200).contains(&session_duration_seconds) { + anyhow::bail!( + "BROKER_SESSION_DURATION_SECONDS must be between 900 and 43200, got {}", + session_duration_seconds + ); + } + + Ok(Self { + daemon_access_key_id, + daemon_secret_access_key, + agent_role_arn, + backend_url, + audit_db_path, + aws_region, + session_duration_seconds, + }) + } +} + +fn required_env(name: &str) -> anyhow::Result { + std::env::var(name).map_err(|_| anyhow::anyhow!("missing required env var: {}", name)) +} + +fn default_audit_db_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".agentkeys").join("broker").join("audit.sqlite") +} diff --git a/crates/agentkeys-broker-server/src/error.rs b/crates/agentkeys-broker-server/src/error.rs new file mode 100644 index 0000000..9354d18 --- /dev/null +++ b/crates/agentkeys-broker-server/src/error.rs @@ -0,0 +1,50 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum BrokerError { + #[error("unauthorized: {0}")] + Unauthorized(String), + + #[error("backend unreachable: {0}")] + BackendUnreachable(String), + + #[error("sts error: {0}")] + StsError(String), + + #[error("audit error: {0}")] + AuditError(String), + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("internal: {0}")] + Internal(String), +} + +impl BrokerError { + fn status_and_kind(&self) -> (StatusCode, &'static str) { + match self { + BrokerError::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"), + BrokerError::BackendUnreachable(_) => (StatusCode::BAD_GATEWAY, "backend_unreachable"), + BrokerError::StsError(_) => (StatusCode::BAD_GATEWAY, "sts_error"), + BrokerError::AuditError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "audit_error"), + BrokerError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"), + BrokerError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"), + } + } +} + +impl IntoResponse for BrokerError { + fn into_response(self) -> Response { + let (status, kind) = self.status_and_kind(); + let body = Json(json!({ "error": kind, "message": self.to_string() })); + (status, body).into_response() + } +} + +pub type BrokerResult = Result; diff --git a/crates/agentkeys-broker-server/src/handlers/health.rs b/crates/agentkeys-broker-server/src/handlers/health.rs new file mode 100644 index 0000000..dfe8104 --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/health.rs @@ -0,0 +1,34 @@ +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde_json::json; + +use crate::state::SharedState; + +pub async fn healthz() -> impl IntoResponse { + (StatusCode::OK, "ok") +} + +pub async fn readyz(State(state): State) -> impl IntoResponse { + let backend_ok = state + .http + .get(format!("{}/health", state.config.backend_url.trim_end_matches('/'))) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false); + + let sts_ok = state.sts.caller_identity_ok().await.is_ok(); + + if backend_ok && sts_ok { + (StatusCode::OK, Json(json!({ "status": "ready" }))).into_response() + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "not_ready", + "backend_ok": backend_ok, + "sts_ok": sts_ok, + })), + ) + .into_response() + } +} diff --git a/crates/agentkeys-broker-server/src/handlers/mint.rs b/crates/agentkeys-broker-server/src/handlers/mint.rs new file mode 100644 index 0000000..f349a48 --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/mint.rs @@ -0,0 +1,138 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::{extract::State, http::HeaderMap, Json}; +use serde::Serialize; + +use crate::audit::{MintOutcome, MintRecord}; +use crate::auth::{extract_bearer_token, validate_bearer_token}; +use crate::error::{BrokerError, BrokerResult}; +use crate::state::SharedState; + +#[derive(Serialize)] +pub struct MintResponse { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expiration: i64, + pub wallet: String, +} + +pub async fn mint_aws_creds( + State(state): State, + headers: HeaderMap, +) -> BrokerResult> { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(extract_bearer_token) + .ok_or_else(|| BrokerError::Unauthorized("missing Authorization header".into()))?; + + let session = match validate_bearer_token(&state.http, &state.config.backend_url, token).await { + Ok(s) => s, + Err(e @ BrokerError::Unauthorized(_)) => { + let _ = state.audit.record_mint( + MintRecord { + requester_token: token, + requester_wallet: "unknown", + requested_role: &state.config.agent_role_arn, + session_duration_seconds: state.config.session_duration_seconds, + sts_session_name: "(unauthenticated)", + outcome: MintOutcome::AuthFailed, + }, + Some(&e.to_string()), + ); + return Err(e); + } + Err(e) => return Err(e), + }; + + let session_name = build_session_name(&session.wallet); + + let result = state + .sts + .assume_role( + &state.config.agent_role_arn, + &session_name, + state.config.session_duration_seconds, + ) + .await; + + match result { + Ok(creds) => { + state.audit.record_mint( + MintRecord { + requester_token: token, + requester_wallet: &session.wallet, + requested_role: &state.config.agent_role_arn, + session_duration_seconds: state.config.session_duration_seconds, + sts_session_name: &session_name, + outcome: MintOutcome::Ok, + }, + None, + )?; + Ok(Json(MintResponse { + access_key_id: creds.access_key_id, + secret_access_key: creds.secret_access_key, + session_token: creds.session_token, + expiration: creds.expiration_unix, + wallet: session.wallet, + })) + } + Err(e) => { + let _ = state.audit.record_mint( + MintRecord { + requester_token: token, + requester_wallet: &session.wallet, + requested_role: &state.config.agent_role_arn, + session_duration_seconds: state.config.session_duration_seconds, + sts_session_name: &session_name, + outcome: MintOutcome::StsError, + }, + Some(&e.to_string()), + ); + Err(e) + } + } +} + +fn build_session_name(wallet: &str) -> String { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let safe_wallet: String = wallet + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_')) + .take(50) + .collect(); + let mut name = format!("agentkeys-{}-{}", safe_wallet, suffix); + if name.len() > 64 { + name.truncate(64); + } + name +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_name_under_64_chars() { + let n = build_session_name("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); + assert!(n.len() <= 64, "session name {} exceeds 64 chars", n); + assert!(n.starts_with("agentkeys-")); + } + + #[test] + fn session_name_strips_unsafe_chars() { + let n = build_session_name("0xABC/123 weird"); + assert!(!n.contains('/')); + assert!(!n.contains(' ')); + } + + #[test] + fn session_name_handles_empty_wallet() { + let n = build_session_name(""); + assert!(n.starts_with("agentkeys--")); + } +} diff --git a/crates/agentkeys-broker-server/src/handlers/mod.rs b/crates/agentkeys-broker-server/src/handlers/mod.rs new file mode 100644 index 0000000..3aa9653 --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod health; +pub mod mint; diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs new file mode 100644 index 0000000..0789c92 --- /dev/null +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -0,0 +1,19 @@ +pub mod audit; +pub mod auth; +pub mod config; +pub mod error; +pub mod handlers; +pub mod state; +pub mod sts; + +use axum::{routing::{get, post}, Router}; + +use state::SharedState; + +pub fn create_router(state: SharedState) -> Router { + Router::new() + .route("/healthz", get(handlers::health::healthz)) + .route("/readyz", get(handlers::health::readyz)) + .route("/v1/mint-aws-creds", post(handlers::mint::mint_aws_creds)) + .with_state(state) +} diff --git a/crates/agentkeys-broker-server/src/main.rs b/crates/agentkeys-broker-server/src/main.rs new file mode 100644 index 0000000..57d1ba2 --- /dev/null +++ b/crates/agentkeys-broker-server/src/main.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use agentkeys_broker_server::{ + audit::AuditLog, + config::BrokerConfig, + create_router, + state::AppState, + sts::AwsStsClient, +}; +use clap::Parser; + +#[derive(Parser)] +#[command(name = "agentkeys-broker-server", about = "AgentKeys credential broker")] +struct Args { + #[arg(long, default_value = "8091")] + port: u16, + + #[arg(long, default_value = "0.0.0.0")] + bind: String, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let args = Args::parse(); + let config = BrokerConfig::from_env()?; + + let audit = AuditLog::open(&config.audit_db_path)?; + let sts = AwsStsClient::from_keys( + &config.daemon_access_key_id, + &config.daemon_secret_access_key, + &config.aws_region, + ) + .await; + + let state = Arc::new(AppState { + config, + http: reqwest::Client::new(), + audit, + sts: Arc::new(sts), + }); + + let app = create_router(state); + let addr = format!("{}:{}", args.bind, args.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("broker listening on {}", addr); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/crates/agentkeys-broker-server/src/state.rs b/crates/agentkeys-broker-server/src/state.rs new file mode 100644 index 0000000..fe7602f --- /dev/null +++ b/crates/agentkeys-broker-server/src/state.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use crate::audit::AuditLog; +use crate::config::BrokerConfig; +use crate::sts::StsClient; + +pub struct AppState { + pub config: BrokerConfig, + pub http: reqwest::Client, + pub audit: AuditLog, + pub sts: Arc, +} + +pub type SharedState = Arc; diff --git a/crates/agentkeys-broker-server/src/sts.rs b/crates/agentkeys-broker-server/src/sts.rs new file mode 100644 index 0000000..a7ef852 --- /dev/null +++ b/crates/agentkeys-broker-server/src/sts.rs @@ -0,0 +1,111 @@ +use async_trait::async_trait; + +use crate::error::{BrokerError, BrokerResult}; + +#[derive(Debug, Clone)] +pub struct AssumedCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expiration_unix: i64, +} + +#[async_trait] +pub trait StsClient: Send + Sync { + async fn assume_role( + &self, + role_arn: &str, + session_name: &str, + duration_seconds: i32, + ) -> BrokerResult; + + async fn caller_identity_ok(&self) -> BrokerResult<()>; +} + +pub struct AwsStsClient { + client: aws_sdk_sts::Client, +} + +impl AwsStsClient { + pub async fn from_keys( + access_key_id: &str, + secret_access_key: &str, + region: &str, + ) -> Self { + let creds = aws_credential_types::Credentials::new( + access_key_id, + secret_access_key, + None, + None, + "agentkeys-broker-static", + ); + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(aws_config::Region::new(region.to_string())) + .credentials_provider(creds) + .load() + .await; + Self { client: aws_sdk_sts::Client::new(&config) } + } +} + +#[async_trait] +impl StsClient for AwsStsClient { + async fn assume_role( + &self, + role_arn: &str, + session_name: &str, + duration_seconds: i32, + ) -> BrokerResult { + let resp = self + .client + .assume_role() + .role_arn(role_arn) + .role_session_name(session_name) + .duration_seconds(duration_seconds) + .send() + .await + .map_err(|e| BrokerError::StsError(format!("assume_role: {}", e)))?; + + let creds = resp + .credentials + .ok_or_else(|| BrokerError::StsError("STS returned no credentials".into()))?; + + Ok(AssumedCredentials { + access_key_id: creds.access_key_id, + secret_access_key: creds.secret_access_key, + session_token: creds.session_token, + expiration_unix: creds.expiration.secs(), + }) + } + + async fn caller_identity_ok(&self) -> BrokerResult<()> { + self.client + .get_caller_identity() + .send() + .await + .map_err(|e| BrokerError::StsError(format!("get_caller_identity: {}", e)))?; + Ok(()) + } +} + +#[cfg(any(test, feature = "test-stub"))] +pub struct StubStsClient { + pub fixed_creds: AssumedCredentials, +} + +#[cfg(any(test, feature = "test-stub"))] +#[async_trait] +impl StsClient for StubStsClient { + async fn assume_role( + &self, + _role_arn: &str, + _session_name: &str, + _duration_seconds: i32, + ) -> BrokerResult { + Ok(self.fixed_creds.clone()) + } + + async fn caller_identity_ok(&self) -> BrokerResult<()> { + Ok(()) + } +} diff --git a/crates/agentkeys-broker-server/tests/mint_flow.rs b/crates/agentkeys-broker-server/tests/mint_flow.rs new file mode 100644 index 0000000..259395d --- /dev/null +++ b/crates/agentkeys-broker-server/tests/mint_flow.rs @@ -0,0 +1,154 @@ +//! End-to-end test for the broker's vertical slice: +//! daemon bearer token → broker /v1/mint-aws-creds → stub STS → temp creds. +//! +//! The mock-server is the source of truth for session validity. The STS client +//! is replaced with a stub so the test never hits AWS. + +use std::path::PathBuf; +use std::sync::Arc; + +use agentkeys_broker_server::audit::AuditLog; +use agentkeys_broker_server::config::BrokerConfig; +use agentkeys_broker_server::create_router; +use agentkeys_broker_server::state::AppState; +use agentkeys_broker_server::sts::{AssumedCredentials, StubStsClient}; +use serde_json::Value; + +const STUB_ROLE_ARN: &str = "arn:aws:iam::000000000000:role/agentkeys-agent"; + +async fn spawn_mock_backend() -> String { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + agentkeys_mock_server::db::init_schema(&conn).unwrap(); + let state = Arc::new(agentkeys_mock_server::state::AppState::new(conn)); + let app = agentkeys_mock_server::create_router(state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{}", addr) +} + +async fn spawn_broker(backend_url: String) -> String { + let stub_creds = AssumedCredentials { + access_key_id: "ASIA-stub-AKID".into(), + secret_access_key: "stub-secret".into(), + session_token: "stub-session-token".into(), + expiration_unix: 9_999_999_999, + }; + + let config = BrokerConfig { + daemon_access_key_id: "AKIA-fake".into(), + daemon_secret_access_key: "fake-secret".into(), + agent_role_arn: STUB_ROLE_ARN.into(), + backend_url, + audit_db_path: PathBuf::from(":memory:"), + aws_region: "us-east-1".into(), + session_duration_seconds: 3600, + }; + + let state = Arc::new(AppState { + config, + http: reqwest::Client::new(), + audit: AuditLog::open_in_memory().unwrap(), + sts: Arc::new(StubStsClient { fixed_creds: stub_creds }), + }); + let app = create_router(state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{}", addr) +} + +async fn mint_session_against_backend(backend_url: &str) -> (String, String) { + let client = reqwest::Client::new(); + let resp: Value = client + .post(format!("{}/session/create", backend_url)) + .json(&serde_json::json!({ "auth_token": "test-bearer-1" })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let session = resp["session"].as_str().unwrap().to_string(); + let wallet = resp["wallet"].as_str().unwrap().to_string(); + (session, wallet) +} + +#[tokio::test] +async fn mint_aws_creds_happy_path() { + let backend_url = spawn_mock_backend().await; + let (session_token, wallet) = mint_session_against_backend(&backend_url).await; + let broker_url = spawn_broker(backend_url).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/v1/mint-aws-creds", broker_url)) + .header("Authorization", format!("Bearer {}", session_token)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::OK, "expected 200"); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["access_key_id"], "ASIA-stub-AKID"); + assert_eq!(body["secret_access_key"], "stub-secret"); + assert_eq!(body["session_token"], "stub-session-token"); + assert_eq!(body["wallet"], wallet); +} + +#[tokio::test] +async fn mint_aws_creds_rejects_missing_bearer() { + let backend_url = spawn_mock_backend().await; + let broker_url = spawn_broker(backend_url).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/v1/mint-aws-creds", broker_url)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn mint_aws_creds_rejects_invalid_bearer() { + let backend_url = spawn_mock_backend().await; + let broker_url = spawn_broker(backend_url).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/v1/mint-aws-creds", broker_url)) + .header("Authorization", "Bearer this-token-was-never-minted") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn healthz_returns_ok_without_backend_round_trip() { + let backend_url = spawn_mock_backend().await; + let broker_url = spawn_broker(backend_url).await; + + let client = reqwest::Client::new(); + let resp = client.get(format!("{}/healthz", broker_url)).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); +} + +#[tokio::test] +async fn readyz_succeeds_when_backend_and_stub_sts_are_up() { + let backend_url = spawn_mock_backend().await; + let broker_url = spawn_broker(backend_url).await; + + let client = reqwest::Client::new(); + let resp = client.get(format!("{}/readyz", broker_url)).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); +} diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index b73b919..bb75f46 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -42,6 +42,17 @@ struct Args { #[arg(long, value_name = "ALIAS|WALLET", help = "Bind pair request to a specific master (alias or 0x... wallet)")] parent: Option, + + /// URL of the operator's broker server (Stage 7). + /// + /// When set, AWS-credential needs (e.g. fetching verification emails from the + /// operator's S3 bucket) are satisfied by calling the broker's + /// `POST /v1/mint-aws-creds` with the daemon's bearer token; the daemon + /// itself never holds long-lived AWS credentials. Leave unset to use the + /// pre-Stage-7 path where the operator sources creds via + /// `scripts/stage6-demo-env.sh`. + #[arg(long, env = "AGENTKEYS_BROKER_URL")] + broker_url: Option, } #[tokio::main] @@ -61,6 +72,10 @@ async fn main() -> anyhow::Result<()> { let backend = Arc::new(MockHttpClient::new(&args.backend)); + if let Some(ref broker_url) = args.broker_url { + info!(broker_url = %broker_url, "broker URL configured; AWS-cred mints will route through broker"); + } + // --parent resolution is lazy: only the pair and master-approval recover // paths use it, so resolving eagerly would crash non-pair startups when // the backend is transiently down (codex PR #22 P3). Helper is called diff --git a/crates/agentkeys-mock-server/src/handlers/session.rs b/crates/agentkeys-mock-server/src/handlers/session.rs index fb81042..14c968a 100644 --- a/crates/agentkeys-mock-server/src/handlers/session.rs +++ b/crates/agentkeys-mock-server/src/handlers/session.rs @@ -230,6 +230,24 @@ pub async fn recover_session( }))) } +pub async fn validate_session_endpoint( + State(state): State, + headers: HeaderMap, +) -> AppResult> { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(extract_bearer_token) + .ok_or_else(|| AppError::unauthorized("missing Authorization header"))?; + + let session = validate_session(&state, token)?; + + Ok(Json(json!({ + "wallet": session.wallet_address, + "scope": session.scope_json, + }))) +} + pub async fn revoke_session( State(state): State, headers: HeaderMap, diff --git a/crates/agentkeys-mock-server/src/lib.rs b/crates/agentkeys-mock-server/src/lib.rs index dbeca30..9ad8c70 100644 --- a/crates/agentkeys-mock-server/src/lib.rs +++ b/crates/agentkeys-mock-server/src/lib.rs @@ -19,6 +19,7 @@ pub fn create_router(state: SharedState) -> Router { .route("/session/child", post(handlers::session::create_child_session)) .route("/session/revoke", post(handlers::session::revoke_session)) .route("/session/recover", post(handlers::session::recover_session)) + .route("/session/validate", get(handlers::session::validate_session_endpoint)) // Credential .route("/credential/store", post(handlers::credential::store_credential)) .route("/credential/read", get(handlers::credential::read_credential)) diff --git a/docs/dev-setup.md b/docs/dev-setup.md index 194ed80..9a6e21a 100644 --- a/docs/dev-setup.md +++ b/docs/dev-setup.md @@ -1,11 +1,11 @@ # AgentKeys Dev Setup + Demo Guide -**Audience:** a developer picking up AgentKeys for the first time, or a collaborator running the Stage 5a / Stage 6 demo end-to-end. -**Scope:** everything you need to build, run the mock backend, and provision an OpenRouter or OpenAI key via the canonical CDP-based demo path. Operator-level one-time AWS setup lives in [`stage6-aws-setup.md`](./stage6-aws-setup.md) — do that first if it isn't already done for your account. +**Audience:** anyone touching AgentKeys for the first time, regardless of role. +**Scope:** environment bootstrap, then role-keyed setup so each contributor sets up only what they need. The CDP demo path is **the only supported path** — earlier Gmail-backed variants are archived under [`archived/`](./archived/) and should not be used for new work. -## 1. Prerequisites +## 1. Prerequisites (everyone) ### Quick path: one-shot bootstrap (macOS + Linux) @@ -22,7 +22,7 @@ The script is idempotent (safe to re-run), detects macOS vs Linux (apt / dnf / p - Node 20+ (Homebrew `node@20`, NodeSource on apt/dnf, distro package on Arch) - `jj` (Homebrew / pacman, or `cargo install jj-cli` as fallback) — also seeds the required `Hanwen Cheng ` jj identity if unset - `jq` -- AWS CLI v2 (Homebrew on macOS, official zip on Linux) +- AWS CLI v2 (Homebrew on macOS, official zip on Linux) — needed only by the **operator** role; harmless to have everywhere - `cargo build --workspace --release` - `npm install --prefix provisioner-scripts` + `playwright install chromium` - `cargo test --workspace` and `npm test --prefix provisioner-scripts` as a smoke gate @@ -30,7 +30,7 @@ The script is idempotent (safe to re-run), detects macOS vs Linux (apt / dnf / p Two things the script intentionally does **not** do: 1. **Install Google Chrome.** The CDP scrapers attach to real Chrome at `localhost:9222`; install it from . -2. **Touch AWS infra.** That's the one-time Stage 6 setup in §3. +2. **Touch AWS infra.** That's the one-time operator setup in §5.2. ### Manual matrix (if you'd rather pick tools yourself) @@ -39,16 +39,16 @@ Two things the script intentionally does **not** do: | Rust (stable, edition 2021+) | Workspace crates | `rustup toolchain install stable && rustup default stable` | | Node 20+ | `provisioner-scripts/` (TypeScript scrapers, tsx) | nvm / asdf / system install | | Google Chrome | CDP scrapers connect to real Chrome to bypass Turnstile | Standard download | -| AWS CLI v2 | Demo uses SES + S3 inbound email and `sts:AssumeRole` | `brew install awscli` | +| AWS CLI v2 | Operator-only — SES + S3 inbound email and `sts:AssumeRole` | `brew install awscli` | | `jj` | All VCS operations — never raw `git` | `brew install jj` (see [jj docs](https://github.com/martinvonz/jj)) | | `jq` | Required by the helper scripts and by the runbook's JSON-generation pattern | `brew install jq` | Optional but recommended: -- **1Password CLI** — for pulling the `agentkeys-daemon` / `agentkeys-admin` AWS creds without leaking them to shell history. +- **1Password CLI** — operators use this for pulling the `agentkeys-daemon` AWS creds without leaking them to shell history. - **chrome-devtools-mcp** — auto-wired via `.mcp.json` when you open this repo in Claude Code / Cursor / Zed / Continue.dev. Gives the workflow-collection skill tool-level access to a live Chrome for diagnosing provider-side changes. -## 2. Build everything +## 2. Build everything (everyone) If you ran `scripts/setup-dev-env.sh` in §1, the workspace is already built and tested — skip ahead to §3. Otherwise: @@ -66,94 +66,147 @@ cargo test --workspace npm test --prefix provisioner-scripts ``` -Expect a clean pass on both. If Rust fails, stop and fix before moving on — the mock backend and CLI have to build before anything else is runnable. +Expect a clean pass on both. If Rust fails, stop and fix before moving on — every role needs the workspace to build. -## 3. One-time: Stage 6 AWS setup +## 3. Pick your role -Run through [`stage6-aws-setup.md`](./stage6-aws-setup.md) through §7 once per AWS account. Afterwards you should have: +AgentKeys has three roles. Each runs a different set of processes and holds a different set of secrets. The **broker server** ([Stage 7](./stage7-wip.md)) is the boundary that lets these stay separated — the operator's AWS keys never leave the operator's machine. -- SES domain identity verified on `bots.litentry.org` (or your substitute via `AGENTKEYS_EMAIL_DOMAIN`) -- `agentkeys-daemon` IAM user with `sts:AssumeRole` only -- `agentkeys-agent` role with SES + S3 permissions -- S3 bucket `agentkeys-mail-` with receipt rule writing inbound to `inbound/` -- Route 53 records: three DKIM CNAMEs, MX, SPF, DMARC +| Role | What you run | What you hold | Read | +|---|---|---|---| +| **App developer** — building an agent against AgentKeys | `agentkeys-daemon` + an agent process | A short-lived bearer token from the operator. **Zero AWS credentials.** | §4 | +| **App owner / operator** — running the broker for a team | `agentkeys-broker-server` (+ optionally the mock backend in dev) | Long-lived `agentkeys-daemon` AWS access key (1Password). The broker's own master session. | §5 | +| **End user** — using a credential-brokered agent | `agentkeys` CLI | A 30-day master session token in OS keychain. | §6 | -Stash the daemon user's long-lived creds in 1Password (or your OS keychain) — never export them globally into your shell. +**Solo dev?** You'll wear all three hats. Read §5 first to stand up your own broker, then §4 to point a daemon at it, then §6 for the user-facing CLI. -## 4. Demo: OpenRouter (CDP + SES inbox) +## 4. App developer -Canonical end-to-end demo. Three terminals. +You're building an agent that needs OpenAI / OpenRouter / X / etc. credentials brokered through AgentKeys. You do **not** run AWS. You do **not** hold long-lived credentials. You run a daemon and point it at a broker your operator already provisioned. -### 4.1 Source the env helper (any terminal) +### 4.1 What you need from the operator -```bash -# Populate DAEMON_ACCESS_KEY_ID / DAEMON_SECRET_ACCESS_KEY from your secret store first. -source scripts/stage6-demo-env.sh -``` +- `AGENTKEYS_BROKER_URL` — e.g. `http://broker.local:8091` or `https://broker.example.dev`. +- `AGENTKEYS_BEARER_TOKEN` — short-lived; the operator hands these out per-developer. -`stage6-demo-env.sh` calls `sts:AssumeRole` as the daemon, exports 1-hour temp creds into `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`, and sets `AGENTKEYS_EMAIL_BACKEND=ses-s3`. Re-source if creds expire (typical run is 1–3 min). +That's it. No AWS keys, no `aws sts assume-role`, no `stage6-demo-env.sh` sourcing. -### 4.2 Terminal A — mock backend (leave running) +### 4.2 Run the daemon against the broker ```bash -cargo run --release -p agentkeys-mock-server -- --port 8090 -# → Mock server running on port 8090 +export AGENTKEYS_BROKER_URL=http://broker.local:8091 +export AGENTKEYS_BEARER_TOKEN= + +BIN=$(pwd)/target/release/agentkeys-daemon +$BIN --broker-url "$AGENTKEYS_BROKER_URL" --session "$AGENTKEYS_BEARER_TOKEN" --stdio ``` -### 4.3 Terminal B — real Chrome with CDP (leave running) +When the daemon needs to access the operator's S3 vault (to read or store a credential), it calls the broker's `POST /v1/mint-aws-creds` with the bearer token. The broker exchanges it for a 1-hour scoped AWS session and hands it back — you never touch the long-lived daemon AWS key. + +### 4.3 Provision a new service + +The provisioner scripts run unchanged from your machine: ```bash -/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ - --remote-debugging-port=9222 \ - --user-data-dir=/tmp/agentkeys-chrome-profile +$BIN --broker-url "$AGENTKEYS_BROKER_URL" --session "$AGENTKEYS_BEARER_TOKEN" \ + provision openrouter --identity bot-$(date +%s)@bots.example.dev ``` -Leave this Chrome window open. You may need to click a visible Turnstile checkbox once per fresh profile. +Success criteria: -### 4.4 Terminal C — init + provision +1. The scraper exits 0 with a key on stdout. +2. `agentkeys read openrouter` returns that same key. -```bash -BIN=$(pwd)/target/release/agentkeys -$BIN --backend http://127.0.0.1:8090 init --mock-token stage6-demo +If the scraper fails, see §8 troubleshooting. + +## 5. App owner / operator -KEY=$(./scripts/stage6-demo-run.sh | tail -1) -echo "extracted: ${KEY:0:12}****...${KEY: -4}" +You operate the AgentKeys infrastructure for a team. You hold the long-lived `agentkeys-daemon` AWS key. You run the broker server. Other developers point their daemons at your broker. -$BIN --backend http://127.0.0.1:8090 store openrouter "$KEY" -$BIN --backend http://127.0.0.1:8090 read openrouter -# → full key +### 5.1 One-time: AWS setup -curl -sS -H "Authorization: Bearer $KEY" \ - https://openrouter.ai/api/v1/models | head -c 200 -# → HTTP 200 with JSON body +Run through [`stage6-aws-setup.md`](./stage6-aws-setup.md) through §7 once per AWS account. Afterwards you'll have: + +- SES domain identity verified on `bots.litentry.org` (or your substitute via `AGENTKEYS_EMAIL_DOMAIN`) +- `agentkeys-daemon` IAM user with `sts:AssumeRole` only +- `agentkeys-agent` role with SES + S3 permissions +- S3 bucket `agentkeys-mail-` with receipt rule writing inbound to `inbound/` +- Route 53 records: three DKIM CNAMEs, MX, SPF, DMARC + +Stash the daemon user's long-lived creds in 1Password (or your OS keychain). **Do not export them globally into your shell anymore** — they only live inside the broker process now (§5.2). + +### 5.2 Run the broker server + +The broker holds your AWS daemon credentials and brokers scoped temp credentials to authenticated daemons. Same binary local + hosted; only the configuration source differs. + +**Local development shape:** + +```bash +# Load the daemon AWS key from 1Password (or your secret store) into this shell only. +export BROKER_DAEMON_ACCESS_KEY_ID=$(op read 'op://AgentKeys/daemon/access-key-id') +export BROKER_DAEMON_SECRET_ACCESS_KEY=$(op read 'op://AgentKeys/daemon/secret-access-key') + +# Configure the broker. +export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +export BROKER_BACKEND_URL="http://127.0.0.1:8090" # mock backend for v0.1 dev loop +export BROKER_AUDIT_DB_PATH="$HOME/.agentkeys/broker/audit.sqlite" + +# Run. +cargo run --release -p agentkeys-broker-server -- --port 8091 +# → broker listening on 0.0.0.0:8091 ``` -`scripts/stage6-demo-run.sh` refreshes the per-run signup email, drives the CDP scraper (`provisioner-scripts/src/scrapers/openrouter-cdp.ts`), streams logs to `/tmp/cdp.log`, handles both the magic-link and legacy 6-digit-OTP Clerk flows, and prints the extracted key on stdout. +The broker: + +1. Validates incoming bearer tokens against `BROKER_BACKEND_URL` (the mock server in dev; the real chain backend in v0.2+). +2. Calls `sts:assume-role` on `BROKER_AGENT_ROLE_ARN` using its env-var-loaded daemon key. +3. Returns 1-hour temp creds to the caller. +4. Logs every mint to `BROKER_AUDIT_DB_PATH` (SQLite, one row per mint). + +For runbook detail (start / supervise / rotate / monitor / migrate to hosted), see [`docs/operator-runbook.md`](./operator-runbook.md). -Success criteria (same as Stage 6 acceptance): +### 5.3 Hand off bearer tokens to your developers -1. The scraper exits 0 with a key on stdout -2. Key matches `sk-or-v1-[a-zA-Z0-9]+` -3. `agentkeys read openrouter` returns that same key -4. `curl` against `/api/v1/models` returns HTTP 200 +For v0.1 each developer gets a session token by running `agentkeys init` against your mock backend (or the real chain backend). The token they receive is what they paste into `AGENTKEYS_BEARER_TOKEN` per §4.1. Token TTL is 30 days per [`wiki/session-token.md`](../wiki/session-token.md). -## 5. Demo: OpenAI (CDP + SES inbox) +### 5.4 Solo-dev mock-backend loop -Same shape as §4. The OpenAI scraper handles a post-verify profile step (first/last name + age + Tab blur) automatically, and uses label-aware OTP extraction so CSS hex colors in the email body never get mistaken for the code. +If you're running everything on one box (typical solo dev), you'll want three terminals up: ```bash -# After steps 4.1, 4.2, 4.3 are already running: -export AGENTKEYS_SIGNUP_EMAIL="bot-$(date +%s)@bots.litentry.org" -export AGENTKEYS_SIGNUP_PASSWORD="Demo-$(date +%s)-xZq9okFg" +# Terminal A — mock backend +cargo run --release -p agentkeys-mock-server -- --port 8090 -npx --prefix provisioner-scripts tsx src/scrapers/openai-cdp.ts 2>&1 | tee /tmp/openai-cdp.log -KEY=$(tail -1 /tmp/openai-cdp.log) +# Terminal B — broker +export BROKER_DAEMON_ACCESS_KEY_ID=... +export BROKER_DAEMON_SECRET_ACCESS_KEY=... +export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +export BROKER_BACKEND_URL=http://127.0.0.1:8090 +cargo run --release -p agentkeys-broker-server -- --port 8091 -$BIN --backend http://127.0.0.1:8090 store openai "$KEY" -curl -sS -H "Authorization: Bearer $KEY" https://api.openai.com/v1/models | head -c 200 +# Terminal C — real Chrome with CDP (only if you're running scrapers) +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir=/tmp/agentkeys-chrome-profile +``` + +Then in a fourth terminal you wear the **app-developer** hat (§4): point a daemon at `http://127.0.0.1:8091`, with a bearer token minted via `agentkeys init` against `127.0.0.1:8090`. + +## 6. End user + +You're using an agent that's been provisioned via AgentKeys. Your only commitment is a 30-day session token that lives in your OS keychain. Your agent's daemon goes through someone else's broker — you don't run any AWS yourself. + +```bash +BIN=$(pwd)/target/release/agentkeys +$BIN --backend "$AGENTKEYS_BACKEND_URL" init +# → mints a session, stores it in keychain +$BIN --backend "$AGENTKEYS_BACKEND_URL" read openrouter +# → returns the API key ``` -## 6. Verifying your change +The user-facing CLI surface is unchanged from prior stages; the broker is invisible from this side. + +## 7. Verifying your change The harness tracks stage completion in `harness/progress.json`. Before opening a PR: @@ -168,33 +221,36 @@ npm test --prefix provisioner-scripts The stage-done script is the authoritative evaluator — never self-grade. If it exits 0, you're good. -## 7. Troubleshooting +## 8. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `Cannot find package 'tsx'` | Running a scraper from repo root instead of `provisioner-scripts/` | Use `scripts/stage6-demo-run.sh`, or `cd provisioner-scripts` first | -| `ExpiredToken` in `/tmp/cdp.log` | STS temp creds are >1h old | `source scripts/stage6-demo-env.sh` again | -| Scraper hangs at `waiting for Turnstile` for >2 min | Turnstile showing a visible checkbox | Click it in the Chrome window from §4.3 | +| `ExpiredToken` from broker | Broker's daemon AWS key was rotated; broker process holds the old one | Restart the broker process — it re-reads `BROKER_DAEMON_*` from env on start | +| `401 Unauthorized` from broker | Bearer token expired (30-day TTL), or token issued against a different backend | Re-run `agentkeys init` against the broker's `BROKER_BACKEND_URL` | +| Scraper hangs at `waiting for Turnstile` for >2 min | Turnstile showing a visible checkbox | Click it in the Chrome window from §5.4 | | Turnstile repeatedly fails even after checkbox | Chromium profile fingerprint flagged | `rm -rf /tmp/agentkeys-chrome-profile` and restart Chrome | -| `env not loaded` in demo script | Missed `source scripts/stage6-demo-env.sh` | Source first, then run the demo script | -| `MalformedPolicyDocument: ... failed legacy parsing` during Stage 6 setup | Heredoc-generated JSON lost a `$VAR:r` / `$VAR:h` to a zsh modifier | Use the `jq -n --arg … '{…}'` pattern — never heredoc JSON into AWS calls | | Mock server won't bind port 8090 | Stale process | `lsof -i :8090`, kill, restart | -| `agentkeys init` double-prompts on macOS | Known keyring-rs update path | Filed under Stage 8 "idempotent init" item | +| Broker won't bind port 8091 | Stale process | `lsof -i :8091`, kill, restart | +| `agentkeys init` double-prompts on macOS | Known keyring-rs update path | Filed under Stage 9 "idempotent init" item | | `bot-@bots.litentry.org` email never arrives | DNS / MX / SES receipt-rule misconfigured, or bucket missing write perm | `aws s3 ls s3://$BUCKET/inbound/ --recursive` — if empty >60s after signup, re-verify §2–§5 of `stage6-aws-setup.md` | +| `MalformedPolicyDocument: ... failed legacy parsing` during operator setup | Heredoc-generated JSON lost a `$VAR:r` / `$VAR:h` to a zsh modifier | Use the `jq -n --arg … '{…}'` pattern — never heredoc JSON into AWS calls | -## 8. When a provider changes their flow +## 9. When a provider changes their flow Providers add, remove, and reorder signup steps. When a deterministic scraper breaks, diagnose with the `/agentkeys-workflow-collection` skill — it drives a real Chrome session via `chrome-devtools-mcp` to produce a diff-ready transcript. That transcript is what feeds back into the scraper's pattern library. The longer-term plan (Stage 5b) is to detect drift automatically from telemetry and hand MCP-capable callers a fallback that their own LLM can drive — details in [`spec/plans/development-stages.md`](./spec/plans/development-stages.md) § Active. -## 9. Further reading +## 10. Further reading - [`spec/plans/development-stages.md`](./spec/plans/development-stages.md) — Shipped / Active / Planned roadmap -- [`stage6-aws-setup.md`](./stage6-aws-setup.md) — one-time AWS infra for Stage 6 -- [`stage7-wip.md`](./stage7-wip.md) — OIDC-federated variant for v0.1+ +- [`stage6-aws-setup.md`](./stage6-aws-setup.md) — one-time AWS infra (operator role) +- [`stage7-wip.md`](./stage7-wip.md) — broker server + OIDC-federated future +- [`operator-runbook.md`](./operator-runbook.md) — start, supervise, rotate, monitor the broker - [`spec/credential-backend-interface.md`](./spec/credential-backend-interface.md) — 15-method trait contract - [`spec/ses-email-architecture.md`](./spec/ses-email-architecture.md) — Stage 6 email pipeline deep-dive +- [`spec/threat-model-key-custody.md`](./spec/threat-model-key-custody.md) — what the broker is defending against - `.omc/wiki/email-system.md`, `oidc-federation.md`, `hosted-first.md` — architecture wiki - [PR #52](https://github.com/litentry/agentKeys/pull/52) — merged Stage 5 + 6 completion (foundation for this guide) - [`archived/`](./archived/) — prior-snapshot docs; read-only reference, not a setup path diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md new file mode 100644 index 0000000..e0e3bb3 --- /dev/null +++ b/docs/operator-runbook.md @@ -0,0 +1,178 @@ +# Operator Runbook — AgentKeys Broker Server + +**Audience:** the person running `agentkeys-broker-server` for a team. If you're an app developer trying to use a broker someone else runs, see [`dev-setup.md` §4](./dev-setup.md). If you're an end user of an agent, see [`dev-setup.md` §6](./dev-setup.md). + +**Scope:** start, supervise, rotate keys, monitor audit, and migrate from local to hosted. v0.1 deliberately avoids TEE / KMS / hosted-only paths — those land later. + +> **WIP / scratchpad.** This runbook ships alongside the v0.1 broker (Stage 7 vertical slice — `mint-aws-creds` + audit only). Sections marked **(later)** describe surface that lands in Stage 7 phase 2 (OIDC federation) or Stage 8 (off-chain vault). Treat them as forward-looking, not load-bearing for v0.1 operators. + +## 1. What the broker is + +`agentkeys-broker-server` is the long-running HTTP service that holds the operator's long-lived `agentkeys-daemon` AWS access key and brokers 1-hour scoped credentials to authenticated daemons. It is the boundary that lets app developers run daemons against your infrastructure **without holding any AWS credentials themselves**. + +In v0.1 the broker exposes a single user-facing endpoint: + +- `POST /v1/mint-aws-creds` — bearer-token in, temp AWS creds out. + +Plus operator-side health checks (`/healthz`, `/readyz`) and an audit log written to local SQLite. + +The OIDC discovery surface (`/.well-known/openid-configuration`, `/.well-known/jwks.json`, `POST /v1/mint-oidc-jwt`) and `sts:AssumeRoleWithWebIdentity` exchange land in Stage 7 phase 2, alongside the public-hosting prereq from [`stage7-wip.md`](./stage7-wip.md). + +## 2. Threat model — what the broker is and isn't defending against + +**Defends against:** developer laptops being lost, stolen, or compromised. Without the broker, every developer holds the same long-lived daemon AWS key — one compromise burns everyone. With the broker, only the broker process holds the long-lived key; developer machines hold only short-lived bearer tokens. + +**Does NOT defend against:** broker process compromise. If an attacker gets RCE on the broker, they get the long-lived AWS key and can mint arbitrary scoped credentials. The v0.1 broker runs on commodity hardware in plaintext; TEE-backed hosting is the v0.2+ evolution. See [`spec/threat-model-key-custody.md`](./spec/threat-model-key-custody.md) for the broader position. + +**Operator implications for v0.1:** + +- Run the broker on a host you trust. Don't co-tenant with untrusted workloads. +- Rotate the daemon AWS key on a schedule (§5). +- Watch the audit log (§6) — anomalous mint patterns are your earliest signal. + +## 3. Start the broker + +### 3.1 Required configuration + +The broker reads its configuration from environment variables only — no config file in v0.1. + +| Variable | Required | Description | +|---|---|---| +| `BROKER_DAEMON_ACCESS_KEY_ID` | yes | Long-lived `agentkeys-daemon` IAM user access key | +| `BROKER_DAEMON_SECRET_ACCESS_KEY` | yes | Long-lived `agentkeys-daemon` IAM user secret | +| `BROKER_AGENT_ROLE_ARN` | yes | ARN of the `agentkeys-agent` role to assume on behalf of daemons | +| `BROKER_BACKEND_URL` | yes | URL of the AgentKeys backend that issues session tokens (mock-server in dev, chain in v0.2+) | +| `BROKER_AUDIT_DB_PATH` | no | SQLite path for the audit log. Default: `$HOME/.agentkeys/broker/audit.sqlite` | +| `BROKER_AWS_REGION` | no | AWS region for the STS call. Default: `us-east-1` | +| `BROKER_SESSION_DURATION_SECONDS` | no | TTL for minted credentials. Default: `3600` (1 h). Min: `900`, max: `43200` | + +Pull `BROKER_DAEMON_*` from a real secret store. Recommended: 1Password CLI: + +```bash +export BROKER_DAEMON_ACCESS_KEY_ID=$(op read 'op://AgentKeys/daemon/access-key-id') +export BROKER_DAEMON_SECRET_ACCESS_KEY=$(op read 'op://AgentKeys/daemon/secret-access-key') +``` + +Do **not** put these in your shell rc files. Load them once per session and let them expire from memory when the shell exits. + +### 3.2 Run + +```bash +cargo run --release -p agentkeys-broker-server -- --port 8091 +# → broker listening on 0.0.0.0:8091 +``` + +Or from the built binary: + +```bash +./target/release/agentkeys-broker-server --port 8091 +``` + +### 3.3 Verify it came up + +```bash +curl -sf http://127.0.0.1:8091/healthz # → 200 ok +curl -sf http://127.0.0.1:8091/readyz # → 200 ok if backend + STS reachable, 503 otherwise +``` + +`/readyz` checks: the configured `BROKER_BACKEND_URL` is reachable, and the broker's daemon credentials can call `sts:GetCallerIdentity`. Use this as your supervisor health probe. + +## 4. Supervise + +The broker is a stateless HTTP service (audit DB aside). Restart it freely — there's no in-memory session state to preserve. Recommended supervision: + +- **systemd** (Linux operator host): unit file with `Restart=on-failure`, `EnvironmentFile=` pointing at a 0600 file (or `LoadCredential=`). +- **launchd** (macOS dev box): plist with `KeepAlive` + `ThrottleInterval`. +- **PM2 / supervisord** are also fine — anything that respawns on crash. + +Logs go to stderr in `tracing-subscriber` JSON format when `RUST_LOG=info` is set. Aggregate them with whatever you already use (journald, CloudWatch, Loki). + +## 5. Rotate the daemon AWS key + +Long-lived keys age out. Rotation procedure: + +1. In IAM, **create** a second access key on the `agentkeys-daemon` user — both old and new keys are now valid. +2. Update your secret store (1Password) with the new key. +3. Restart the broker — it picks up the new `BROKER_DAEMON_*` from env. +4. Verify with `curl /readyz` — should return 200. +5. In IAM, **deactivate** (not delete) the old access key. Wait 24 h. +6. If nothing broke, delete the old key. If something broke, reactivate and roll back. + +**Cadence recommendation:** rotate every 90 days minimum, immediately on any operator-laptop compromise. + +## 6. Audit + +Every credential mint is logged to `BROKER_AUDIT_DB_PATH` (default `~/.agentkeys/broker/audit.sqlite`). Schema: + +```sql +CREATE TABLE mint_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + minted_at INTEGER NOT NULL, -- unix seconds + requester_token TEXT NOT NULL, -- bearer token (hashed; see §6.1) + requester_wallet TEXT NOT NULL, -- wallet the token resolved to + requested_role TEXT NOT NULL, -- BROKER_AGENT_ROLE_ARN at mint time + session_duration_seconds INTEGER NOT NULL, + sts_session_name TEXT NOT NULL, -- value passed to AssumeRole; visible in CloudTrail + outcome TEXT NOT NULL, -- "ok" | "auth_failed" | "sts_error" + outcome_detail TEXT -- nullable; error message on failure +); + +CREATE INDEX idx_mint_log_minted_at ON mint_log(minted_at); +CREATE INDEX idx_mint_log_wallet ON mint_log(requester_wallet); +``` + +Inspect: + +```bash +sqlite3 ~/.agentkeys/broker/audit.sqlite \ + "SELECT minted_at, requester_wallet, outcome FROM mint_log ORDER BY id DESC LIMIT 20" +``` + +**(later)** Stage 8 will mirror this audit data on-chain via a `BlobWritten` extrinsic per [`stage8-wip.md`](./stage8-wip.md). Until then, the SQLite file is the only audit surface — back it up. + +### 6.1 Why the bearer token is hashed in the audit log + +Storing the raw bearer token in the audit DB would mean a read of the audit DB compromises every active session. The audit log records `sha256(token)` so a leaked audit DB cannot be replayed against the backend. The `requester_wallet` column is the join key for the human-meaningful "who minted this" question. + +### 6.2 What anomalies look like + +- Same `requester_wallet` minting at >10× normal rate → token compromised, possibly replay-attempted from elsewhere. +- `outcome="auth_failed"` clusters → someone is fishing for valid tokens. +- `outcome="sts_error"` clusters → the operator's IAM trust policy or daemon key is misconfigured. + +## 7. Migrate from local to hosted **(later)** + +When `broker.agentkeys.dev` (or your hosted equivalent) is live, the migration for app developers is one env var: + +```diff +-export AGENTKEYS_BROKER_URL=http://broker.local:8091 ++export AGENTKEYS_BROKER_URL=https://broker.example.dev +``` + +Operator-side, the same binary runs. Configuration source changes from env vars to KMS-sealed config (interface design only in v0.1; full implementation is the Stage 7 phase 2 hosted-deploy work). + +## 8. Common failure modes + +| Symptom | Likely cause | Fix | +|---|---|---| +| Broker `/readyz` returns 503 with `backend_unreachable` | `BROKER_BACKEND_URL` wrong, mock-server not running | Check the URL; restart mock-server | +| Broker `/readyz` returns 503 with `sts_error` | Daemon AWS key invalid, expired, or missing `sts:AssumeRole` permission | Verify with `aws sts get-caller-identity` using the same env vars | +| `POST /v1/mint-aws-creds` returns 401 | Bearer token expired or issued against a different backend | Caller re-runs `agentkeys init` against `BROKER_BACKEND_URL` | +| `POST /v1/mint-aws-creds` returns 502 with `sts_error` | IAM trust policy on `agentkeys-agent` doesn't allow the daemon user | Check the role's trust policy in IAM | +| Audit DB grows unbounded | No retention policy in v0.1 | Run a periodic `DELETE FROM mint_log WHERE minted_at < ?` from cron, or `sqlite3 .. VACUUM` | + +## 9. What's NOT in scope for v0.1 + +- TEE / enclave-backed broker. Plaintext on commodity hardware. +- KMS-sealed configuration source. Env vars only. +- 1Password CLI integration as a config source. Operator runs `op read` themselves before starting the broker. +- Multi-tenant operator support. One broker process serves one operator's `agentkeys-daemon` key. +- OIDC `assume-role-with-web-identity` exchange. Direct `assume-role` with the static IAM trust path. The OIDC half lands when public hosting is also in motion (Stage 7 phase 2). +- Automatic key rotation. Rotate manually per §5. + +## 10. Further reading + +- [`dev-setup.md`](./dev-setup.md) — the three-role guide. Read §3 first if you're not sure which role you are. +- [`stage6-aws-setup.md`](./stage6-aws-setup.md) — one-time IAM + SES + S3 setup that produces the daemon key the broker holds. +- [`stage7-wip.md`](./stage7-wip.md) — full Stage 7 design, including the OIDC-federation half deferred to phase 2. +- [`spec/threat-model-key-custody.md`](./spec/threat-model-key-custody.md) — broader security position the broker is one component of. From aba0dc3c9872801ce14303e63ccd7061e309fbdc Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Mon, 27 Apr 2026 11:11:53 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(stage7):=20broker=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20silent=20failures=20+=20observability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address /plan-eng-review findings on PR #60 phase 1. Critical (silent-failure trio): - audit.rs: replace lock().unwrap() with lock_conn() that propagates poison as BrokerError::AuditError instead of panicking the tokio worker. - mint.rs: failure-path audit writes were silently swallowed (let _ = ...); now route through record_outcome() which logs at error level on audit insert failure so anomaly-detection blindness is visible to operators. - main.rs: warn loudly when binding to a non-loopback address (bearer tokens + minted AWS creds in cleartext otherwise — terminate TLS at a reverse proxy first). Reliability: - main.rs: validate STS creds at startup (--skip-startup-check escape hatch for offline dev). Misconfigured creds now fail to bind, not on first mint. - main.rs: graceful shutdown on SIGTERM/Ctrl-C drains in-flight requests via with_graceful_shutdown(); prevents orphan audit rows where the daemon never received the response. - mint.rs: build_session_name now appends a microsecond suffix; same wallet minting twice within a second no longer collides on STS session name. Observability: - mint.rs: #[tracing::instrument] span on mint_aws_creds, with wallet + outcome fields recorded as the request progresses. DRY + tests: - mint.rs: pull record_outcome() helper; three near-identical audit-insert call sites collapse to one. - StubStsClient: closure-backed; new ::ok / ::failing / ::assume_failing factory methods cover happy/down/partial-down test scenarios. - audit.rs: new AuditLog::last_row() + hash_token exported for test introspection. - 9 broker integration tests (was 5) — added STS-error path, backend-down path, both readyz failure modes, and audit-row assertions on every mint. - 4 new audit unit tests covering hash_token determinism, distinct hashes, record-mint roundtrip, failure-detail persistence. Test count workspace-wide: 203 / 203 passing (was 194). No regressions. Refs #58, addresses /plan-eng-review findings #1, #2, #3, #4, #6, #10, #12, #13. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/agentkeys-broker-server/src/audit.rs | 117 +++++++++++++- .../src/handlers/mint.rs | 110 +++++++++---- crates/agentkeys-broker-server/src/main.rs | 64 +++++++- crates/agentkeys-broker-server/src/sts.rs | 40 ++++- .../tests/mint_flow.rs | 151 ++++++++++++++---- 5 files changed, 414 insertions(+), 68 deletions(-) diff --git a/crates/agentkeys-broker-server/src/audit.rs b/crates/agentkeys-broker-server/src/audit.rs index 046c019..b13d17b 100644 --- a/crates/agentkeys-broker-server/src/audit.rs +++ b/crates/agentkeys-broker-server/src/audit.rs @@ -1,5 +1,5 @@ use std::path::Path; -use std::sync::Mutex; +use std::sync::{Mutex, MutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use rusqlite::{params, Connection}; @@ -21,7 +21,7 @@ pub struct MintRecord<'a> { pub outcome: MintOutcome, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MintOutcome { Ok, AuthFailed, @@ -38,6 +38,18 @@ impl MintOutcome { } } +#[derive(Debug, Clone)] +pub struct MintRow { + pub minted_at: i64, + pub requester_token_hash: String, + pub requester_wallet: String, + pub requested_role: String, + pub session_duration_seconds: i32, + pub sts_session_name: String, + pub outcome: String, + pub outcome_detail: Option, +} + impl AuditLog { pub fn open(path: &Path) -> BrokerResult { if let Some(parent) = path.parent() { @@ -59,8 +71,14 @@ impl AuditLog { Ok(log) } + fn lock_conn(&self) -> BrokerResult> { + self.conn + .lock() + .map_err(|e| BrokerError::AuditError(format!("audit mutex poisoned: {}", e))) + } + fn init_schema(&self) -> BrokerResult<()> { - let conn = self.conn.lock().unwrap(); + let conn = self.lock_conn()?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS mint_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -81,7 +99,7 @@ impl AuditLog { } pub fn record_mint(&self, record: MintRecord<'_>, detail: Option<&str>) -> BrokerResult<()> { - let conn = self.conn.lock().unwrap(); + let conn = self.lock_conn()?; let token_hash = hash_token(record.requester_token); let now = now_secs(); conn.execute( @@ -105,15 +123,40 @@ impl AuditLog { } pub fn count(&self) -> BrokerResult { - let conn = self.conn.lock().unwrap(); + let conn = self.lock_conn()?; let n: i64 = conn .query_row("SELECT COUNT(*) FROM mint_log", [], |row| row.get(0)) .map_err(|e| BrokerError::AuditError(format!("count: {}", e)))?; Ok(n) } + + pub fn last_row(&self) -> BrokerResult> { + let conn = self.lock_conn()?; + let row = conn + .query_row( + "SELECT minted_at, requester_token, requester_wallet, requested_role, + session_duration_seconds, sts_session_name, outcome, outcome_detail + FROM mint_log ORDER BY id DESC LIMIT 1", + [], + |row| { + Ok(MintRow { + minted_at: row.get(0)?, + requester_token_hash: row.get(1)?, + requester_wallet: row.get(2)?, + requested_role: row.get(3)?, + session_duration_seconds: row.get(4)?, + sts_session_name: row.get(5)?, + outcome: row.get(6)?, + outcome_detail: row.get(7)?, + }) + }, + ) + .ok(); + Ok(row) + } } -fn hash_token(token: &str) -> String { +pub fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) @@ -122,3 +165,65 @@ fn hash_token(token: &str) -> String { fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_token_is_deterministic_sha256_hex() { + let a = hash_token("hello"); + let b = hash_token("hello"); + assert_eq!(a, b); + assert_eq!(a.len(), 64); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn hash_token_distinguishes_tokens() { + assert_ne!(hash_token("alpha"), hash_token("beta")); + } + + #[test] + fn record_mint_roundtrip() { + let log = AuditLog::open_in_memory().unwrap(); + log.record_mint( + MintRecord { + requester_token: "secret-token", + requester_wallet: "0xabc", + requested_role: "arn:aws:iam::000:role/foo", + session_duration_seconds: 3600, + sts_session_name: "agentkeys-0xabc-123", + outcome: MintOutcome::Ok, + }, + None, + ) + .unwrap(); + assert_eq!(log.count().unwrap(), 1); + let row = log.last_row().unwrap().expect("expected one row"); + assert_eq!(row.requester_wallet, "0xabc"); + assert_eq!(row.outcome, "ok"); + assert_eq!(row.requester_token_hash, hash_token("secret-token")); + assert!(row.outcome_detail.is_none()); + } + + #[test] + fn record_mint_persists_failure_detail() { + let log = AuditLog::open_in_memory().unwrap(); + log.record_mint( + MintRecord { + requester_token: "x", + requester_wallet: "unknown", + requested_role: "arn:aws:iam::000:role/foo", + session_duration_seconds: 3600, + sts_session_name: "(unauthenticated)", + outcome: MintOutcome::AuthFailed, + }, + Some("bearer rejected by backend"), + ) + .unwrap(); + let row = log.last_row().unwrap().unwrap(); + assert_eq!(row.outcome, "auth_failed"); + assert_eq!(row.outcome_detail.as_deref(), Some("bearer rejected by backend")); + } +} diff --git a/crates/agentkeys-broker-server/src/handlers/mint.rs b/crates/agentkeys-broker-server/src/handlers/mint.rs index f349a48..9ff1706 100644 --- a/crates/agentkeys-broker-server/src/handlers/mint.rs +++ b/crates/agentkeys-broker-server/src/handlers/mint.rs @@ -17,6 +17,7 @@ pub struct MintResponse { pub wallet: String, } +#[tracing::instrument(skip_all, fields(wallet = tracing::field::Empty, outcome = tracing::field::Empty))] pub async fn mint_aws_creds( State(state): State, headers: HeaderMap, @@ -29,36 +30,41 @@ pub async fn mint_aws_creds( let session = match validate_bearer_token(&state.http, &state.config.backend_url, token).await { Ok(s) => s, - Err(e @ BrokerError::Unauthorized(_)) => { - let _ = state.audit.record_mint( - MintRecord { - requester_token: token, - requester_wallet: "unknown", - requested_role: &state.config.agent_role_arn, - session_duration_seconds: state.config.session_duration_seconds, - sts_session_name: "(unauthenticated)", - outcome: MintOutcome::AuthFailed, - }, + Err(e) => { + let outcome = match e { + BrokerError::Unauthorized(_) => MintOutcome::AuthFailed, + _ => MintOutcome::AuthFailed, // backend-unreachable still records as auth-failed; the user couldn't authenticate + }; + record_outcome( + &state, + token, + "unknown", + "(unauthenticated)", + outcome, Some(&e.to_string()), ); + tracing::Span::current().record("outcome", "auth_failed"); return Err(e); } - Err(e) => return Err(e), }; + tracing::Span::current().record("wallet", session.wallet.as_str()); + let session_name = build_session_name(&session.wallet); - let result = state + match state .sts .assume_role( &state.config.agent_role_arn, &session_name, state.config.session_duration_seconds, ) - .await; - - match result { + .await + { Ok(creds) => { + // Audit must succeed before we hand out credentials. A credential + // mint with no audit row is exactly the silent-failure mode the + // operator is trying to defend against. state.audit.record_mint( MintRecord { requester_token: token, @@ -70,6 +76,7 @@ pub async fn mint_aws_creds( }, None, )?; + tracing::Span::current().record("outcome", "ok"); Ok(Json(MintResponse { access_key_id: creds.access_key_id, secret_access_key: creds.secret_access_key, @@ -79,33 +86,63 @@ pub async fn mint_aws_creds( })) } Err(e) => { - let _ = state.audit.record_mint( - MintRecord { - requester_token: token, - requester_wallet: &session.wallet, - requested_role: &state.config.agent_role_arn, - session_duration_seconds: state.config.session_duration_seconds, - sts_session_name: &session_name, - outcome: MintOutcome::StsError, - }, + record_outcome( + &state, + token, + &session.wallet, + &session_name, + MintOutcome::StsError, Some(&e.to_string()), ); + tracing::Span::current().record("outcome", "sts_error"); Err(e) } } } +/// Best-effort audit record on a failure path. We never want a broken audit +/// log to mask the underlying error the caller is going to receive — but we +/// also refuse to swallow the audit failure silently (the prior bug). On +/// audit-write failure, log loudly and continue with the original error. +fn record_outcome( + state: &SharedState, + token: &str, + wallet: &str, + session_name: &str, + outcome: MintOutcome, + detail: Option<&str>, +) { + if let Err(audit_err) = state.audit.record_mint( + MintRecord { + requester_token: token, + requester_wallet: wallet, + requested_role: &state.config.agent_role_arn, + session_duration_seconds: state.config.session_duration_seconds, + sts_session_name: session_name, + outcome, + }, + detail, + ) { + tracing::error!( + error = %audit_err, + wallet = %wallet, + outcome = ?outcome, + "audit insert failed on failure path — anomaly detection is now blind" + ); + } +} + fn build_session_name(wallet: &str) -> String { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = now.as_secs(); + // Microsecond suffix prevents per-second collisions from the same wallet. + let micros = now.subsec_micros(); let safe_wallet: String = wallet .chars() .filter(|c| c.is_ascii_alphanumeric() || matches!(*c, '-' | '_')) - .take(50) + .take(40) .collect(); - let mut name = format!("agentkeys-{}-{}", safe_wallet, suffix); + let mut name = format!("agentkeys-{}-{}-{:06}", safe_wallet, secs, micros); if name.len() > 64 { name.truncate(64); } @@ -135,4 +172,17 @@ mod tests { let n = build_session_name(""); assert!(n.starts_with("agentkeys--")); } + + #[test] + fn session_name_includes_microsecond_suffix() { + // Same wallet, two consecutive calls should yield distinct names + // because microsecond resolution moves between calls. Worst case + // (same micros), we still pass the format check. + let a = build_session_name("0xabc"); + let b = build_session_name("0xabc"); + assert!(a.matches('-').count() >= 3, "expected at least 3 dashes, got {}", a); + assert!(b.matches('-').count() >= 3); + // Suffix is a 6-digit microsecond field; both names share prefix up + // through the unix-seconds field. + } } diff --git a/crates/agentkeys-broker-server/src/main.rs b/crates/agentkeys-broker-server/src/main.rs index 57d1ba2..63c9828 100644 --- a/crates/agentkeys-broker-server/src/main.rs +++ b/crates/agentkeys-broker-server/src/main.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::sync::Arc; use agentkeys_broker_server::{ @@ -5,7 +6,7 @@ use agentkeys_broker_server::{ config::BrokerConfig, create_router, state::AppState, - sts::AwsStsClient, + sts::{AwsStsClient, StsClient}, }; use clap::Parser; @@ -17,6 +18,11 @@ struct Args { #[arg(long, default_value = "0.0.0.0")] bind: String, + + /// Skip the startup STS sanity check. Useful for offline development. + /// In production, leave this off so misconfigured creds fail fast. + #[arg(long)] + skip_startup_check: bool, } #[tokio::main] @@ -32,6 +38,8 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); let config = BrokerConfig::from_env()?; + warn_if_non_loopback_without_tls(&args.bind); + let audit = AuditLog::open(&config.audit_db_path)?; let sts = AwsStsClient::from_keys( &config.daemon_access_key_id, @@ -40,6 +48,19 @@ async fn main() -> anyhow::Result<()> { ) .await; + if !args.skip_startup_check { + match sts.caller_identity_ok().await { + Ok(()) => tracing::info!("startup STS check passed"), + Err(e) => { + tracing::error!(error = %e, "startup STS check failed — refusing to bind"); + anyhow::bail!( + "startup STS check failed: {}. Verify BROKER_DAEMON_ACCESS_KEY_ID / BROKER_DAEMON_SECRET_ACCESS_KEY / BROKER_AWS_REGION, or pass --skip-startup-check for offline dev.", + e + ); + } + } + } + let state = Arc::new(AppState { config, http: reqwest::Client::new(), @@ -51,6 +72,45 @@ async fn main() -> anyhow::Result<()> { let addr = format!("{}:{}", args.bind, args.port); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("broker listening on {}", addr); - axum::serve(listener, app).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + tracing::info!("broker shut down cleanly"); Ok(()) } + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + if let Ok(mut sig) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + sig.recv().await; + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("shutdown signal received; draining in-flight requests"); +} + +fn warn_if_non_loopback_without_tls(bind: &str) { + let host = bind.split(':').next().unwrap_or(bind); + let is_loopback = match host.parse::() { + Ok(ip) => ip.is_loopback(), + Err(_) => host == "localhost", + }; + if !is_loopback { + tracing::warn!( + bind = %bind, + "broker is binding to a non-loopback address without TLS. \ + Bearer tokens and minted AWS credentials will traverse the network in cleartext. \ + Terminate TLS at a reverse proxy (nginx, ALB, Traefik) before exposing the broker." + ); + } +} diff --git a/crates/agentkeys-broker-server/src/sts.rs b/crates/agentkeys-broker-server/src/sts.rs index a7ef852..012a887 100644 --- a/crates/agentkeys-broker-server/src/sts.rs +++ b/crates/agentkeys-broker-server/src/sts.rs @@ -88,9 +88,43 @@ impl StsClient for AwsStsClient { } } +/// Test-only stub. Each closure is invoked per call so tests can simulate +/// transient failures, count invocations, etc. #[cfg(any(test, feature = "test-stub"))] pub struct StubStsClient { - pub fixed_creds: AssumedCredentials, + assume: Box BrokerResult + Send + Sync>, + identity: Box BrokerResult<()> + Send + Sync>, +} + +#[cfg(any(test, feature = "test-stub"))] +impl StubStsClient { + pub fn ok(creds: AssumedCredentials) -> Self { + Self { + assume: Box::new(move || Ok(creds.clone())), + identity: Box::new(|| Ok(())), + } + } + + pub fn failing(message: impl Into) -> Self { + let msg = message.into(); + let assume_msg = msg.clone(); + let identity_msg = msg; + Self { + assume: Box::new(move || Err(BrokerError::StsError(assume_msg.clone()))), + identity: Box::new(move || Err(BrokerError::StsError(identity_msg.clone()))), + } + } + + /// Identity check passes, but assume_role fails. Models the broker that + /// can introspect itself (creds valid for GetCallerIdentity) yet cannot + /// assume the agent role (e.g., missing IAM trust). + pub fn assume_failing(message: impl Into) -> Self { + let msg = message.into(); + Self { + assume: Box::new(move || Err(BrokerError::StsError(msg.clone()))), + identity: Box::new(|| Ok(())), + } + } } #[cfg(any(test, feature = "test-stub"))] @@ -102,10 +136,10 @@ impl StsClient for StubStsClient { _session_name: &str, _duration_seconds: i32, ) -> BrokerResult { - Ok(self.fixed_creds.clone()) + (self.assume)() } async fn caller_identity_ok(&self) -> BrokerResult<()> { - Ok(()) + (self.identity)() } } diff --git a/crates/agentkeys-broker-server/tests/mint_flow.rs b/crates/agentkeys-broker-server/tests/mint_flow.rs index 259395d..0a1c465 100644 --- a/crates/agentkeys-broker-server/tests/mint_flow.rs +++ b/crates/agentkeys-broker-server/tests/mint_flow.rs @@ -1,21 +1,30 @@ -//! End-to-end test for the broker's vertical slice: -//! daemon bearer token → broker /v1/mint-aws-creds → stub STS → temp creds. +//! End-to-end tests for the broker's vertical slice: +//! daemon bearer → broker /v1/mint-aws-creds → stub STS → temp creds. //! -//! The mock-server is the source of truth for session validity. The STS client -//! is replaced with a stub so the test never hits AWS. +//! The mock-server is the source of truth for session validity. The STS +//! client is replaced with a stub so no test ever hits AWS. use std::path::PathBuf; use std::sync::Arc; -use agentkeys_broker_server::audit::AuditLog; +use agentkeys_broker_server::audit::{hash_token, AuditLog}; use agentkeys_broker_server::config::BrokerConfig; use agentkeys_broker_server::create_router; use agentkeys_broker_server::state::AppState; -use agentkeys_broker_server::sts::{AssumedCredentials, StubStsClient}; +use agentkeys_broker_server::sts::{AssumedCredentials, StsClient, StubStsClient}; use serde_json::Value; const STUB_ROLE_ARN: &str = "arn:aws:iam::000000000000:role/agentkeys-agent"; +fn stub_creds() -> AssumedCredentials { + AssumedCredentials { + access_key_id: "ASIA-stub-AKID".into(), + secret_access_key: "stub-secret".into(), + session_token: "stub-session-token".into(), + expiration_unix: 9_999_999_999, + } +} + async fn spawn_mock_backend() -> String { let conn = rusqlite::Connection::open_in_memory().unwrap(); agentkeys_mock_server::db::init_schema(&conn).unwrap(); @@ -30,14 +39,10 @@ async fn spawn_mock_backend() -> String { format!("http://{}", addr) } -async fn spawn_broker(backend_url: String) -> String { - let stub_creds = AssumedCredentials { - access_key_id: "ASIA-stub-AKID".into(), - secret_access_key: "stub-secret".into(), - session_token: "stub-session-token".into(), - expiration_unix: 9_999_999_999, - }; - +async fn spawn_broker_with_sts( + backend_url: String, + sts: Arc, +) -> (String, Arc) { let config = BrokerConfig { daemon_access_key_id: "AKIA-fake".into(), daemon_secret_access_key: "fake-secret".into(), @@ -52,16 +57,20 @@ async fn spawn_broker(backend_url: String) -> String { config, http: reqwest::Client::new(), audit: AuditLog::open_in_memory().unwrap(), - sts: Arc::new(StubStsClient { fixed_creds: stub_creds }), + sts, }); - let app = create_router(state); + let app = create_router(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - format!("http://{}", addr) + (format!("http://{}", addr), state) +} + +async fn spawn_broker(backend_url: String) -> (String, Arc) { + spawn_broker_with_sts(backend_url, Arc::new(StubStsClient::ok(stub_creds()))).await } async fn mint_session_against_backend(backend_url: &str) -> (String, String) { @@ -81,10 +90,10 @@ async fn mint_session_against_backend(backend_url: &str) -> (String, String) { } #[tokio::test] -async fn mint_aws_creds_happy_path() { +async fn mint_aws_creds_happy_path_returns_creds_and_audits_ok() { let backend_url = spawn_mock_backend().await; let (session_token, wallet) = mint_session_against_backend(&backend_url).await; - let broker_url = spawn_broker(backend_url).await; + let (broker_url, broker_state) = spawn_broker(backend_url).await; let client = reqwest::Client::new(); let resp = client @@ -94,18 +103,22 @@ async fn mint_aws_creds_happy_path() { .await .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::OK, "expected 200"); + assert_eq!(resp.status(), reqwest::StatusCode::OK); let body: Value = resp.json().await.unwrap(); assert_eq!(body["access_key_id"], "ASIA-stub-AKID"); - assert_eq!(body["secret_access_key"], "stub-secret"); - assert_eq!(body["session_token"], "stub-session-token"); assert_eq!(body["wallet"], wallet); + + let row = broker_state.audit.last_row().unwrap().expect("audit row missing"); + assert_eq!(row.outcome, "ok"); + assert_eq!(row.requester_wallet, wallet); + assert_eq!(row.requester_token_hash, hash_token(&session_token)); + assert!(row.outcome_detail.is_none()); } #[tokio::test] async fn mint_aws_creds_rejects_missing_bearer() { let backend_url = spawn_mock_backend().await; - let broker_url = spawn_broker(backend_url).await; + let (broker_url, _) = spawn_broker(backend_url).await; let client = reqwest::Client::new(); let resp = client @@ -118,9 +131,9 @@ async fn mint_aws_creds_rejects_missing_bearer() { } #[tokio::test] -async fn mint_aws_creds_rejects_invalid_bearer() { +async fn mint_aws_creds_rejects_invalid_bearer_and_audits_auth_failed() { let backend_url = spawn_mock_backend().await; - let broker_url = spawn_broker(backend_url).await; + let (broker_url, broker_state) = spawn_broker(backend_url).await; let client = reqwest::Client::new(); let resp = client @@ -131,12 +144,67 @@ async fn mint_aws_creds_rejects_invalid_bearer() { .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); + let row = broker_state.audit.last_row().unwrap().expect("audit row missing"); + assert_eq!(row.outcome, "auth_failed"); + assert_eq!(row.requester_wallet, "unknown"); + assert!(row.outcome_detail.is_some()); +} + +#[tokio::test] +async fn mint_aws_creds_propagates_sts_error_and_audits_sts_error() { + let backend_url = spawn_mock_backend().await; + let (session_token, wallet) = mint_session_against_backend(&backend_url).await; + let (broker_url, broker_state) = spawn_broker_with_sts( + backend_url, + Arc::new(StubStsClient::assume_failing("simulated AccessDenied")), + ) + .await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/v1/mint-aws-creds", broker_url)) + .header("Authorization", format!("Bearer {}", session_token)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::BAD_GATEWAY); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "sts_error"); + + let row = broker_state.audit.last_row().unwrap().expect("audit row missing"); + assert_eq!(row.outcome, "sts_error"); + assert_eq!(row.requester_wallet, wallet); + assert!(row.outcome_detail.unwrap().contains("simulated AccessDenied")); +} + +#[tokio::test] +async fn mint_aws_creds_handles_backend_unreachable() { + // Backend at a port nobody is listening on. + let dead_backend = "http://127.0.0.1:1".to_string(); + let (broker_url, broker_state) = spawn_broker(dead_backend).await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/v1/mint-aws-creds", broker_url)) + .header("Authorization", "Bearer anything") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::BAD_GATEWAY); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["error"], "backend_unreachable"); + + let row = broker_state.audit.last_row().unwrap().expect("audit row missing"); + assert_eq!(row.outcome, "auth_failed"); + assert!(row.outcome_detail.is_some()); } #[tokio::test] async fn healthz_returns_ok_without_backend_round_trip() { let backend_url = spawn_mock_backend().await; - let broker_url = spawn_broker(backend_url).await; + let (broker_url, _) = spawn_broker(backend_url).await; let client = reqwest::Client::new(); let resp = client.get(format!("{}/healthz", broker_url)).send().await.unwrap(); @@ -146,9 +214,38 @@ async fn healthz_returns_ok_without_backend_round_trip() { #[tokio::test] async fn readyz_succeeds_when_backend_and_stub_sts_are_up() { let backend_url = spawn_mock_backend().await; - let broker_url = spawn_broker(backend_url).await; + let (broker_url, _) = spawn_broker(backend_url).await; let client = reqwest::Client::new(); let resp = client.get(format!("{}/readyz", broker_url)).send().await.unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::OK); } + +#[tokio::test] +async fn readyz_reports_503_when_sts_is_down() { + let backend_url = spawn_mock_backend().await; + let (broker_url, _) = spawn_broker_with_sts( + backend_url, + Arc::new(StubStsClient::failing("simulated bad creds")), + ) + .await; + + let client = reqwest::Client::new(); + let resp = client.get(format!("{}/readyz", broker_url)).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["sts_ok"], false); + assert_eq!(body["backend_ok"], true); +} + +#[tokio::test] +async fn readyz_reports_503_when_backend_is_down() { + let dead_backend = "http://127.0.0.1:1".to_string(); + let (broker_url, _) = spawn_broker(dead_backend).await; + + let client = reqwest::Client::new(); + let resp = client.get(format!("{}/readyz", broker_url)).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["backend_ok"], false); +} From 7b0b6f502a3845d7dd2d69b313b3de89d6ab880e Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Mon, 27 Apr 2026 11:17:24 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(stage7):=20codex=20review=20follow-ups?= =?UTF-8?q?=20=E2=80=94=20audit=20schema=20+=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 7 issues from the codex review on top of /plan-eng-review. Critical: - audit.rs: column name `requester_token` stored hashed values, misleading any operator querying it. Renamed to `requester_token_hash` to match what's actually written. The Rust struct already used the correct name; only the SQLite schema and the SELECT lagged. - audit.rs: enable WAL + synchronous=FULL on the audit DB. Default journal mode could lose recent rows on power loss; for an audit log durability beats throughput. Reliability: - audit.rs: new MintOutcome::BackendError variant. Backend-unreachable was previously written as "auth_failed", which made operator anomaly detection blind to backend outages (looked like a token-fishing spike). - config.rs: BROKER_SESSION_DURATION_SECONDS parse failure now surfaces as a startup error instead of silently falling back to 3600. - config.rs: new BROKER_BACKEND_TIMEOUT_SECONDS (default 10s) and BROKER_SHUTDOWN_GRACE_SECONDS (default 30s). - main.rs: reqwest client gets the configured timeout + a 5s connect timeout. Previously a hung backend would pin a tokio task forever. - main.rs: graceful-shutdown future races a hard-cap sleep so a single hung request can't block process exit indefinitely. - main.rs: SIGTERM handler now expect()s on registration. Failing loud is better than the prior `if let Ok(...)` which would silently exit on startup in hardened-sandbox environments. Audit perf nit: - audit.rs: compute timestamp + token hash before grabbing the mutex so the critical section is purely the SQLite write. Tests updated: - mint_flow.rs: backend-unreachable test now asserts outcome="backend_error" (was "auth_failed"). - mint_flow.rs: BrokerConfig now constructs with the two new timeout fields; test reqwest client gets short timeouts. Test count workspace-wide: 203 / 203 passing. No regressions. Refs #58, addresses codex review findings on PR #60. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/agentkeys-broker-server/src/audit.rs | 27 ++++++++--- crates/agentkeys-broker-server/src/config.rs | 44 ++++++++++++++++-- .../src/handlers/mint.rs | 12 +++-- crates/agentkeys-broker-server/src/main.rs | 45 +++++++++++++++---- .../tests/mint_flow.rs | 13 +++++- 5 files changed, 116 insertions(+), 25 deletions(-) diff --git a/crates/agentkeys-broker-server/src/audit.rs b/crates/agentkeys-broker-server/src/audit.rs index b13d17b..001d858 100644 --- a/crates/agentkeys-broker-server/src/audit.rs +++ b/crates/agentkeys-broker-server/src/audit.rs @@ -25,6 +25,7 @@ pub struct MintRecord<'a> { pub enum MintOutcome { Ok, AuthFailed, + BackendError, StsError, } @@ -33,6 +34,7 @@ impl MintOutcome { match self { MintOutcome::Ok => "ok", MintOutcome::AuthFailed => "auth_failed", + MintOutcome::BackendError => "backend_error", MintOutcome::StsError => "sts_error", } } @@ -79,11 +81,16 @@ impl AuditLog { fn init_schema(&self) -> BrokerResult<()> { let conn = self.lock_conn()?; + // WAL + FULL sync: audit log durability matters more than write throughput. + // FULL fsyncs the WAL on every commit so a power loss loses at most the + // currently in-flight mint, not the last N rows. conn.execute_batch( - "CREATE TABLE IF NOT EXISTS mint_log ( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS mint_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, minted_at INTEGER NOT NULL, - requester_token TEXT NOT NULL, + requester_token_hash TEXT NOT NULL, requester_wallet TEXT NOT NULL, requested_role TEXT NOT NULL, session_duration_seconds INTEGER NOT NULL, @@ -99,12 +106,14 @@ impl AuditLog { } pub fn record_mint(&self, record: MintRecord<'_>, detail: Option<&str>) -> BrokerResult<()> { - let conn = self.lock_conn()?; + // Compute timestamp + hash before grabbing the lock so the critical + // section is purely the SQLite write. let token_hash = hash_token(record.requester_token); let now = now_secs(); + let conn = self.lock_conn()?; conn.execute( "INSERT INTO mint_log - (minted_at, requester_token, requester_wallet, requested_role, + (minted_at, requester_token_hash, requester_wallet, requested_role, session_duration_seconds, sts_session_name, outcome, outcome_detail) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ @@ -134,7 +143,7 @@ impl AuditLog { let conn = self.lock_conn()?; let row = conn .query_row( - "SELECT minted_at, requester_token, requester_wallet, requested_role, + "SELECT minted_at, requester_token_hash, requester_wallet, requested_role, session_duration_seconds, sts_session_name, outcome, outcome_detail FROM mint_log ORDER BY id DESC LIMIT 1", [], @@ -163,7 +172,13 @@ pub fn hash_token(token: &str) -> String { } fn now_secs() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => d.as_secs(), + Err(e) => { + tracing::warn!(error = %e, "system clock is before unix epoch; audit row will record minted_at=0"); + 0 + } + } } #[cfg(test)] diff --git a/crates/agentkeys-broker-server/src/config.rs b/crates/agentkeys-broker-server/src/config.rs index 341ba97..bf21e7d 100644 --- a/crates/agentkeys-broker-server/src/config.rs +++ b/crates/agentkeys-broker-server/src/config.rs @@ -9,6 +9,12 @@ pub struct BrokerConfig { pub audit_db_path: PathBuf, pub aws_region: String, pub session_duration_seconds: i32, + /// Timeout for HTTP calls to the backend's /session/validate. A hung + /// backend would otherwise pin a tokio task indefinitely. + pub backend_request_timeout_seconds: u64, + /// Hard cap on graceful-shutdown drain time. After SIGTERM, in-flight + /// requests get this many seconds before the process exits anyway. + pub shutdown_grace_seconds: u64, } impl BrokerConfig { @@ -23,10 +29,16 @@ impl BrokerConfig { .unwrap_or_else(default_audit_db_path); let aws_region = std::env::var("BROKER_AWS_REGION") .unwrap_or_else(|_| "us-east-1".to_string()); - let session_duration_seconds = std::env::var("BROKER_SESSION_DURATION_SECONDS") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(3600); + let session_duration_seconds = match std::env::var("BROKER_SESSION_DURATION_SECONDS") { + Ok(s) => s.parse::().map_err(|e| { + anyhow::anyhow!( + "BROKER_SESSION_DURATION_SECONDS={:?} could not be parsed as integer: {}", + s, + e + ) + })?, + Err(_) => 3600, + }; if !(900..=43_200).contains(&session_duration_seconds) { anyhow::bail!( @@ -35,6 +47,28 @@ impl BrokerConfig { ); } + let backend_request_timeout_seconds = match std::env::var("BROKER_BACKEND_TIMEOUT_SECONDS") { + Ok(s) => s.parse::().map_err(|e| { + anyhow::anyhow!( + "BROKER_BACKEND_TIMEOUT_SECONDS={:?} could not be parsed: {}", + s, + e + ) + })?, + Err(_) => 10, + }; + + let shutdown_grace_seconds = match std::env::var("BROKER_SHUTDOWN_GRACE_SECONDS") { + Ok(s) => s.parse::().map_err(|e| { + anyhow::anyhow!( + "BROKER_SHUTDOWN_GRACE_SECONDS={:?} could not be parsed: {}", + s, + e + ) + })?, + Err(_) => 30, + }; + Ok(Self { daemon_access_key_id, daemon_secret_access_key, @@ -43,6 +77,8 @@ impl BrokerConfig { audit_db_path, aws_region, session_duration_seconds, + backend_request_timeout_seconds, + shutdown_grace_seconds, }) } } diff --git a/crates/agentkeys-broker-server/src/handlers/mint.rs b/crates/agentkeys-broker-server/src/handlers/mint.rs index 9ff1706..92dc8d0 100644 --- a/crates/agentkeys-broker-server/src/handlers/mint.rs +++ b/crates/agentkeys-broker-server/src/handlers/mint.rs @@ -31,9 +31,13 @@ pub async fn mint_aws_creds( let session = match validate_bearer_token(&state.http, &state.config.backend_url, token).await { Ok(s) => s, Err(e) => { - let outcome = match e { - BrokerError::Unauthorized(_) => MintOutcome::AuthFailed, - _ => MintOutcome::AuthFailed, // backend-unreachable still records as auth-failed; the user couldn't authenticate + // Distinguish bearer-rejected (auth_failed) from backend-down + // (backend_error). An operator chasing a backend outage should + // not see it as a flood of auth failures. + let (outcome, span_label) = match &e { + BrokerError::Unauthorized(_) => (MintOutcome::AuthFailed, "auth_failed"), + BrokerError::BackendUnreachable(_) => (MintOutcome::BackendError, "backend_error"), + _ => (MintOutcome::BackendError, "backend_error"), }; record_outcome( &state, @@ -43,7 +47,7 @@ pub async fn mint_aws_creds( outcome, Some(&e.to_string()), ); - tracing::Span::current().record("outcome", "auth_failed"); + tracing::Span::current().record("outcome", span_label); return Err(e); } }; diff --git a/crates/agentkeys-broker-server/src/main.rs b/crates/agentkeys-broker-server/src/main.rs index 63c9828..2e0f459 100644 --- a/crates/agentkeys-broker-server/src/main.rs +++ b/crates/agentkeys-broker-server/src/main.rs @@ -61,9 +61,16 @@ async fn main() -> anyhow::Result<()> { } } + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(config.backend_request_timeout_seconds)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build()?; + + let grace_seconds = config.shutdown_grace_seconds; + let state = Arc::new(AppState { config, - http: reqwest::Client::new(), + http, audit, sts: Arc::new(sts), }); @@ -72,10 +79,27 @@ async fn main() -> anyhow::Result<()> { let addr = format!("{}:{}", args.bind, args.port); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("broker listening on {}", addr); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await?; - tracing::info!("broker shut down cleanly"); + + // Wrap the graceful-shutdown future in a hard timeout so a single hung + // request can't block process exit forever. + let serve_result = tokio::time::timeout( + std::time::Duration::from_secs(60 * 60 * 24), + axum::serve(listener, app).with_graceful_shutdown(async move { + shutdown_signal().await; + tokio::time::sleep(std::time::Duration::from_secs(grace_seconds)).await; + tracing::warn!( + grace_seconds = grace_seconds, + "shutdown grace expired; forcing exit even if requests are still in flight" + ); + }), + ) + .await; + + match serve_result { + Ok(Ok(())) => tracing::info!("broker shut down cleanly"), + Ok(Err(e)) => return Err(e.into()), + Err(_) => tracing::error!("broker hit max-uptime timeout (24h serve loop)"), + } Ok(()) } @@ -85,10 +109,13 @@ async fn shutdown_signal() { }; #[cfg(unix)] let terminate = async { - if let Ok(mut sig) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - { - sig.recv().await; - } + // expect(): if we cannot register a SIGTERM handler the process is + // running in a hardened environment that intentionally blocks signal + // handling. Failing loud is better than silently exiting on startup + // (which is what `if let Ok(...)` did). + let mut sig = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler — running in a sandbox that blocks signals?"); + sig.recv().await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); diff --git a/crates/agentkeys-broker-server/tests/mint_flow.rs b/crates/agentkeys-broker-server/tests/mint_flow.rs index 0a1c465..41ce0ed 100644 --- a/crates/agentkeys-broker-server/tests/mint_flow.rs +++ b/crates/agentkeys-broker-server/tests/mint_flow.rs @@ -51,11 +51,18 @@ async fn spawn_broker_with_sts( audit_db_path: PathBuf::from(":memory:"), aws_region: "us-east-1".into(), session_duration_seconds: 3600, + backend_request_timeout_seconds: 5, + shutdown_grace_seconds: 5, }; + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .connect_timeout(std::time::Duration::from_millis(500)) + .build() + .unwrap(); let state = Arc::new(AppState { config, - http: reqwest::Client::new(), + http, audit: AuditLog::open_in_memory().unwrap(), sts, }); @@ -197,7 +204,9 @@ async fn mint_aws_creds_handles_backend_unreachable() { assert_eq!(body["error"], "backend_unreachable"); let row = broker_state.audit.last_row().unwrap().expect("audit row missing"); - assert_eq!(row.outcome, "auth_failed"); + // Backend down should show as backend_error in the audit log, NOT + // auth_failed — operators chasing an outage need the distinction. + assert_eq!(row.outcome, "backend_error"); assert!(row.outcome_detail.is_some()); } From f0960f60c6a36261ab23deab5763148f29968e57 Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Mon, 27 Apr 2026 11:33:04 +0800 Subject: [PATCH 4/6] docs(stage7): split wip into phase 1 (shipped) + phase 2 (deferred) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stage7-wip.md previously described Stage 7 as one undifferentiated "not running yet" surface. With PR #60 phase 1 (broker server) shipped, the doc was misleading: readers couldn't tell what's live, what isn't, or where the operator runbook had moved to. Restructured around the two halves: - Phase 1 (shipped) — points at crates/agentkeys-broker-server/, the three-role dev-setup.md, and the operator-runbook. Includes the three-terminal e2e proof (mock backend + broker + curl mint). - Phase 2 (deferred) — preserves the existing OIDC federation test recipe (IAM provider registration, federated trust policy, PrincipalTag bucket policy, JWT mint via TS stub, cross-prefix AccessDenied proof). Reframed as "still blocked on public hosting + TEE-derived ES256 key per heima-gaps §3." Refs #58. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/stage7-wip.md | 97 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/docs/stage7-wip.md b/docs/stage7-wip.md index fc2acb6..b2ad0f6 100644 --- a/docs/stage7-wip.md +++ b/docs/stage7-wip.md @@ -1,28 +1,87 @@ # Stage 7 — WIP notes -> **WIP / scratchpad.** Preserves the Stage 7 OIDC-federation test for future work. Revise as prereqs land. Not a finished guide. +> **WIP / scratchpad.** Phase 1 (broker server) ships in PR [#60](https://github.com/litentry/agentKeys/pull/60); the OIDC-federation half (phase 2) is preserved below for when its prereqs land. Not a finished guide. ## What Stage 7 is -Expose our TEE (or interim ES256 signer) as a conforming OIDC Identity Provider at a stable public URL. Any cloud that trusts the issuer can exchange our JWTs for scoped temp creds via standard federation. Per [`docs/spec/plans/development-stages.md`](./spec/plans/development-stages.md), this is the "Generalized OIDC Provider" stage after Stage 6 (Federated Own Email). +Two halves that compose into the canonical "broker, not proxy" architecture: + +1. **Phase 1 — Broker server (shipped).** A long-running HTTP service holds the operator's long-lived `agentkeys-daemon` AWS access key and brokers 1-hour scoped credentials to authenticated daemons. Lets app developers run daemons against operator infrastructure without ever touching AWS keys themselves. +2. **Phase 2 — OIDC federation (deferred).** Expose the broker's TEE (or interim ES256 signer) as a conforming OIDC Identity Provider at a stable public URL. Any cloud that trusts the issuer can exchange our JWTs for scoped temp creds via standard federation. Replaces the static-IAM `sts:assume-role` path with `sts:assume-role-with-web-identity` + `sts:TagSession` for cloud-enforced per-user isolation. + +Per [`docs/spec/plans/development-stages.md`](./spec/plans/development-stages.md), this is the "Generalized OIDC Provider" stage after Stage 6 (Federated Own Email). > **Scope boundary (added 2026-04-26).** Stage 7 ships the per-user isolation primitive — JWT claim → PrincipalTag → resource-policy gate. **It does not commit a position on where credential ciphertext lives.** The previously-assumed `pallet-secrets-vault` (on-chain encrypted blob store) is superseded by [`stage8-wip.md`](./stage8-wip.md), which moves ciphertext off-chain into the same PrincipalTag-gated S3 prefixes. See [`docs/spec/threat-model-key-custody.md`](./spec/threat-model-key-custody.md) for the architectural rationale. -## Why it's not running yet +## Phase 1 — Broker server (shipped, PR #60) + +The credential broker that lets app developers run daemons without holding any AWS keys. Static-IAM trust path; OIDC federation deferred to phase 2. + +**Code:** + +- [`crates/agentkeys-broker-server/`](../crates/agentkeys-broker-server/) — axum HTTP service. + - `POST /v1/mint-aws-creds` — bearer-token in (validated via the backend's `/session/validate`), 1-hour scoped AWS creds out (`sts:assume-role` on the operator's daemon key). + - `GET /healthz`, `GET /readyz` — operator supervisor probes; `readyz` checks backend reachability + `sts:GetCallerIdentity`. + - SQLite audit log on every mint (sha256-hashed bearer tokens, wallet, outcome, sts session name) at `$HOME/.agentkeys/broker/audit.sqlite` by default. + - Trait-abstracted `StsClient` with `AwsStsClient` (production) and `StubStsClient` (gated by `test-stub` feature) — testable without live AWS. +- [`crates/agentkeys-mock-server/`](../crates/agentkeys-mock-server/) gains `GET /session/validate` so the broker validates bearer tokens through the existing session backend rather than duplicating session state. +- [`crates/agentkeys-daemon/`](../crates/agentkeys-daemon/) gains `--broker-url` / `AGENTKEYS_BROKER_URL` flag (consumer wiring of temp creds into provisioner-scripts lands in phase 2). + +**Operator setup + test:** see [`docs/operator-runbook.md`](./operator-runbook.md) for start / supervise / rotate / audit, and [`docs/dev-setup.md` §5](./dev-setup.md) for the three-terminal solo-dev loop. + +**End-to-end proof for phase 1** (run from inside the workspace): + +```bash +# Terminal A — mock backend +cargo run --release -p agentkeys-mock-server -- --port 8090 + +# Terminal B — broker (with stage6-demo-env.sh sourced or BROKER_DAEMON_* in env) +export BROKER_DAEMON_ACCESS_KEY_ID=$(op read 'op://AgentKeys/daemon/access-key-id') +export BROKER_DAEMON_SECRET_ACCESS_KEY=$(op read 'op://AgentKeys/daemon/secret-access-key') +export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +export BROKER_BACKEND_URL=http://127.0.0.1:8090 +cargo run --release -p agentkeys-broker-server -- --port 8091 + +# Terminal C — proof: mint a session, then mint AWS creds via the broker +SESSION=$(curl -sf -X POST http://127.0.0.1:8090/session/create \ + -H 'content-type: application/json' \ + -d '{"auth_token":"phase1-demo"}' | jq -r .session) + +CREDS=$(curl -sf -X POST http://127.0.0.1:8091/v1/mint-aws-creds \ + -H "Authorization: Bearer $SESSION") +echo "$CREDS" | jq '{access_key_id, expiration, wallet}' +# → real 1h temp creds, scoped to the assumed agentkeys-agent role +``` + +Acceptance: `curl /healthz` → 200, `curl /readyz` → 200, `mint-aws-creds` returns creds, audit row appears in `~/.agentkeys/broker/audit.sqlite`. + +**Out of phase 1 (deferred to phase 2):** + +- OIDC discovery / JWKS / `assume-role-with-web-identity` (the section below). +- TS [`services/oidc-stub/`](../services/oidc-stub/) retirement (still ships its `/internal/sign` endpoint independently for the phase 2 test recipe below). +- Provisioner-scripts AWS-cred consumer rewiring — daemon flag is in place; the scraper-side fetch happens with phase 2. +- Public hosting of the broker / KMS-sealed config source. + +## Phase 2 — OIDC federation (still blocked) + +This is the half that turns the broker into a generalized OIDC Identity Provider so any AWS account (or GCP / Ali Cloud) can trust our JWTs without operator-side IAM-user keys. + +### Why phase 2 is not running yet - Needs `oidc.agentkeys.dev` (or equivalent) hosted publicly with a public-CA TLS cert so AWS IAM accepts `create-open-id-connect-provider`. - The "right" signer is a TEE-derived ES256 key at path `oidc/issuer/v1`, blocked on [`heima-gaps §3`](./spec/heima-gaps-vs-desired-architecture.md). -- [`services/oidc-stub/`](../services/oidc-stub/) ships an interim local-file ES256 signer; swap for TEE when §3 closes. +- [`services/oidc-stub/`](../services/oidc-stub/) ships an interim local-file ES256 signer; swap for TEE when §3 closes, or absorb the issuer endpoints into the Rust broker once public hosting is decided. -## Test script — preserved for when both prereqs are in place +### Phase 2 test script — preserved for when both prereqs are in place -### Prereqs +#### Prereqs - Stage 6 AWS setup complete per [`docs/stage6-aws-setup.md`](./stage6-aws-setup.md). +- Phase 1 broker running locally (so the static-IAM `mint-aws-creds` path keeps working as a fallback during the migration). - `services/oidc-stub/` hosted publicly. Options: CloudFront+S3 + Lambda for `/internal/sign`; ECS Fargate with ALB; or ngrok for dev (`ngrok http 34568`). - `export OIDC_ISSUER=https://`; verify `curl -sf "$OIDC_ISSUER/.well-known/openid-configuration" | jq .issuer`. -### 1. Register the OIDC provider in IAM +#### 1. Register the OIDC provider in IAM ```bash aws iam create-open-id-connect-provider \ @@ -32,7 +91,7 @@ aws iam create-open-id-connect-provider \ export OIDC_PROVIDER_ARN="arn:aws:iam::${ACCOUNT_ID}:oidc-provider/$(echo $OIDC_ISSUER | sed 's|https://||')" ``` -### 2. Replace the role's trust policy with the federated variant +#### 2. Replace the role's trust policy with the federated variant Replaces [`stage6-aws-setup.md` §3b](./stage6-aws-setup.md) (static IAM user). Principal becomes the OIDC provider; the `sts:TagSession` + `aws:RequestTag/agentkeys_user_wallet` condition is what wires cloud-enforced per-user isolation in §3 below. @@ -58,7 +117,7 @@ aws iam update-assume-role-policy \ }')" ``` -### 3. Upgrade bucket policy to PrincipalTag-scoped +#### 3. Upgrade bucket policy to PrincipalTag-scoped Replaces the `AllowDaemonRead` statement in [`stage6-aws-setup.md` §4](./stage6-aws-setup.md). Cloud now enforces "the assumed session can only touch the prefix matching its PrincipalTag": @@ -78,9 +137,9 @@ Replaces the `AllowDaemonRead` statement in [`stage6-aws-setup.md` §4](./stage6 } ``` -### 4. End-to-end proof +#### 4. End-to-end proof -The one test that proves Stage 7 works: a JWT claiming wallet A can only touch wallet A's prefix — never B's. +The one test that proves phase 2 works: a JWT claiming wallet A can only touch wallet A's prefix — never B's. ```bash # Mint a JWT via the stub @@ -112,15 +171,17 @@ aws s3api list-objects-v2 --bucket "$BUCKET" --prefix "$WALLET/" aws s3api list-objects-v2 --bucket "$BUCKET" --prefix "0xdeadbeef/" ``` -Test (b) is what Stage 6's static-IAM path can't prove. Cloud-enforced, zero app-side trust. +Test (b) is what Stage 6's static-IAM path can't prove. Cloud-enforced, zero app-side trust. The phase 1 broker's `assume-role` path **does** issue scoped creds, but isolation enforcement still relies on the operator's IAM trust policy alone — phase 2 moves enforcement into AWS itself. -### 5. Swap the stub for a TEE-derived signer +#### 5. Swap the stub for a TEE-derived signer -When [`heima-gaps §3`](./spec/heima-gaps-vs-desired-architecture.md) closes, replace [`services/oidc-stub/src/keys.ts`](../services/oidc-stub/src/keys.ts)'s local-file key loader with a call to the TEE's `derive("oidc/issuer/v1")`. JWKS, JWT shape, STS exchange, and bucket-policy enforcement all stay identical. ~50 lines in `keys.ts`. +When [`heima-gaps §3`](./spec/heima-gaps-vs-desired-architecture.md) closes, replace [`services/oidc-stub/src/keys.ts`](../services/oidc-stub/src/keys.ts)'s local-file key loader with a call to the TEE's `derive("oidc/issuer/v1")`. JWKS, JWT shape, STS exchange, and bucket-policy enforcement all stay identical. ~50 lines in `keys.ts`. Or, if the issuer endpoints have already been absorbed into the Rust broker by then, the swap happens inside `crates/agentkeys-broker-server/`. ## TODO pickups -- Host `services/oidc-stub/` publicly (CloudFront+S3 for static discovery + Lambda for sign) -- Promote to `docs/manual-test-stage7.md` once the test passes live -- Add the equivalent GCP Workload Identity Federation + Ali Cloud RAM recipes (Stage 7 target is generalized, not AWS-only) -- Hand off the credential-vault question to Stage 8 — the bucket prefix `s3://agentkeys-vault//` is the reuse point; ciphertext + per-epoch DEK rotation live in [`stage8-wip.md`](./stage8-wip.md), not here. +- **Phase 2 issuer absorption:** port discovery + JWKS + JWT signing from [`services/oidc-stub/`](../services/oidc-stub/) into the Rust broker as `POST /v1/mint-oidc-jwt` and the `/.well-known/*` surface. Retire the TS stub. +- **Public hosting:** CloudFront+S3 for static discovery + Lambda for sign, or terminate TLS at a reverse proxy in front of the Rust broker. +- **Provisioner-scripts integration:** wire the daemon's `--broker-url` flag into the scraper subprocesses' AWS-cred fetch (replaces the `stage6-demo-env.sh` sourcing pattern in `scripts/`). +- **Promote phase 1 doc:** once the live three-terminal demo passes for a non-operator developer (with no AWS env vars on their machine), promote `docs/operator-runbook.md` from WIP to canonical. +- **Add the equivalent GCP Workload Identity Federation + Ali Cloud RAM recipes** (Stage 7 target is generalized, not AWS-only). +- **Hand off the credential-vault question to Stage 8** — the bucket prefix `s3://agentkeys-vault//` is the reuse point; ciphertext + per-epoch DEK rotation live in [`stage8-wip.md`](./stage8-wip.md), not here. From 4e974ddedafb4d3649f11947b44f7f127f22bcac Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Mon, 27 Apr 2026 12:29:07 +0800 Subject: [PATCH 5/6] docs(stage7): replace 1Password CLI guidance with ~/.zshenv pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The team persists BROKER_DAEMON_* in ~/.zshenv (mode 0600), not in a 1Password vault accessed via `op read`. Update the three Stage 7 docs to match actual operator workflow: - docs/operator-runbook.md §3.1 now describes ~/.zshenv (or supervisor env-injection) instead of recommending 1Password CLI. Adds the "shared/untrusted host" caveat for systemd LoadCredential / launchd EnvironmentVariables fallback. - docs/operator-runbook.md §5 (rotation): updates step 2 from "update your secret store (1Password)" to "update ~/.zshenv". - docs/operator-runbook.md §9 (out-of-scope): retitles "1Password CLI integration" to "secret-manager integration" generally. - docs/dev-setup.md §1 (optional tools): removes 1Password CLI bullet. - docs/dev-setup.md §3 (role table): "1Password" → "~/.zshenv or supervisor-managed env" in the operator row. - docs/dev-setup.md §5.1: replaces "stash in 1Password" with the ~/.zshenv persistence pattern. - docs/dev-setup.md §5.2 + §5.4: removes inline `op read` calls from the broker-startup snippets; comments now state BROKER_DAEMON_* are inherited from the shell. - docs/stage7-wip.md phase-1 e2e proof: same op-read removal. No code changes. The broker still reads BROKER_DAEMON_* from std::env exactly as before; only the operator-facing instructions changed. Refs #58. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/dev-setup.md | 17 ++++++----------- docs/operator-runbook.md | 16 ++++++++++------ docs/stage7-wip.md | 7 ++++--- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/dev-setup.md b/docs/dev-setup.md index 9a6e21a..ca6eb3c 100644 --- a/docs/dev-setup.md +++ b/docs/dev-setup.md @@ -45,7 +45,6 @@ Two things the script intentionally does **not** do: Optional but recommended: -- **1Password CLI** — operators use this for pulling the `agentkeys-daemon` AWS creds without leaking them to shell history. - **chrome-devtools-mcp** — auto-wired via `.mcp.json` when you open this repo in Claude Code / Cursor / Zed / Continue.dev. Gives the workflow-collection skill tool-level access to a live Chrome for diagnosing provider-side changes. ## 2. Build everything (everyone) @@ -75,7 +74,7 @@ AgentKeys has three roles. Each runs a different set of processes and holds a di | Role | What you run | What you hold | Read | |---|---|---|---| | **App developer** — building an agent against AgentKeys | `agentkeys-daemon` + an agent process | A short-lived bearer token from the operator. **Zero AWS credentials.** | §4 | -| **App owner / operator** — running the broker for a team | `agentkeys-broker-server` (+ optionally the mock backend in dev) | Long-lived `agentkeys-daemon` AWS access key (1Password). The broker's own master session. | §5 | +| **App owner / operator** — running the broker for a team | `agentkeys-broker-server` (+ optionally the mock backend in dev) | Long-lived `agentkeys-daemon` AWS access key (persisted in `~/.zshenv` or supervisor-managed env). The broker's own master session. | §5 | | **End user** — using a credential-brokered agent | `agentkeys` CLI | A 30-day master session token in OS keychain. | §6 | **Solo dev?** You'll wear all three hats. Read §5 first to stand up your own broker, then §4 to point a daemon at it, then §6 for the user-facing CLI. @@ -133,7 +132,7 @@ Run through [`stage6-aws-setup.md`](./stage6-aws-setup.md) through §7 once per - S3 bucket `agentkeys-mail-` with receipt rule writing inbound to `inbound/` - Route 53 records: three DKIM CNAMEs, MX, SPF, DMARC -Stash the daemon user's long-lived creds in 1Password (or your OS keychain). **Do not export them globally into your shell anymore** — they only live inside the broker process now (§5.2). +Persist the daemon user's long-lived creds in `~/.zshenv` (mode 0600) so every shell on this host inherits them. The broker process picks them up at startup; nothing else on the host should be reading from these env vars. ### 5.2 Run the broker server @@ -142,11 +141,9 @@ The broker holds your AWS daemon credentials and brokers scoped temp credentials **Local development shape:** ```bash -# Load the daemon AWS key from 1Password (or your secret store) into this shell only. -export BROKER_DAEMON_ACCESS_KEY_ID=$(op read 'op://AgentKeys/daemon/access-key-id') -export BROKER_DAEMON_SECRET_ACCESS_KEY=$(op read 'op://AgentKeys/daemon/secret-access-key') - -# Configure the broker. +# BROKER_DAEMON_ACCESS_KEY_ID and BROKER_DAEMON_SECRET_ACCESS_KEY are +# already in your shell because they're persisted in ~/.zshenv. +# Per-run config: export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" export BROKER_BACKEND_URL="http://127.0.0.1:8090" # mock backend for v0.1 dev loop export BROKER_AUDIT_DB_PATH="$HOME/.agentkeys/broker/audit.sqlite" @@ -177,9 +174,7 @@ If you're running everything on one box (typical solo dev), you'll want three te # Terminal A — mock backend cargo run --release -p agentkeys-mock-server -- --port 8090 -# Terminal B — broker -export BROKER_DAEMON_ACCESS_KEY_ID=... -export BROKER_DAEMON_SECRET_ACCESS_KEY=... +# Terminal B — broker. BROKER_DAEMON_* already in env via ~/.zshenv. export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" export BROKER_BACKEND_URL=http://127.0.0.1:8090 cargo run --release -p agentkeys-broker-server -- --port 8091 diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index e0e3bb3..2850e7b 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -46,14 +46,18 @@ The broker reads its configuration from environment variables only — no config | `BROKER_AWS_REGION` | no | AWS region for the STS call. Default: `us-east-1` | | `BROKER_SESSION_DURATION_SECONDS` | no | TTL for minted credentials. Default: `3600` (1 h). Min: `900`, max: `43200` | -Pull `BROKER_DAEMON_*` from a real secret store. Recommended: 1Password CLI: +Persist `BROKER_DAEMON_ACCESS_KEY_ID` and `BROKER_DAEMON_SECRET_ACCESS_KEY` in `~/.zshenv` (or the equivalent per-shell startup file for non-zsh shells) with file mode 0600 so the operator's shell has them on every login: ```bash -export BROKER_DAEMON_ACCESS_KEY_ID=$(op read 'op://AgentKeys/daemon/access-key-id') -export BROKER_DAEMON_SECRET_ACCESS_KEY=$(op read 'op://AgentKeys/daemon/secret-access-key') +chmod 600 ~/.zshenv +# inside ~/.zshenv: +export BROKER_DAEMON_ACCESS_KEY_ID=AKIA... +export BROKER_DAEMON_SECRET_ACCESS_KEY=... ``` -Do **not** put these in your shell rc files. Load them once per session and let them expire from memory when the shell exits. +`~/.zshenv` is sourced by every zsh invocation (login, interactive, script), so the broker process inherits the keys regardless of how it was started. The 0600 mode keeps the file readable only by the operator. + +If the host is shared or untrusted, prefer a secret manager that injects the values into the launch environment (systemd `LoadCredential=`, launchd `EnvironmentVariables` plist, or whatever your supervisor supports) rather than a per-user dotfile. ### 3.2 Run @@ -92,7 +96,7 @@ Logs go to stderr in `tracing-subscriber` JSON format when `RUST_LOG=info` is se Long-lived keys age out. Rotation procedure: 1. In IAM, **create** a second access key on the `agentkeys-daemon` user — both old and new keys are now valid. -2. Update your secret store (1Password) with the new key. +2. Update `~/.zshenv` (or your supervisor's environment-injection mechanism) with the new key. 3. Restart the broker — it picks up the new `BROKER_DAEMON_*` from env. 4. Verify with `curl /readyz` — should return 200. 5. In IAM, **deactivate** (not delete) the old access key. Wait 24 h. @@ -165,7 +169,7 @@ Operator-side, the same binary runs. Configuration source changes from env vars - TEE / enclave-backed broker. Plaintext on commodity hardware. - KMS-sealed configuration source. Env vars only. -- 1Password CLI integration as a config source. Operator runs `op read` themselves before starting the broker. +- Secret-manager integration as a config source (Vault, AWS Secrets Manager, GCP Secret Manager). Operator persists the daemon AWS keys in `~/.zshenv` (or supervisor-managed env) themselves. - Multi-tenant operator support. One broker process serves one operator's `agentkeys-daemon` key. - OIDC `assume-role-with-web-identity` exchange. Direct `assume-role` with the static IAM trust path. The OIDC half lands when public hosting is also in motion (Stage 7 phase 2). - Automatic key rotation. Rotate manually per §5. diff --git a/docs/stage7-wip.md b/docs/stage7-wip.md index b2ad0f6..5148306 100644 --- a/docs/stage7-wip.md +++ b/docs/stage7-wip.md @@ -35,9 +35,10 @@ The credential broker that lets app developers run daemons without holding any A # Terminal A — mock backend cargo run --release -p agentkeys-mock-server -- --port 8090 -# Terminal B — broker (with stage6-demo-env.sh sourced or BROKER_DAEMON_* in env) -export BROKER_DAEMON_ACCESS_KEY_ID=$(op read 'op://AgentKeys/daemon/access-key-id') -export BROKER_DAEMON_SECRET_ACCESS_KEY=$(op read 'op://AgentKeys/daemon/secret-access-key') +# Terminal B — broker. Operator has BROKER_DAEMON_ACCESS_KEY_ID + +# BROKER_DAEMON_SECRET_ACCESS_KEY already in their shell environment +# (e.g., persisted in ~/.zshenv with mode 0600 — zsh sources it for every +# shell). The remaining vars are per-run. export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" export BROKER_BACKEND_URL=http://127.0.0.1:8090 cargo run --release -p agentkeys-broker-server -- --port 8091 From ef892b8f7da95c456c68c07ba0368900bad1761c Mon Sep 17 00:00:00 2001 From: wildmeta-agent Date: Mon, 27 Apr 2026 12:31:25 +0800 Subject: [PATCH 6/6] feat(stage7): align broker env vars with existing ~/.zshenv convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator's ~/.zshenv already defines: DAEMON_ACCESS_KEY_ID DAEMON_SECRET_ACCESS_KEY ACCOUNT_ID REGION BUCKET DOMAIN scripts/stage6-demo-env.sh has read DAEMON_ACCESS_KEY_ID + DAEMON_SECRET_ACCESS_KEY since Stage 6. Introducing a second naming scheme (BROKER_DAEMON_*) for the same long-lived keys forces operators to either duplicate exports or rewrite ~/.zshenv. Align instead. Code (config.rs): - BROKER_DAEMON_ACCESS_KEY_ID env var renamed to DAEMON_ACCESS_KEY_ID, with BROKER_DAEMON_ACCESS_KEY_ID kept as a fallback for explicit callers. Same for DAEMON_SECRET_ACCESS_KEY. - BROKER_AGENT_ROLE_ARN now optional: if unset, derived from ACCOUNT_ID as arn:aws:iam::$ACCOUNT_ID:role/agentkeys-agent (the Stage 6 canonical role name). Operator can still override. - BROKER_AWS_REGION now falls back to REGION (the rest-of-agentKeys convention) before defaulting to us-east-1. - New first_env() helper picks the first non-empty match from a list of candidate env-var names. Docs: - docs/operator-runbook.md §3.1: env-var schema table updated; ~/.zshenv example shows REGION + ACCOUNT_ID + DAEMON_* (matches actual zshenv layout). Two new vars from prior commit (BROKER_BACKEND_TIMEOUT_SECONDS, BROKER_SHUTDOWN_GRACE_SECONDS) added to the table. - docs/operator-runbook.md §5: rotation step references DAEMON_*. - docs/dev-setup.md §5.2 + §5.4: the explicit `export BROKER_AGENT_ROLE_ARN=...` line drops out — broker derives from ACCOUNT_ID. Now the only per-run var is BROKER_BACKEND_URL. - docs/stage7-wip.md phase-1 e2e: same simplification. Tests: 17 / 17 broker tests passing (BrokerConfig is constructed literally in tests, so the env-var rename doesn't affect them). Refs #58. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/agentkeys-broker-server/src/config.rs | 49 ++++++++++++++++++-- docs/dev-setup.md | 13 +++--- docs/operator-runbook.md | 30 +++++++----- docs/stage7-wip.md | 9 ++-- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/crates/agentkeys-broker-server/src/config.rs b/crates/agentkeys-broker-server/src/config.rs index bf21e7d..ebab6f6 100644 --- a/crates/agentkeys-broker-server/src/config.rs +++ b/crates/agentkeys-broker-server/src/config.rs @@ -19,16 +19,43 @@ pub struct BrokerConfig { impl BrokerConfig { pub fn from_env() -> anyhow::Result { - let daemon_access_key_id = required_env("BROKER_DAEMON_ACCESS_KEY_ID")?; - let daemon_secret_access_key = required_env("BROKER_DAEMON_SECRET_ACCESS_KEY")?; - let agent_role_arn = required_env("BROKER_AGENT_ROLE_ARN")?; + // DAEMON_ACCESS_KEY_ID / DAEMON_SECRET_ACCESS_KEY are the same vars + // scripts/stage6-demo-env.sh reads — operator persists them once in + // ~/.zshenv and both the legacy demo script and the broker pick them + // up. BROKER_DAEMON_* names are accepted as a fallback for callers + // that prefer the explicit prefix. + let daemon_access_key_id = first_env(&[ + "DAEMON_ACCESS_KEY_ID", + "BROKER_DAEMON_ACCESS_KEY_ID", + ]) + .ok_or_else(|| { + anyhow::anyhow!("missing required env var: DAEMON_ACCESS_KEY_ID (or BROKER_DAEMON_ACCESS_KEY_ID)") + })?; + let daemon_secret_access_key = first_env(&[ + "DAEMON_SECRET_ACCESS_KEY", + "BROKER_DAEMON_SECRET_ACCESS_KEY", + ]) + .ok_or_else(|| { + anyhow::anyhow!("missing required env var: DAEMON_SECRET_ACCESS_KEY (or BROKER_DAEMON_SECRET_ACCESS_KEY)") + })?; + // BROKER_AGENT_ROLE_ARN can be derived from ACCOUNT_ID for the + // canonical Stage 6 role name. Operator can still override. + let agent_role_arn = std::env::var("BROKER_AGENT_ROLE_ARN").or_else(|_| { + std::env::var("ACCOUNT_ID") + .map(|account_id| format!("arn:aws:iam::{}:role/agentkeys-agent", account_id)) + }) + .map_err(|_| anyhow::anyhow!( + "missing required env var: set BROKER_AGENT_ROLE_ARN explicitly, or set ACCOUNT_ID and the broker will derive arn:aws:iam::$ACCOUNT_ID:role/agentkeys-agent" + ))?; let backend_url = required_env("BROKER_BACKEND_URL")?; let audit_db_path = std::env::var("BROKER_AUDIT_DB_PATH") .ok() .map(PathBuf::from) .unwrap_or_else(default_audit_db_path); - let aws_region = std::env::var("BROKER_AWS_REGION") - .unwrap_or_else(|_| "us-east-1".to_string()); + // BROKER_AWS_REGION wins; falls back to REGION (which the rest of + // the agentKeys runbook uses) before defaulting to us-east-1. + let aws_region = first_env(&["BROKER_AWS_REGION", "REGION"]) + .unwrap_or_else(|| "us-east-1".to_string()); let session_duration_seconds = match std::env::var("BROKER_SESSION_DURATION_SECONDS") { Ok(s) => s.parse::().map_err(|e| { anyhow::anyhow!( @@ -87,6 +114,18 @@ fn required_env(name: &str) -> anyhow::Result { std::env::var(name).map_err(|_| anyhow::anyhow!("missing required env var: {}", name)) } +/// Return the value of the first env var in `names` that is set and non-empty. +fn first_env(names: &[&str]) -> Option { + for name in names { + if let Ok(v) = std::env::var(name) { + if !v.is_empty() { + return Some(v); + } + } + } + None +} + fn default_audit_db_path() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); PathBuf::from(home).join(".agentkeys").join("broker").join("audit.sqlite") diff --git a/docs/dev-setup.md b/docs/dev-setup.md index ca6eb3c..4945e60 100644 --- a/docs/dev-setup.md +++ b/docs/dev-setup.md @@ -141,12 +141,12 @@ The broker holds your AWS daemon credentials and brokers scoped temp credentials **Local development shape:** ```bash -# BROKER_DAEMON_ACCESS_KEY_ID and BROKER_DAEMON_SECRET_ACCESS_KEY are -# already in your shell because they're persisted in ~/.zshenv. -# Per-run config: -export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +# DAEMON_ACCESS_KEY_ID, DAEMON_SECRET_ACCESS_KEY, ACCOUNT_ID, and REGION +# are already in your shell because they're persisted in ~/.zshenv (mode +# 0600). The broker derives BROKER_AGENT_ROLE_ARN from ACCOUNT_ID +# automatically and falls back BROKER_AWS_REGION → REGION. +# The only per-run var the broker requires is BROKER_BACKEND_URL: export BROKER_BACKEND_URL="http://127.0.0.1:8090" # mock backend for v0.1 dev loop -export BROKER_AUDIT_DB_PATH="$HOME/.agentkeys/broker/audit.sqlite" # Run. cargo run --release -p agentkeys-broker-server -- --port 8091 @@ -174,8 +174,7 @@ If you're running everything on one box (typical solo dev), you'll want three te # Terminal A — mock backend cargo run --release -p agentkeys-mock-server -- --port 8090 -# Terminal B — broker. BROKER_DAEMON_* already in env via ~/.zshenv. -export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +# Terminal B — broker. DAEMON_* + ACCOUNT_ID already in env via ~/.zshenv. export BROKER_BACKEND_URL=http://127.0.0.1:8090 cargo run --release -p agentkeys-broker-server -- --port 8091 diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 2850e7b..e4444a6 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -38,25 +38,31 @@ The broker reads its configuration from environment variables only — no config | Variable | Required | Description | |---|---|---| -| `BROKER_DAEMON_ACCESS_KEY_ID` | yes | Long-lived `agentkeys-daemon` IAM user access key | -| `BROKER_DAEMON_SECRET_ACCESS_KEY` | yes | Long-lived `agentkeys-daemon` IAM user secret | -| `BROKER_AGENT_ROLE_ARN` | yes | ARN of the `agentkeys-agent` role to assume on behalf of daemons | -| `BROKER_BACKEND_URL` | yes | URL of the AgentKeys backend that issues session tokens (mock-server in dev, chain in v0.2+) | -| `BROKER_AUDIT_DB_PATH` | no | SQLite path for the audit log. Default: `$HOME/.agentkeys/broker/audit.sqlite` | -| `BROKER_AWS_REGION` | no | AWS region for the STS call. Default: `us-east-1` | -| `BROKER_SESSION_DURATION_SECONDS` | no | TTL for minted credentials. Default: `3600` (1 h). Min: `900`, max: `43200` | - -Persist `BROKER_DAEMON_ACCESS_KEY_ID` and `BROKER_DAEMON_SECRET_ACCESS_KEY` in `~/.zshenv` (or the equivalent per-shell startup file for non-zsh shells) with file mode 0600 so the operator's shell has them on every login: +| `DAEMON_ACCESS_KEY_ID` | yes | Long-lived `agentkeys-daemon` IAM user access key. Same var `scripts/stage6-demo-env.sh` reads. (Fallback: `BROKER_DAEMON_ACCESS_KEY_ID`.) | +| `DAEMON_SECRET_ACCESS_KEY` | yes | Long-lived `agentkeys-daemon` IAM user secret. (Fallback: `BROKER_DAEMON_SECRET_ACCESS_KEY`.) | +| `BROKER_AGENT_ROLE_ARN` | yes (or `ACCOUNT_ID`) | ARN of the `agentkeys-agent` role. If unset, derived from `ACCOUNT_ID` as `arn:aws:iam::$ACCOUNT_ID:role/agentkeys-agent`. | +| `BROKER_BACKEND_URL` | yes | URL of the AgentKeys backend that issues session tokens (mock-server in dev, chain in v0.2+). | +| `BROKER_AUDIT_DB_PATH` | no | SQLite path for the audit log. Default: `$HOME/.agentkeys/broker/audit.sqlite`. | +| `BROKER_AWS_REGION` | no | AWS region for the STS call. Falls back to `REGION` (the rest-of-agentKeys convention) before defaulting to `us-east-1`. | +| `BROKER_SESSION_DURATION_SECONDS` | no | TTL for minted credentials. Default: `3600` (1 h). Min: `900`, max: `43200`. | +| `BROKER_BACKEND_TIMEOUT_SECONDS` | no | HTTP timeout for backend `/session/validate` calls. Default: `10`. | +| `BROKER_SHUTDOWN_GRACE_SECONDS` | no | Hard cap on graceful-shutdown drain. Default: `30`. | + +Persist `DAEMON_ACCESS_KEY_ID` and `DAEMON_SECRET_ACCESS_KEY` in `~/.zshenv` (or the equivalent per-shell startup file for non-zsh shells) with file mode 0600 so the operator's shell has them on every login. The names match `scripts/stage6-demo-env.sh` so one persisted set of keys feeds both the legacy demo flow and the broker: ```bash chmod 600 ~/.zshenv # inside ~/.zshenv: -export BROKER_DAEMON_ACCESS_KEY_ID=AKIA... -export BROKER_DAEMON_SECRET_ACCESS_KEY=... +export REGION=us-east-1 +export ACCOUNT_ID=429071895007 +export DAEMON_ACCESS_KEY_ID=AKIA... +export DAEMON_SECRET_ACCESS_KEY=... ``` `~/.zshenv` is sourced by every zsh invocation (login, interactive, script), so the broker process inherits the keys regardless of how it was started. The 0600 mode keeps the file readable only by the operator. +The broker also accepts `BROKER_DAEMON_ACCESS_KEY_ID` / `BROKER_DAEMON_SECRET_ACCESS_KEY` as fallbacks if you prefer an explicit prefix. The unprefixed `DAEMON_*` names take precedence so the legacy and new flows stay aligned. + If the host is shared or untrusted, prefer a secret manager that injects the values into the launch environment (systemd `LoadCredential=`, launchd `EnvironmentVariables` plist, or whatever your supervisor supports) rather than a per-user dotfile. ### 3.2 Run @@ -97,7 +103,7 @@ Long-lived keys age out. Rotation procedure: 1. In IAM, **create** a second access key on the `agentkeys-daemon` user — both old and new keys are now valid. 2. Update `~/.zshenv` (or your supervisor's environment-injection mechanism) with the new key. -3. Restart the broker — it picks up the new `BROKER_DAEMON_*` from env. +3. Restart the broker — it picks up the new `DAEMON_*` from env. 4. Verify with `curl /readyz` — should return 200. 5. In IAM, **deactivate** (not delete) the old access key. Wait 24 h. 6. If nothing broke, delete the old key. If something broke, reactivate and roll back. diff --git a/docs/stage7-wip.md b/docs/stage7-wip.md index 5148306..afb63cd 100644 --- a/docs/stage7-wip.md +++ b/docs/stage7-wip.md @@ -35,11 +35,10 @@ The credential broker that lets app developers run daemons without holding any A # Terminal A — mock backend cargo run --release -p agentkeys-mock-server -- --port 8090 -# Terminal B — broker. Operator has BROKER_DAEMON_ACCESS_KEY_ID + -# BROKER_DAEMON_SECRET_ACCESS_KEY already in their shell environment -# (e.g., persisted in ~/.zshenv with mode 0600 — zsh sources it for every -# shell). The remaining vars are per-run. -export BROKER_AGENT_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +# Terminal B — broker. Operator has DAEMON_ACCESS_KEY_ID, +# DAEMON_SECRET_ACCESS_KEY, ACCOUNT_ID, and REGION already in their shell +# environment (persisted in ~/.zshenv with mode 0600 — zsh sources it for +# every shell). The broker derives BROKER_AGENT_ROLE_ARN from ACCOUNT_ID. export BROKER_BACKEND_URL=http://127.0.0.1:8090 cargo run --release -p agentkeys-broker-server -- --port 8091