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..001d858 --- /dev/null +++ b/crates/agentkeys-broker-server/src/audit.rs @@ -0,0 +1,244 @@ +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; +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, PartialEq, Eq)] +pub enum MintOutcome { + Ok, + AuthFailed, + BackendError, + StsError, +} + +impl MintOutcome { + fn as_str(self) -> &'static str { + match self { + MintOutcome::Ok => "ok", + MintOutcome::AuthFailed => "auth_failed", + MintOutcome::BackendError => "backend_error", + MintOutcome::StsError => "sts_error", + } + } +} + +#[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() { + 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 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.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( + "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_hash 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<()> { + // 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_hash, 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.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_hash, 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) + } +} + +pub fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn now_secs() -> u64 { + 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)] +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/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..ebab6f6 --- /dev/null +++ b/crates/agentkeys-broker-server/src/config.rs @@ -0,0 +1,132 @@ +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, + /// 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 { + pub fn from_env() -> anyhow::Result { + // 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); + // 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!( + "BROKER_SESSION_DURATION_SECONDS={:?} could not be parsed as integer: {}", + s, + e + ) + })?, + Err(_) => 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 + ); + } + + 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, + agent_role_arn, + backend_url, + audit_db_path, + aws_region, + session_duration_seconds, + backend_request_timeout_seconds, + shutdown_grace_seconds, + }) + } +} + +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/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..92dc8d0 --- /dev/null +++ b/crates/agentkeys-broker-server/src/handlers/mint.rs @@ -0,0 +1,192 @@ +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, +} + +#[tracing::instrument(skip_all, fields(wallet = tracing::field::Empty, outcome = tracing::field::Empty))] +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) => { + // 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, + token, + "unknown", + "(unauthenticated)", + outcome, + Some(&e.to_string()), + ); + tracing::Span::current().record("outcome", span_label); + return Err(e); + } + }; + + tracing::Span::current().record("wallet", session.wallet.as_str()); + + let session_name = build_session_name(&session.wallet); + + match state + .sts + .assume_role( + &state.config.agent_role_arn, + &session_name, + state.config.session_duration_seconds, + ) + .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, + 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, + )?; + tracing::Span::current().record("outcome", "ok"); + 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) => { + 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 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(40) + .collect(); + let mut name = format!("agentkeys-{}-{}-{:06}", safe_wallet, secs, micros); + 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--")); + } + + #[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/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..2e0f459 --- /dev/null +++ b/crates/agentkeys-broker-server/src/main.rs @@ -0,0 +1,143 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use agentkeys_broker_server::{ + audit::AuditLog, + config::BrokerConfig, + create_router, + state::AppState, + sts::{AwsStsClient, StsClient}, +}; +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, + + /// 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] +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()?; + + 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, + &config.daemon_secret_access_key, + &config.aws_region, + ) + .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 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, + 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); + + // 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(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + // 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::<()>(); + 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/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..012a887 --- /dev/null +++ b/crates/agentkeys-broker-server/src/sts.rs @@ -0,0 +1,145 @@ +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(()) + } +} + +/// 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 { + 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"))] +#[async_trait] +impl StsClient for StubStsClient { + async fn assume_role( + &self, + _role_arn: &str, + _session_name: &str, + _duration_seconds: i32, + ) -> BrokerResult { + (self.assume)() + } + + async fn caller_identity_ok(&self) -> BrokerResult<()> { + (self.identity)() + } +} 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..41ce0ed --- /dev/null +++ b/crates/agentkeys-broker-server/tests/mint_flow.rs @@ -0,0 +1,260 @@ +//! 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 no test ever hits AWS. + +use std::path::PathBuf; +use std::sync::Arc; + +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, 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(); + 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_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(), + 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, + 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, + audit: AuditLog::open_in_memory().unwrap(), + sts, + }); + 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), 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) { + 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_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, broker_state) = 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); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["access_key_id"], "ASIA-stub-AKID"); + 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 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_and_audits_auth_failed() { + let backend_url = spawn_mock_backend().await; + let (broker_url, broker_state) = 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); + 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"); + // 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()); +} + +#[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); +} + +#[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); +} 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..4945e60 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,15 @@ 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. - **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 +65,142 @@ 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 (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 | -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 + +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 + +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 +# 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 + +# 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. 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 -$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 +215,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..e4444a6 --- /dev/null +++ b/docs/operator-runbook.md @@ -0,0 +1,188 @@ +# 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 | +|---|---|---| +| `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 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 + +```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 `~/.zshenv` (or your supervisor's environment-injection mechanism) with the new key. +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. + +**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. +- 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. + +## 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. diff --git a/docs/stage7-wip.md b/docs/stage7-wip.md index fc2acb6..afb63cd 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. 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 + +# 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.