From 0d0a5f9c9cf555c0e5ff2b96902df2c1ae328210 Mon Sep 17 00:00:00 2001 From: Ben Wisecup Date: Tue, 30 Jun 2026 19:42:25 -0400 Subject: [PATCH 1/5] feat(auth): add `actual auth create-token` for scoped, non-interactive tokens Add a scoped personal access token (PAT) path so CI jobs and autonomous agents can authenticate without a browser. `actual auth create-token` reuses the existing `actual login` session: it loads the stored credentials, refreshes the access token if needed, calls the authenticated issuance endpoint, and prints the raw `actl_pat_...` once. Storage is the OS keychain via the `keyring` crate, with an XChaCha20-Poly1305 + Argon2id encrypted-file fallback for headless Linux / CI that has no Secret Service daemon. Non-interactive callers resolve a token from `ACTUAL_TOKEN`, then the keychain, then the encrypted file. The raw value is never written to a log or `Debug` output. `actual auth` keeps its existing runner-auth check when invoked with no subcommand, so the change is backward compatible. Proof of concept: the issuance endpoint is developed against the documented contract and repointed with `--api-url` / `ACTUAL_API_URL` once the server side ships. Generated by the operator's software factory. On behalf of: @benw5483 Co-Authored-By: Actual AI Factory Bot Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 723 ++++++++++++++++++++++++++++++- Cargo.toml | 12 + README.md | 5 + docs/AGENT_AUTH.md | 165 +++++++ src/auth/mod.rs | 2 + src/auth/oauth.rs | 5 +- src/auth/pat.rs | 289 ++++++++++++ src/auth/token_store.rs | 681 +++++++++++++++++++++++++++++ src/cli/args.rs | 43 +- src/cli/commands/auth.rs | 21 +- src/cli/commands/create_token.rs | 246 +++++++++++ src/cli/commands/mod.rs | 1 + src/lib.rs | 7 +- tests/lib_test.rs | 24 +- 14 files changed, 2210 insertions(+), 14 deletions(-) create mode 100644 docs/AGENT_AUTH.md create mode 100644 src/auth/pat.rs create mode 100644 src/auth/token_store.rs create mode 100644 src/cli/commands/create_token.rs diff --git a/Cargo.lock b/Cargo.lock index 4fc58110..4b36f652 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,8 +7,10 @@ name = "actual-cli" version = "0.2.0" dependencies = [ "anyhow", + "argon2", "assert_cmd", "base64", + "chacha20poly1305", "chrono", "clap", "console", @@ -20,6 +22,7 @@ dependencies = [ "glob", "ignore", "indicatif", + "keyring", "mockito", "open", "predicates", @@ -66,6 +69,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -166,6 +179,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -197,6 +222,123 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -224,6 +366,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -251,6 +399,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -260,6 +417,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bstr" version = "1.12.1" @@ -329,6 +508,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -359,6 +547,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -403,6 +615,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -494,6 +707,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -521,6 +743,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -628,6 +870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -690,6 +933,35 @@ dependencies = [ "serde", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -856,6 +1128,33 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -892,6 +1191,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1050,6 +1370,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1247,12 +1580,27 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1595,6 +1943,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1729,6 +2078,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lab" version = "0.11.0" @@ -1753,6 +2118,15 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -2019,6 +2393,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2036,6 +2443,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2066,6 +2504,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.5" @@ -2092,6 +2536,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2102,6 +2556,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2134,6 +2594,17 @@ dependencies = [ "regex", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -2263,6 +2734,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2282,6 +2764,31 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2373,6 +2880,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2819,6 +3335,61 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2869,6 +3440,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3474,7 +4056,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -3524,6 +4106,18 @@ dependencies = [ "winnow 0.7.14", ] +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + [[package]] name = "toml_parser" version = "1.1.0+spec-1.1.0" @@ -3853,6 +4447,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3900,6 +4505,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4606,6 +5221,9 @@ name = "winnow" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -4710,6 +5328,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xz2" version = "0.1.7" @@ -4742,6 +5370,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.39" @@ -4911,3 +5595,40 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index f3f0e443..b30728b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,18 @@ glob = "0.3" # Crypto sha2 = "0.10" base64 = "0.22" +# Scoped personal-access-token storage: OS keychain (primary) with an +# AEAD-encrypted-file fallback for headless Linux/CI that has no Secret Service +# daemon. `keyring` is the portable keychain abstraction; chacha20poly1305 + +# argon2 protect the fallback file at rest. +keyring = { version = "3", default-features = false, features = [ + "apple-native", + "windows-native", + "sync-secret-service", + "crypto-rust", +] } +chacha20poly1305 = "0.10" +argon2 = "0.5" # Async utilities futures = "0.3" diff --git a/README.md b/README.md index 181586d6..1190ffd8 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ you've written yourself. actual adr-bot # analyze repo & write AI context files actual status # check output file state (managed markers, staleness) actual auth # verify authentication +actual auth create-token # mint a scoped token for CI / agents (prototype) actual config show # view current configuration actual config set # set a config value actual config path # print config file location @@ -130,6 +131,10 @@ actual models # list known model names grouped by runner actual cache clear # clear local analysis and tailoring caches ``` +For non-interactive (CI / agent) authentication with scoped access tokens, see +[Agent authentication](docs/AGENT_AUTH.md). It also covers the +dedicated-token-per-agent and never-in-prompt rules agents must follow. + ## Configuration Config lives at `~/.actualai/actual/config.yaml` and is created automatically diff --git a/docs/AGENT_AUTH.md b/docs/AGENT_AUTH.md new file mode 100644 index 00000000..3381012c --- /dev/null +++ b/docs/AGENT_AUTH.md @@ -0,0 +1,165 @@ +# Scoped access tokens for non-interactive use + +`actual login` signs you in through the browser. That's fine for a person at a +keyboard. A CI job or an autonomous agent has no browser to click through, +though, and that's the gap `actual auth create-token` closes: it mints a +**scoped personal access token** (PAT) from your existing login session, so +headless callers authenticate without one. + +> **Status: prototype.** This command is a proof of concept. The issuance +> endpoint it calls is being finalized on the server; the CLI is built against +> the documented contract and is repointed with a single flag or environment +> variable once the endpoint ships. See [Endpoint](#endpoint) below. + +## Mint a token + +```console +$ actual login # once, interactively, in a browser +$ actual auth create-token --name ci-deploy --scopes adr.read,advisor.read +✔ Created scoped access token "ci-deploy" + Token id: tok_9f3c + Scopes: adr.read advisor.read + Stored in: OS keychain + +Your token is shown once below. Copy it now: + +actl_pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxx + +Keep this secret safe: + • Use a DEDICATED token per agent so actions are attributable and + individually revocable; never reuse your interactive login session. + • NEVER paste it into a prompt, commit it, or echo it to logs/history. + • For CI / non-interactive use, pass it via the ACTUAL_TOKEN env var. +``` + +The raw `actl_pat_…` value is printed **once**, to stdout, on its own line, so +`TOKEN=$(actual auth create-token …)` captures just the secret. After that it +lives only behind the OS keychain, or the encrypted-file fallback described +below. It never reaches a log. Copy it now, because there's no second chance to +read it back out in the clear. + +`--name` is required and `--scopes` takes a comma- or space-separated list. + +## Two rules for agents + +These two rules aren't optional hardening. They're the difference between a +credential you can reason about and one you can't. + +### 1. A dedicated token per agent + +Mint a **separate** token for each agent instance, named after that agent +(`--name `). Never hand an agent the human's interactive login session. + +One token per agent buys two things. Every action an agent takes is attributable +to that agent's token rather than to a shared identity. And when one agent +misbehaves or its host is compromised, you revoke that single token without +disturbing every other agent and every human session. + +### 2. The token never enters the model's context + +A PAT lives in exactly one of two places. Those are the OS keychain, or the +`ACTUAL_TOKEN` environment variable for a non-interactive run. It must **never** +appear in: + +- an agent's prompt or conversation context, +- the shell history, +- a command-line argument that other processes can read, +- a log line or an error report. + +The failure mode this guards against is specific to agents. An agent reads +untrusted input: a web page, a file, a tool result. A prompt-injection payload +hidden in that input can instruct the agent to exfiltrate any secret currently +in its context. A token that is never in the context cannot be exfiltrated that +way. Keep the secret in the keychain, or in an environment variable the model +does not read, and that attack has nothing to reach. + +## Non-interactive use + +A headless caller resolves its token in this order: + +1. the `ACTUAL_TOKEN` environment variable, which is the CI path and needs no + storage; +2. the OS keychain; +3. the encrypted-file fallback. + +```console +# CI: inject the secret as a masked environment variable, never echoed. +$ export ACTUAL_TOKEN="actl_pat_…" # from your CI secret store +$ actual advisor "why is the build failing?" +``` + +In CI, pass the token through the platform's secret store as `ACTUAL_TOKEN`. Do +not re-mint a token on every run, and do not write it to a file the job logs. + +## Storage + +```mermaid +flowchart TD + A[create-token mints actl_pat_] --> B{ACTUAL_TOKEN_STORE} + B -->|auto default| C[Try OS keychain] + B -->|keyring| C + B -->|file| F[Encrypted file] + C -->|available| D[Stored in keychain] + C -->|unavailable, e.g. headless Linux| E{ACTUAL_TOKEN_PASSPHRASE set?} + E -->|yes| F + E -->|no| G[Error: configure a fallback or use ACTUAL_TOKEN] + F --> H[Argon2id key + XChaCha20-Poly1305 AEAD, 0600 file] +``` + +The primary store is the OS keychain (macOS Keychain, Windows Credential +Manager, or the Linux Secret Service), reached through the portable +[`keyring`](https://crates.io/crates/keyring) crate. + +Where no keychain is available, an **encrypted-file fallback** keeps the token at +rest under the config directory. The file is sealed with XChaCha20-Poly1305, +keyed by Argon2id over a passphrase read from `ACTUAL_TOKEN_PASSPHRASE`, and +written `0600`. No passphrase, no fallback. The CLI refuses rather than write +anything weaker, so a token is never stored in a form softer than the keychain. + +| Environment variable | Purpose | +| --- | --- | +| `ACTUAL_TOKEN` | A ready-to-use token for a non-interactive run; wins over stored credentials. | +| `ACTUAL_TOKEN_STORE` | Backend select: `auto` (default), `keyring`, or `file`. | +| `ACTUAL_TOKEN_PASSPHRASE` | Passphrase that seals the encrypted-file fallback. | + +## Headless-storage finding + +The question this prototype set out to answer: does the keychain library degrade +gracefully on a headless Linux box or CI runner that has no Secret Service +daemon? + +The answer is no, and that is the expected, correct behavior. The `keyring` +crate's Secret Service backend talks to a D-Bus daemon; with no daemon running, +a store or read returns an error rather than silently dropping the secret or +inventing a weaker store. Silent degradation is the more dangerous outcome, +because it leaves a token written somewhere unprotected. An explicit error is +what you want. + +The CLI turns that error into a deliberate fallback. In the default `auto` mode, +a keychain failure routes to the encrypted-file store **when a passphrase is +configured**, and otherwise fails with a message pointing at `ACTUAL_TOKEN` or +`ACTUAL_TOKEN_PASSPHRASE`. The practical guidance that follows: + +- **Interactive desktop** (macOS, Windows, Linux with a keyring daemon): the OS + keychain is used with no extra configuration. +- **CI**: prefer `ACTUAL_TOKEN` from the platform's secret store. No on-disk + storage is involved at all. +- **Headless Linux that must persist a token**: set `ACTUAL_TOKEN_PASSPHRASE` + to enable the encrypted-file fallback. + +### Prototype limitations + +- The endpoint contract is provisional; see below. +- The encrypted-file fallback derives its key from a passphrase. Treat that + passphrase as a secret of the same weight as the token, and supply a + high-entropy value (the Argon2id work factor slows brute force but cannot + rescue a guessable passphrase). + +## Endpoint + +`create-token` calls `POST /api/auth/tokens` with the login session token +as the bearer, and reads back the minted `actl_pat_…`. The base URL is resolved +from `--api-url`, then the `ACTUAL_API_URL` environment variable, then the +api-service default, so a local mock or a future production path needs no code +change. It isn't final yet. Until the server endpoint ships, treat the exact +path and payload as provisional. diff --git a/src/auth/mod.rs b/src/auth/mod.rs index afa2adfd..5a737178 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -11,7 +11,9 @@ pub mod loopback; pub mod oauth; +pub mod pat; pub mod pkce; pub mod store; +pub mod token_store; pub use store::StoredCredentials; diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index 564518a6..34a5bcee 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -141,7 +141,10 @@ pub fn build_authorize_url( /// Build an HTTP client for the auth server. Enforces HTTPS for non-loopback /// URLs so tokens are never sent in clear text (loopback `http://` is allowed /// for the local mock). -fn build_http_client(base_url: &str) -> Result { +/// +/// Shared with [`crate::auth::pat`], whose token-issuance call carries the same +/// "never send a bearer over clear-text http" requirement. +pub(crate) fn build_http_client(base_url: &str) -> Result { let is_localhost = base_url.starts_with("http://localhost") || base_url.starts_with("http://127.0.0.1") || base_url.starts_with("http://[::1]"); diff --git a/src/auth/pat.rs b/src/auth/pat.rs new file mode 100644 index 00000000..43617ce8 --- /dev/null +++ b/src/auth/pat.rs @@ -0,0 +1,289 @@ +//! Scoped personal access token (PAT) issuance against the Actual AI platform. +//! +//! `actual auth create-token` mints a PAT by calling the authenticated +//! issuance endpoint with the user's existing `actual login` session token as +//! the bearer — no second browser dance, the established session *is* the +//! authentication for issuance. The endpoint returns the raw `actl_pat_…` +//! exactly once; the CLI prints it once and otherwise keeps it only behind the +//! keychain / encrypted-file store (see [`crate::auth::token_store`]). +//! +//! ## Endpoint contract (developed against; confirm against the backend) +//! +//! The backend issuance endpoint is still landing, so this module is written +//! against the documented contract and is trivially repointed once the server +//! ships: +//! +//! - `POST /api/auth/tokens` +//! - `Authorization: Bearer ` +//! - request body: `{ "name": "