diff --git a/Cargo.lock b/Cargo.lock index 4fc58110..8315bf9f 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" @@ -224,6 +249,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 +282,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" @@ -359,6 +399,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 +467,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -521,6 +586,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 +713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1729,6 +1815,21 @@ 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", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lab" version = "0.11.0" @@ -1788,6 +1889,16 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2066,6 +2177,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" @@ -2134,6 +2251,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" @@ -2282,6 +2410,17 @@ dependencies = [ "miniz_oxide", ] +[[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" @@ -2819,6 +2958,42 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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" @@ -3900,6 +4075,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" diff --git a/Cargo.toml b/Cargo.toml index f3f0e443..271c2083 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,24 @@ 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 where no OS keychain is +# available. `keyring` is the portable keychain abstraction; chacha20poly1305 + +# argon2 protect the fallback file at rest. +# +# The Linux backend is `linux-native` (kernel keyutils), NOT `sync-secret-service`. +# The Secret Service backend links the system `libdbus` at build time through +# pkg-config, so it fails to *compile* on a headless / CI Linux host that lacks +# `libdbus-1-dev` — a harder failure than a runtime missing-daemon error. keyutils +# is pure syscalls: no build-time system dependency, no D-Bus daemon, so it builds +# everywhere and works headless. See docs/AGENT_AUTH.md for the finding. +keyring = { version = "3", default-features = false, features = [ + "apple-native", + "windows-native", + "linux-native", +] } +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..6f3fd753 --- /dev/null +++ b/docs/AGENT_AUTH.md @@ -0,0 +1,181 @@ +# 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:query,adr:review +✔ Created scoped access token "ci-deploy" + Token id: tok_9f3c + Scopes: adr:query adr:review + 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 kernel keyutils keyring), 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 with no desktop keyring? + +The first cut used the `keyring` crate's **Secret Service** backend +(`sync-secret-service`), and the finding was sharper than expected. Secret +Service does not fail gracefully at runtime here; it fails at **build time**. +That backend links the system `libdbus` through `pkg-config`, so a host without +`libdbus-1-dev` cannot compile the CLI at all. On stock Linux CI runners every +job went red on `The system library dbus-1 required by crate libdbus-sys was not +found`, across build, lint, test, and coverage alike. A missing runtime daemon +is recoverable. A binary that never builds is not. + +The fix is to pick a Linux backend with no build-time system dependency. This +CLI now uses `linux-native`, the kernel **keyutils** keyring, reached through raw +syscalls: no `libdbus`, no `pkg-config`, no D-Bus daemon. It compiles on any +Linux, including a bare CI container, and it stores secrets headless without a +desktop session. macOS and Windows keep their native keychains (`apple-native`, +`windows-native`), which never had the problem. + +Runtime degradation is still handled explicitly. In the default `auto` mode a +keychain error routes to the encrypted-file store **when a passphrase is +configured**, and otherwise fails loudly with a message pointing at +`ACTUAL_TOKEN` or `ACTUAL_TOKEN_PASSPHRASE`. The CLI never invents a weaker store +behind your back. Silent degradation would leave a token written somewhere +unprotected, and that is the outcome worth avoiding. + +One property of keyutils is worth knowing. Kernel keyrings are scoped to a +session or the persistent per-user keyring, so a secret there is less durable +across reboots than a Secret Service entry on a desktop. For durable +non-interactive use that does not matter, because the recommended paths avoid the +OS keychain entirely: + +- **CI**: pass `ACTUAL_TOKEN` from the platform's secret store. Nothing is + written to disk. +- **Headless Linux that must persist a token**: set `ACTUAL_TOKEN_PASSPHRASE` to + enable the encrypted-file fallback, which survives reboots at `0600`. +- **Interactive desktop** (macOS, Windows, Linux with keyutils): the OS keychain + is used with no extra configuration. + +### 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/oauth/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..4cdfbfe0 --- /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/oauth/tokens` +//! - `Authorization: Bearer ` +//! - request body: `{ "name": "