From dd71e09c75ad1e5cf45427b52ed9a7981d8d0bc3 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 19:33:55 +0000 Subject: [PATCH 1/7] docs(plan): atem status / pair verify CLI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Built with SMT --- .../plans/2026-10-15-status-pair-verify.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 designs/plans/2026-10-15-status-pair-verify.md diff --git a/designs/plans/2026-10-15-status-pair-verify.md b/designs/plans/2026-10-15-status-pair-verify.md new file mode 100644 index 0000000..181d6ee --- /dev/null +++ b/designs/plans/2026-10-15-status-pair-verify.md @@ -0,0 +1,57 @@ +# atem status, pair, pair verify, cred (Atem CLI workflow) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Implement the approved CLI workflow from `designs/session-multiplexing.md` ยง1 that needs no new wire contract: `atem status` (+ `atem cred status` alias), `atem pair` as a no-op when paired, `atem pair verify`, and `atem cred unlock` that never starts pairing/verification. + +**Spec:** `designs/session-multiplexing.md` ยง1 and ยง11 rows 2โ€“5 (copy on branch `feat/agent-runtime`; hosted at https://truck.netbird.cloud:58310/session-multiplexing.md). Authoritative wording is quoted in Task 1. + +## Global Constraints + +- Branch `feat/status-pair-verify` from `main`. Commits end with a blank line and `๐Ÿค– Built with SMT `. Never push. +- `atem status` is read-only and works offline: no network, no Astation connection, never starts the key agent (query only a running one), never writes files. Cached remote information is marked stale. User-facing label `Credentials`, never `Keys`. +- `atem pair` on an already paired profile (any paired credential entry or saved Astation session) prints the status plus `Already paired. Verify this device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair` and exits 0 without connecting, changing identity, trust, keys, sessions or files. Applies also when verification is incomplete. +- `atem pair verify` requires an existing pairing (else: `Not paired. Run atem pair first.`, exit 1). Already validly verified with the paired Astation โ†’ `Already verified (safety code โ€ฆ).`, no connection, exit 0. Revoked by that Astation โ†’ no verification; message: `This device was revoked by Astation . Run atem unpair, then atem pair, to set it up again.` exit 1 โ€” verification never moves keys aside or replaces trusted keys. Otherwise run the existing verification ceremony (`src/pair_verify.rs` + the verification half of `run_pair`) on a connection authenticated with the saved session (no pairing code, `allow_pairing = false`); cancellation/interruption keeps the pairing. +- `atem pair` (not yet paired) keeps today's pairing flow, then runs verification as today (first-time setup stays one command); the revoked-keys-moved-aside branch is removed from `atem pair` (now only reachable after `atem unpair`, where the device has no pin for that Astation). +- `atem cred unlock` on an unverified profile: `This device isn't verified. Run atem pair verify first.` exit 1, no connection. `atem cred lock` unchanged (offline, always allowed). Neither starts pairing or verification. +- `atem cred status` prints exactly what `atem status` prints (one implementation). +- Never rustfmt `src/cli.rs`, `src/main.rs`, `src/websocket_client.rs`, `src/memory/mod.rs`, `src/memory/crypto.rs`, `src/memory/secrets.rs`; check others with `--check` first. Never project-wide `cargo fmt`. +- Never run the installed `atem`, never touch the real `~/.config/atem`, the user's key agent or running atem processes; tests use temp config dirs (existing patterns in src/memory tests and `cli.rs` tests). +- Full `cargo test` reading every `test result` line; clippy not above `main`'s count (measure first). + +### Task 1: status, pair no-op, pair verify, cred gating + +Files: `src/cli.rs` (commands, `run_pair`, new `run_status`, `PairCommands::Verify`), new `src/status.rs` (state gathering + rendering, pure and testable), `src/memory/unlock.rs` (cred status โ†’ status; unlock gate), `src/pair_verify.rs` (verification entry usable without pairing), tests; AGENTS.md / README.md / npm/README.md command lists. + +`src/status.rs` interface: + +```rust +pub struct StatusReport { + pub account: Line, // "signed in" (SSO or paired credentials present and not expired) | "signed in (expired)" | "signed out" + pub pairing: Line, // "paired ()" | "not paired" + pub verification: Line, // "verified (safety code โ€ฆ)" | "not verified" | "revoked by " | "โ€”" when not paired + pub credentials: Line, // "unlocked" | "locked" | "no key agent running" (=locked) | "not set up" + pub authorization: Line, // "unknown (stale)" until the freshness contract lands โ€” never "fresh" without proof + pub relay: Line, // "unknown (offline check)" โ€” status never connects + pub local_agents: Line, // "unknown" until the runtime lands (feat/agent-runtime) โ€” or a count if a runtime answers without starting it +} +pub struct Line { pub value: String, pub stale: bool } +pub fn gather(config_dir: &Path) -> StatusReport; // reads files + a running key agent only +pub fn render(r: &StatusReport) -> String; // the aligned block from the spec, "(stale)" suffix when stale +``` + +Output format (labels exactly): + +```text +Account: signed in +Pairing: paired (astation-โ€ฆ) +Verification: verified (safety code MN3H-A74N-FJE4) +Credentials: locked +Authorization: unknown (stale) +Relay: unknown (stale) +Local agents: unknown +``` + +Tests (first): render labels/alignment; gather on temp config dirs: nothing โ†’ signed out / not paired / โ€” / not set up; paired+unverified; paired+verified; revoked; `atem pair` when paired makes no connection (point ASTATION_WS at a closed port and assert it never dials, e.g. via a listener that must not get a connection) and leaves files unchanged (hash the config dir before/after); `pair verify` already verified โ†’ no connection; not paired โ†’ error text; revoked โ†’ error text, no files changed; `cred unlock` unverified โ†’ error text, no connection; `cred status` output == `status` output; CLI parsing for `status`, `pair verify`. + +- [ ] tests โ†’ implement โ†’ verify โ†’ commit `feat(cli): atem status, pair verify, and pair/unlock that never re-pair`. From 4c68ec44249910238c6cb3cbf5c6a9277b01d481 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 19:47:38 +0000 Subject: [PATCH 2/7] feat(cli): atem status, pair verify, and pair/unlock that never re-pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - atem status (new src/status.rs): read-only, offline account / pairing / verification / credentials report; asks only an already running key agent; authorization and relay shown as unknown (stale). atem cred status prints the same (the old cred status report is removed). - atem pair on a paired profile prints the status plus "Already paired..." and exits 0 without connecting or changing files. - atem pair verify: verifies the existing pairing over the saved session (no pairing), no-op when already verified, refuses after a revocation. - atem cred unlock on an unverified device stops offline. - Revoked keys are no longer set aside by pair/verification; atem unpair does it, and now also removes saved Astation sessions. - Hints that said "run atem pair" to verify now say atem pair verify. ๐Ÿค– Built with SMT --- AGENTS.md | 19 +- README.md | 27 +- npm/README.md | 27 +- src/auth.rs | 12 +- src/cli.rs | 377 +++++++++++++++++++-- src/main.rs | 1 + src/memory/agent_socket.rs | 28 +- src/memory/key_agent.rs | 8 +- src/memory/trust.rs | 8 +- src/memory/unlock.rs | 359 +++----------------- src/memory/verification.rs | 35 +- src/pair_verify.rs | 10 +- src/status.rs | 663 +++++++++++++++++++++++++++++++++++++ src/tui/draw.rs | 8 +- 14 files changed, 1177 insertions(+), 405 deletions(-) create mode 100644 src/status.rs diff --git a/AGENTS.md b/AGENTS.md index 5ef0762..fbd8446 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,8 @@ src/ โ”œโ”€โ”€ web_server/ # Shared HTTPS scaffolding (cert, request, /api/token, net, html, browser open) โ”œโ”€โ”€ command.rs # Task queue and stream buffer for voice commands โ”œโ”€โ”€ dispatch.rs # Work item dispatcher for mark tasks -โ”œโ”€โ”€ pair_verify.rs # atem pair device verification: /dev/tty code confirmation, verifyAbort on failure +โ”œโ”€โ”€ pair_verify.rs # atem pair / atem pair verify device verification: /dev/tty code confirmation, verifyAbort on failure +โ”œโ”€โ”€ status.rs # atem status (and the atem cred status alias): read-only, offline; also decides pair (no-op when paired), pair verify and the cred unlock gate โ”œโ”€โ”€ vault_client.rs # atem vault CLI client (relay /api/vault: request builders, renderers, executor) โ”œโ”€โ”€ memory/ # Atem Memory: shared memory + skills across agents and machines โ”‚ โ”œโ”€โ”€ model.rs # Memory/Skill types, hashing @@ -114,7 +115,7 @@ src/ โ”‚ โ”œโ”€โ”€ storage_key.rs # storage key + device_keys.sealed (XChaCha20-Poly1305); unlock-grant/ack checks โ”‚ โ”œโ”€โ”€ key_agent.rs # key agent state: unlock, rotation, abandon, K installed and used in memory (Crypt) โ”‚ โ”œโ”€โ”€ agent_socket.rs # `atem key-agent` Unix socket server + client (v2 JSON lines, same UID only) -โ”‚ โ”œโ”€โ”€ unlock.rs # `atem cred projects|status|unlock|lock`; unlock/rotation carried over the Astation link +โ”‚ โ”œโ”€โ”€ unlock.rs # `atem cred projects|status|unlock|lock` (status = `atem status`); unlock/rotation carried over the Astation link โ”‚ โ”œโ”€โ”€ migration.rs # migration read-back check + atem-migration-complete-v1 proof input โ”‚ โ”œโ”€โ”€ fake_astation.rs # (test-only) Astation's side of unlock and rotation, mirrors the design doc โ”‚ โ”œโ”€โ”€ kat_tests.rs # (test-only) known-answer vectors listed in designs/e2e-encryption.md @@ -206,7 +207,7 @@ Key methods: **Atem Memory** (`src/memory/`): `atem sync`, `atem memory โ€ฆ`, `atem skill โ€ฆ`. Agents learn from each other across agents and machines. Claude's saved memories are harvested (an edited file becomes a replacement, a deleted file an invalidation), Codex saves facts with `atem memory add --agent codex` and replaces outdated ones with `atem memory replace ""` (its block shows each fact's short id), and skills are versioned directories (`atem skill history`/`atem skill restore` read old versions from the relay). Facts are never edited in place: `memory replace`/`memory invalidate` keep the history (`valid_at`, `invalid_at`, `superseded_by`; see `memory list --history`), invalidation is final, and only valid facts are injected โ€” a block that leaves facts out says how many and points to `atem memory search`, which is local FTS5 (trigram, BM25) over `knowledge.db`. Everything is synced through the relay (`/api/memory`, `/api/skills`, Astation pairing-session auth) with an offline SQLite store, then applied as a managed block in `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, and `/CLAUDE.local.md`, and as skills in `.claude/skills` and `.agents/skills`. Credential values are never stored: names only. Tracked files are never written. See `designs/atem-memory.md`. -**Key agent** (`src/memory/key_agent.rs`, `agent_socket.rs`, `unlock.rs`): this device's keys live sealed in `device_keys.sealed` under a storage key only the home Astation (the first one this device verified with) holds. The hidden `atem key-agent` (same binary, started on demand, detached; Unix socket, same-UID peers only, `"v": 2` JSON lines; `PR_SET_DUMPABLE=0` on Linux, and `mlockall` there when `RLIMIT_MEMLOCK` is unlimited or โ‰ฅ 512 MiB; one agent per key directory via a `flock` on `key_agent.lock`; an agent whose socket disappears, e.g. logind removing `$XDG_RUNTIME_DIR`, wipes its keys and exits within ~5 s) holds the unlocked keys; while it is locked, grants are ignored (`encryptionMode`/`keyGrant` arriving while atem waits for another reply are applied, not dropped, and after an unlock atem sends `keyRequest` if the signed state still needs `K`; an already unlocked `atem cred unlock` still connects while the signed state is `enabling`/`disabling`, to finish the migration and send the signed completion, since `atem sync` never talks to Astation). `atem cred unlock` asks it for a single-use key, sends an `unlockRequest` signed by `unlock_auth_key`, and hands Astation's Touch-ID-approved, signed `unlockGrant` straight back to the agent; the agent then rotates the storage key in three crash-safe phases (`.next` โ†’ `storageKeyAck` โ†’ promote, `.prev` kept until the next unlock โ†’ `storageKeyConfirm`), abandoning a stale pending key if needed. `atem pair` escrows the first storage key in the same run; until Astation is known to hold it, a fresh device keeps a plain `device_keys` file (like a step-1 device), which the agent re-seals at each start and deletes at the first confirmed escrow, so an agent that stops first loses nothing; `atem cred lock` wipes the agent and stops it (always allowed: the agent stops listening, removes its socket and `agent.socket`, gives requests in flight up to 1 s, clears its pid from `key_agent.lock` and exits; the CLI waits up to 2 s for it to go, and an agent from an older atem that only wipes is reported as still running until the next `atem cred unlock` replaces it); `atem cred status` shows verified / home Astation / agent / storage key, and the reset steps when a key file is broken. An `unlockDenied` with `revoked: true` (or "revoked" in its reason) is recorded in `cred_state.json` (`atem cred status` shows "revoked by Astation โ€ฆ"); the next `atem pair` with the agent locked renames the old key files to `.revoked-`, forgets that Astation's verification, home and escrow state, and verifies with new keys (the signal is unsigned: a forged one costs a re-pair, never keys or trust). Since step 2b `K` lives only in the agent: it installs granted keys into the sealed payload (`InstallGrant`; a payload with keys is `version: 2`, so a step-2a binary refuses it), does every seal/open/keyed hash for `EncryptionContext` in batched `Crypt` requests (no reply carries `K`), takes the mode and kid from the newest signed account state across every verified Astation naming the account (one helper, `verification::effective_state`, for install, `Crypt`, `account_mode` and `key_needed`), serializes key payloads into exactly sized wiped buffers, and moves a leftover `data_keys.enc` in at the first unlock (then deletes it, unless an entry of a verified account can't be parsed: then the valid keys move and the file stays; an unreadable one is renamed aside, never deleted); a locked agent keeps sync changes queued until `atem cred unlock`. A key agent left running from before the upgrade speaks protocol v1 and must be stopped (the error says how); a restart before the first escrow loses a post-2b `K`, which is requested again. After the upgrade a verified device needs `atem cred unlock` once. The status reply carries the agent's `build` (`key_agent::build_id`: version + the executable's mtime and size when the agent started, so every rebuild differs; absent from an older agent, which counts as older). A command that may start the agent compares it with its own once: a locked agent of another build is asked to stop (one too old for that gets SIGTERM, only when its pid is confirmed as this user's `atem key-agent` through `key_agent.lock`, else the error gives the `pgrep`/`pkill` lines) and this build's agent is started; it holds no keys, so nothing is lost. An unlocked one is never restarted: the command warns once ("The key agent was started by another atem build; run `atem cred lock`, then `atem cred unlock` to use this one.") and uses it, a request it rejects as unknown gets that hint instead of the raw error, and `atem cred status` adds `(started by another atem build; run `atem cred lock`, then `atem cred unlock`)` to the agent line (builds are compared for equality only: a dev build and the npm release of the same version differ, and neither counts as newer). The unlocked agent also signs what atem reports to Astation: `CredentialProjects` (the HPKE credential-project report plus optional original-case `labels`, `credential_projects.rs`; sent best effort after each authenticated connection, on `credentialProjectListRequest`, and by `atem cred projects`) and `MigrationComplete` (the `atem-migration-complete-v1` completion with its `K` possession proof, sent only after the read-back pass in `migration.rs` finds every protected field migrated). See `designs/e2e-encryption.md` "Keys on disk (atem)" and "Astation work for step 2a", and `docs/credential-project-reporting.md`. +**Key agent** (`src/memory/key_agent.rs`, `agent_socket.rs`, `unlock.rs`): this device's keys live sealed in `device_keys.sealed` under a storage key only the home Astation (the first one this device verified with) holds. The hidden `atem key-agent` (same binary, started on demand, detached; Unix socket, same-UID peers only, `"v": 2` JSON lines; `PR_SET_DUMPABLE=0` on Linux, and `mlockall` there when `RLIMIT_MEMLOCK` is unlimited or โ‰ฅ 512 MiB; one agent per key directory via a `flock` on `key_agent.lock`; an agent whose socket disappears, e.g. logind removing `$XDG_RUNTIME_DIR`, wipes its keys and exits within ~5 s) holds the unlocked keys; while it is locked, grants are ignored (`encryptionMode`/`keyGrant` arriving while atem waits for another reply are applied, not dropped, and after an unlock atem sends `keyRequest` if the signed state still needs `K`; an already unlocked `atem cred unlock` still connects while the signed state is `enabling`/`disabling`, to finish the migration and send the signed completion, since `atem sync` never talks to Astation). `atem cred unlock` asks it for a single-use key, sends an `unlockRequest` signed by `unlock_auth_key`, and hands Astation's Touch-ID-approved, signed `unlockGrant` straight back to the agent; the agent then rotates the storage key in three crash-safe phases (`.next` โ†’ `storageKeyAck` โ†’ promote, `.prev` kept until the next unlock โ†’ `storageKeyConfirm`), abandoning a stale pending key if needed. `atem pair` escrows the first storage key in the same run; until Astation is known to hold it, a fresh device keeps a plain `device_keys` file (like a step-1 device), which the agent re-seals at each start and deletes at the first confirmed escrow, so an agent that stops first loses nothing; `atem cred lock` wipes the agent and stops it (always allowed: the agent stops listening, removes its socket and `agent.socket`, gives requests in flight up to 1 s, clears its pid from `key_agent.lock` and exits; the CLI waits up to 2 s for it to go, and an agent from an older atem that only wipes is reported as still running until the next `atem cred unlock` replaces it); `atem cred status` is `atem status` (see SSO auth). An `unlockDenied` with `revoked: true` (or "revoked" in its reason) is recorded in `cred_state.json` (`atem status` shows "revoked by "; `atem pair verify` then refuses); `atem unpair` with the agent locked (or not running) renames the old key files to `.revoked-` and forgets that Astation's verification, home and escrow state, so the next `atem pair` verifies with new keys (the signal is unsigned: a forged one costs a re-pair, never keys or trust; verification itself never sets keys aside). Since step 2b `K` lives only in the agent: it installs granted keys into the sealed payload (`InstallGrant`; a payload with keys is `version: 2`, so a step-2a binary refuses it), does every seal/open/keyed hash for `EncryptionContext` in batched `Crypt` requests (no reply carries `K`), takes the mode and kid from the newest signed account state across every verified Astation naming the account (one helper, `verification::effective_state`, for install, `Crypt`, `account_mode` and `key_needed`), serializes key payloads into exactly sized wiped buffers, and moves a leftover `data_keys.enc` in at the first unlock (then deletes it, unless an entry of a verified account can't be parsed: then the valid keys move and the file stays; an unreadable one is renamed aside, never deleted); a locked agent keeps sync changes queued until `atem cred unlock`. A key agent left running from before the upgrade speaks protocol v1 and must be stopped (the error says how); a restart before the first escrow loses a post-2b `K`, which is requested again. After the upgrade a verified device needs `atem cred unlock` once. The status reply carries the agent's `build` (`key_agent::build_id`: version + the executable's mtime and size when the agent started, so every rebuild differs; absent from an older agent, which counts as older). A command that may start the agent compares it with its own once: a locked agent of another build is asked to stop (one too old for that gets SIGTERM, only when its pid is confirmed as this user's `atem key-agent` through `key_agent.lock`, else the error gives the `pgrep`/`pkill` lines) and this build's agent is started; it holds no keys, so nothing is lost. An unlocked one is never restarted: the command warns once ("The key agent was started by another atem build; run `atem cred lock`, then `atem cred unlock` to use this one.") and uses it, a request it rejects as unknown gets that hint instead of the raw error (builds are compared for equality only: a dev build and the npm release of the same version differ, and neither counts as newer). The unlocked agent also signs what atem reports to Astation: `CredentialProjects` (the HPKE credential-project report plus optional original-case `labels`, `credential_projects.rs`; sent best effort after each authenticated connection, on `credentialProjectListRequest`, and by `atem cred projects`) and `MigrationComplete` (the `atem-migration-complete-v1` completion with its `K` possession proof, sent only after the read-back pass in `migration.rs` finds every protected field migrated). See `designs/e2e-encryption.md` "Keys on disk (atem)" and "Astation work for step 2a", and `docs/credential-project-reporting.md`. **Claude Code Integration** (`claude_client.rs`): Manages Claude Code as a PTY subprocess using `portable-pty`. Includes terminal output parsing via `vt100`, session recording, and resize handling. @@ -251,9 +252,11 @@ Key methods: **SSO auth** (`sso_auth.rs`): - `atem login` โ€” OAuth 2.0 + PKCE browser flow against `sso2.agora.io`; writes an `sso` entry to `credentials.enc` - `atem logout` โ€” removes the `sso` entry from `credentials.enc` -- `atem pair [--save]` โ€” connect to Astation, send `PairSavePreference`, wait for `CredentialSync`, write paired entry, then verify the device: commit-then-reveal (`verifyCommit` โ†’ `verifyKeys` โ†’ `verifyReveal`), both sides show a 12-character safety code, the user answers `codes match? [y/N]` (read from `/dev/tty`, flushed first, never stdin; no terminal fails the verification) and confirms on Astation with Touch ID, and atem applies the signed `deviceVerified` atomically (pins, device keys, signed state, grants; nothing on failure). If `K` is needed but missing it sends `keyRequest`. Astations that don't answer within 15s leave the device as it was (see `designs/e2e-encryption.md`). Any later failure (code refused, no terminal, 300 s Touch ID timeout, lost connection, apply error) drops the pending pin and sends `verifyAbort` with delivery confirmed (not when Astation itself aborted); the pairing stays saved and `atem pair` exits nonzero. A failed `keyRequest` after success is only a warning (`atem cred unlock` asks again). Logic and tests: `src/pair_verify.rs`. On the home Astation's first verification the fresh keys are sealed and the first storage key is escrowed (`storageKeyRotate` with an empty old kid, then confirmed) in the same run -- `atem cred projects|status|unlock|lock` (tier 2) โ€” send the current Git project and the account's remembered project names to the home Astation's credential picker (keys must be unlocked; connects within 15 s; see `docs/credential-project-reporting.md`); key agent state; Touch ID unlock through the home Astation (waits up to 300 s, then rotates the storage key; when already unlocked it still finishes a pending migration); wipe the unlocked keys and stop the agent. `atem key-agent` is the hidden agent process itself -- `atem unpair [--yes]` โ€” remove all paired entries. It, `atem memory purge` and `atem skill purge` confirm first (`cli::confirm_destructive`): `--yes` skips the question, and without a terminal they refuse unless `--yes` is given. The purge prompts show the id and scope only, never the content, since a purged item may hold a credential +- `atem status` (tier 0, `src/status.rs`) โ€” read-only and offline: `Account` (signed in / signed in (expired) / signed out), `Pairing` (`paired ()` โ€” the one `require_pairing` resolves, else the first paired one, `(+N more)` for others โ€” or `not paired`), `Verification` (`verified (safety code โ€ฆ)` / `not verified` / `revoked by ` / `โ€”`), `Credentials` (`unlocked` / `locked` / `not set up`; asks only an already running key agent, never starts one; no agent = locked), `Authorization` and `Relay` `unknown (stale)`, `Local agents` `unknown`. Never connects, never writes. `atem cred status` prints exactly the same. "Paired" = any paired credential entry or valid saved Astation session in `sessions.json` +- `atem pair [--save]` โ€” on an already paired profile (even unverified or revoked) prints the status plus `Already paired. Verify this device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair` and exits 0 without connecting or changing anything. Otherwise: connect to Astation, send `PairSavePreference`, wait for `CredentialSync`, write paired entry, then verify the device: commit-then-reveal (`verifyCommit` โ†’ `verifyKeys` โ†’ `verifyReveal`), both sides show a 12-character safety code, the user answers `codes match? [y/N]` (read from `/dev/tty`, flushed first, never stdin; no terminal fails the verification) and confirms on Astation with Touch ID, and atem applies the signed `deviceVerified` atomically (pins, device keys, signed state, grants; nothing on failure). If `K` is needed but missing it sends `keyRequest`. Astations that don't answer within 15s leave the device as it was (see `designs/e2e-encryption.md`). Any later failure (code refused, no terminal, 300 s Touch ID timeout, lost connection, apply error) drops the pending pin and sends `verifyAbort` with delivery confirmed (not when Astation itself aborted); the pairing stays saved and `atem pair` exits nonzero (`atem pair verify` retries). A failed `keyRequest` after success is only a warning (`atem cred unlock` asks again). Logic and tests: `src/pair_verify.rs`. On the home Astation's first verification the fresh keys are sealed and the first storage key is escrowed (`storageKeyRotate` with an empty old kid, then confirmed) in the same run +- `atem pair verify` โ€” needs a pairing (`Not paired. Run atem pair first.`, exit 1); already verified with the paired Astation โ†’ `Already verified (safety code โ€ฆ).`, no connection; revoked โ†’ `This device was revoked by Astation . Run atem unpair, then atem pair, to set it up again.`, exit 1, nothing moved. Otherwise runs the same verification as `atem pair` (`run_device_verification`) over a connection authenticated with the saved session (local Astation if it is the paired one, else its relay identity room; never pairing). A cancelled or failed verification keeps the pairing +- `atem cred projects|status|unlock|lock` (tier 2; `status` works on any profile) โ€” send the current Git project and the account's remembered project names to the home Astation's credential picker (keys must be unlocked; connects within 15 s; see `docs/credential-project-reporting.md`); `atem status`; Touch ID unlock through the home Astation (waits up to 300 s, then rotates the storage key; when already unlocked it still finishes a pending migration; an unverified device stops offline with `This device isn't verified. Run atem pair verify first.`, and it never starts pairing or verification); wipe the unlocked keys and stop the agent. `atem key-agent` is the hidden agent process itself +- `atem unpair [--yes]` โ€” remove all paired credential entries and saved Astation sessions (the SSO login stays), and set revoked keys aside (see Key agent). It, `atem memory purge` and `atem skill purge` confirm first (`cli::confirm_destructive`): `--yes` skips the question, and without a terminal they refuse unless `--yes` is given. The purge prompts show the id and scope only, never the content, since a purged item may hold a credential - `valid_token(connected_astation_id, sso_url)` โ€” resolves via priority chain and returns an access token. Within 60s of expiry, an `sso` entry refreshes itself; a paired entry never does (Agora rotates refresh tokens, so it would invalidate Astation's copy): atem connects to Astation, which sends a fresh `credentialSync` on connect. **BFF API** (`agora_api.rs`): @@ -267,7 +270,7 @@ SSO: logged in (52a4f560...) Paired: astation- (SSO: 52a4f560...) [save: yes] Verified: yes (safety code MN3H-A74N-FJE4) ``` -An unverified pairing shows `Verified: no (run 'atem pair' to verify this device)`. +An unverified pairing shows `Verified: no (run 'atem pair verify' to verify this device)`. **WebSocket messages** (`websocket_client.rs`): - `CredentialSync { access_token, refresh_token, expires_at, login_id, astation_id, save_credentials }` โ€” Astation โ†’ Atem, after pair, on connect, or on Astation-side refresh (replaced `SsoTokenSync`) @@ -304,7 +307,7 @@ with Astation unlocks the full set (tier 2) โ€” Astation is the control plane. | Tier | Needs | Commands | |---|---|---| -| 0 | โ€” | serv files, config, token with AGORA_APP_ID/CERT env, `project use `, `project show` (local cache) | +| 0 | โ€” | status (and `cred status`), serv files, config, token with AGORA_APP_ID/CERT env, `project use `, `project show` (local cache) | | 1 | `atem login` | `project list`, `project use `, token (active project), serv rtc/convo/webhooks | | 2 | paired with Astation | vault, sync, memory, skill, cred, and Astation-driven remote agent control, voice coding, mark tasks | diff --git a/README.md b/README.md index 659c861..f9c50df 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,11 @@ atem repl # Interactive REPL with AI command inter ```bash atem login # Log in with Agora Console (opens browser) atem logout # Log out from SSO -atem pair # Pair with Astation, then verify this device +atem status # Account, pairing, verification and credentials (offline, read-only) +atem pair # Pair with Astation, then verify this device (shows the status when already paired) atem pair --save # Same, and keep credentials for offline use -atem unpair [--yes] # Remove all Astation pairings (asks first) +atem pair verify # Verify this device with the paired Astation (no re-pairing) +atem unpair [--yes] # Remove all Astation pairings and saved sessions (asks first) ``` `atem login` unlocks a limited set: project list, tokens for the active @@ -61,15 +63,24 @@ atem and your Mac each show a 12-character safety code. If they match, type Either side can finish first. atem reads that `y` from the terminal itself, never from piped input or keys typed earlier, so run `atem pair` in an interactive terminal. If verification fails, the pairing stays saved, Astation -is told, and `atem pair` exits nonzero; run it again to verify. A connected or SSO-paired session alone does not -mean the device is verified; check `atem cred status`. With `--save` +is told, and `atem pair` exits nonzero; run `atem pair verify` to retry. A connected or SSO-paired session alone does not +mean the device is verified; check `atem status`. With `--save` the credentials keep working when Astation disconnects; without it they stop 5 minutes after the disconnect (atem asks when `--save` isn't given). +Once paired, `atem pair` only prints the status (`Already paired. Verify this +device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair`): +it never pairs again or changes keys, trust or sessions. `atem pair verify` +verifies the existing pairing over its saved session, does nothing when the +device is already verified, and refuses after a revocation (run `atem unpair`, +which sets the revoked keys aside, then `atem pair`). `atem status` reads only +local files and an already running key agent; it never connects, so +authorization and relay show as `unknown (stale)`. + ### Device keys (`atem cred`) ```bash -atem cred status # Show whether this device is verified, its home Astation and its key agent +atem cred status # Same as `atem status` atem cred unlock # Unlock this device's keys (approve with Touch ID on your Mac) atem cred lock # Wipe unlocked keys and stop the key agent atem cred projects # Send known Git projects to Astation's credential picker (keys must be unlocked) @@ -77,7 +88,9 @@ atem cred projects # Send known Git projects to Astation's This device's keys are sealed on disk under a storage key that only the paired Mac (the home Astation) holds. `atem cred unlock` asks the Mac for it (Touch -ID) and starts a background `atem key-agent` that holds the keys in memory. +ID) and starts a background `atem key-agent` that holds the keys in memory; +on a device no Astation verified it stops with `This device isn't verified. +Run atem pair verify first.` and never starts pairing or verification. `atem cred lock` wipes them and stops the agent. After upgrading atem, run `atem cred lock` then `atem cred unlock` to pick up the new agent. @@ -260,7 +273,7 @@ such as timestamps and ciphertext sizes; it is not authoritative for device trust or encryption state. Migration cannot prove that a server erased old plaintext backups. -The device must be verified (`atem pair`) and unlocked (`atem cred unlock`). +The device must be verified (`atem pair`, or `atem pair verify` to retry) and unlocked (`atem cred unlock`). While it is locked, `atem sync` keeps changes queued. Switching encryption on or off migrates existing data; each verified atem finishes its part at its next `atem cred unlock` (`atem sync` alone doesn't contact Astation). **Turning on** diff --git a/npm/README.md b/npm/README.md index f50bd8e..9f86daf 100644 --- a/npm/README.md +++ b/npm/README.md @@ -34,9 +34,11 @@ atem repl # Interactive REPL with AI command inter ```bash atem login # Log in with Agora Console (opens browser) atem logout # Log out from SSO -atem pair # Pair with Astation, then verify this device +atem status # Account, pairing, verification and credentials (offline, read-only) +atem pair # Pair with Astation, then verify this device (shows the status when already paired) atem pair --save # Same, and keep credentials for offline use -atem unpair [--yes] # Remove all Astation pairings (asks first) +atem pair verify # Verify this device with the paired Astation (no re-pairing) +atem unpair [--yes] # Remove all Astation pairings and saved sessions (asks first) ``` `atem login` unlocks a limited set: project list, tokens for the active @@ -52,15 +54,24 @@ atem and your Mac each show a 12-character safety code. If they match, type Either side can finish first. atem reads that `y` from the terminal itself, never from piped input or keys typed earlier, so run `atem pair` in an interactive terminal. If verification fails, the pairing stays saved, Astation -is told, and `atem pair` exits nonzero; run it again to verify. A connected or SSO-paired session alone does not -mean the device is verified; check `atem cred status`. With `--save` +is told, and `atem pair` exits nonzero; run `atem pair verify` to retry. A connected or SSO-paired session alone does not +mean the device is verified; check `atem status`. With `--save` the credentials keep working when Astation disconnects; without it they stop 5 minutes after the disconnect (atem asks when `--save` isn't given). +Once paired, `atem pair` only prints the status (`Already paired. Verify this +device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair`): +it never pairs again or changes keys, trust or sessions. `atem pair verify` +verifies the existing pairing over its saved session, does nothing when the +device is already verified, and refuses after a revocation (run `atem unpair`, +which sets the revoked keys aside, then `atem pair`). `atem status` reads only +local files and an already running key agent; it never connects, so +authorization and relay show as `unknown (stale)`. + ### Device keys (`atem cred`) ```bash -atem cred status # Show whether this device is verified, its home Astation and its key agent +atem cred status # Same as `atem status` atem cred unlock # Unlock this device's keys (approve with Touch ID on your Mac) atem cred lock # Wipe unlocked keys and stop the key agent atem cred projects # Send known Git projects to Astation's credential picker (keys must be unlocked) @@ -68,7 +79,9 @@ atem cred projects # Send known Git projects to Astation's This device's keys are sealed on disk under a storage key that only the paired Mac (the home Astation) holds. `atem cred unlock` asks the Mac for it (Touch -ID) and starts a background `atem key-agent` that holds the keys in memory. +ID) and starts a background `atem key-agent` that holds the keys in memory; +on a device no Astation verified it stops with `This device isn't verified. +Run atem pair verify first.` and never starts pairing or verification. `atem cred lock` wipes them and stops the agent. After upgrading atem, run `atem cred lock` then `atem cred unlock` to pick up the new agent. @@ -203,7 +216,7 @@ such as timestamps and ciphertext sizes; it is not authoritative for device trust or encryption state. Migration cannot prove that a server erased old plaintext backups. -The device must be verified (`atem pair`) and unlocked (`atem cred unlock`). +The device must be verified (`atem pair`, or `atem pair verify` to retry) and unlocked (`atem cred unlock`). While it is locked, `atem sync` keeps changes queued. Switching encryption on or off migrates existing data; each verified atem finishes its part at its next `atem cred unlock` (`atem sync` alone doesn't contact Astation). **Turning on** diff --git a/src/auth.rs b/src/auth.rs index 932fcaf..fb3428c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -204,13 +204,23 @@ impl SessionManager { self.sessions.insert(astation_id, session); } + /// Every saved session's Astation id, expired ones included. + pub(crate) fn astation_ids(&self) -> Vec { + self.sessions.keys().cloned().collect() + } + + /// Forgets every saved session (`atem unpair`). + pub(crate) fn clear(&mut self) { + self.sessions.clear(); + } + /// Get all active (valid) sessions. pub fn active_sessions(&self) -> Vec<&AuthSession> { self.sessions.values().filter(|s| s.is_valid()).collect() } /// Get the path to the sessions file. - fn sessions_path() -> Result { + pub(crate) fn sessions_path() -> Result { let config_dir = dirs::config_dir() .ok_or_else(|| anyhow!("Could not determine config directory"))?; Ok(config_dir.join("atem").join("sessions.json")) diff --git a/src/cli.rs b/src/cli.rs index 2aa625b..7c0bcd2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -40,11 +40,16 @@ pub enum Commands { Login, /// Log out from SSO Logout, - /// Pair with Astation + /// Show account, pairing, verification and credential status (read-only, offline) + Status, + /// Pair with Astation (shows the status when already paired) + #[command(args_conflicts_with_subcommands = true)] Pair { /// Save credentials for offline use #[arg(long)] save: bool, + #[command(subcommand)] + command: Option, }, /// Unpair from Astation Unpair { @@ -391,11 +396,17 @@ pub enum ServCommands { }, } +#[derive(clap::Subcommand, Debug)] +pub enum PairCommands { + /// Verify this device with the paired Astation (safety code + Touch ID); no re-pairing + Verify, +} + #[derive(clap::Subcommand, Debug)] pub enum CredCommands { /// Send known Git projects to Astation's credential picker (keys must be unlocked) Projects, - /// Show whether this device is verified, its home Astation and its key agent + /// Same as `atem status` Status, /// Unlock this device's keys (approve with Touch ID on your Mac) Unlock, @@ -887,15 +898,19 @@ pub async fn handle_cli_command(command: Commands) -> Result<()> { println!("Logged out."); Ok(()) } - Commands::Pair { save } => run_pair(save).await, + Commands::Status => { + let src = crate::status::Sources::current()?; + print!("{}", crate::memory::key_agent::blocking(move || Ok(crate::status::text(&src))).await?); + Ok(()) + } + Commands::Pair { save, command: None } => run_pair(&crate::status::Sources::current()?, save).await, + Commands::Pair { command: Some(PairCommands::Verify), .. } => { + run_pair_verify(&crate::status::Sources::current()?).await + } Commands::Unpair { yes } => { - let mut store = crate::credentials::CredentialStore::load(); - let count = store - .entries - .iter() - .filter(|e| e.source == crate::credentials::CredentialSource::AstationPaired) - .count(); - if count == 0 { + let src = crate::status::Sources::current()?; + let count = pairings_to_remove(&src); + if count == 0 && !revocation_recorded(&src) { println!("No paired sessions to remove."); return Ok(()); } @@ -909,11 +924,18 @@ pub async fn handle_cli_command(command: Commands) -> Result<()> { println!("Cancelled; nothing was removed."); return Ok(()); } - store - .entries - .retain(|e| e.source != crate::credentials::CredentialSource::AstationPaired); - store.save()?; + let notice = { + let src = src.clone(); + crate::memory::key_agent::blocking(move || { + let agent = crate::memory::key_agent::running_agent(); + unpair_profile(&src, agent.as_deref()) + }) + .await? + }; println!("Unpaired ({count} session{plural} removed)."); + if let Some(notice) = notice { + println!("{notice}"); + } Ok(()) } Commands::Agent { agent_command } => match agent_command { @@ -1141,11 +1163,68 @@ fn prompt_save_credentials() -> bool { prompt_yes_no("Save credentials so they keep working when Astation disconnects? [y/N]: ") } -/// `atem pair [--save]` โ€” connect to Astation, send PairSavePreference, wait for CredentialSync, -/// then verify this device (`run_device_verification`). -async fn run_pair(save: bool) -> Result<()> { +/// How many Astation pairings `atem unpair` removes: the distinct Astations +/// of the paired credential entries and the saved sessions. +fn pairings_to_remove(src: &crate::status::Sources) -> usize { + let store = crate::credentials::CredentialStore::load_from(&src.credentials); + let mut ids: std::collections::BTreeSet = store + .entries + .iter() + .filter(|e| e.source == crate::credentials::CredentialSource::AstationPaired) + .map(|e| e.astation_id.clone().unwrap_or_default()) + .collect(); + if let Ok(sessions) = crate::auth::SessionManager::load_from(&src.sessions) { + ids.extend(sessions.astation_ids()); + } + ids.len() +} + +/// Whether an Astation revoked this device (`atem unpair` sets its keys aside). +fn revocation_recorded(src: &crate::status::Sources) -> bool { + crate::memory::trust::TrustStore::load_from(&src.keys.trust) + .is_ok_and(|trust| trust.revoked().is_some()) +} + +/// `atem unpair` after the confirmation: removes the paired credential +/// entries and the saved Astation sessions (the login stays), and, when an +/// Astation revoked this device and `agent` (the running key agent, if any) +/// doesn't hold its keys unlocked, sets the revoked keys aside so the next +/// `atem pair` verifies with new ones. Returns that notice. Blocking. +fn unpair_profile( + src: &crate::status::Sources, + agent: Option<&dyn crate::memory::key_agent::KeyAgentApi>, +) -> Result> { + let mut store = crate::credentials::CredentialStore::load_from(&src.credentials); + let before = store.entries.len(); + store + .entries + .retain(|e| e.source != crate::credentials::CredentialSource::AstationPaired); + if store.entries.len() != before { + store.save_to(&src.credentials)?; + } + if src.sessions.exists() { + let mut sessions = crate::auth::SessionManager::load_from(&src.sessions)?; + sessions.clear(); + sessions.save_to(&src.sessions)?; + } + crate::memory::verification::set_aside_revoked_keys(&src.keys, agent) +} + +/// `atem pair [--save]` โ€” when the profile is already paired, prints its status +/// and changes nothing (no connection). Otherwise connects to Astation, sends +/// PairSavePreference, waits for CredentialSync, then verifies this device +/// (`run_device_verification`). +async fn run_pair(src: &crate::status::Sources, save: bool) -> Result<()> { use tokio::time::{Duration, timeout}; - let config = crate::config::AtemConfig::load()?; + let already = { + let src = src.clone(); + crate::memory::key_agent::blocking(move || Ok(crate::status::already_paired(&src))).await? + }; + if let Some(text) = already { + print!("{text}"); + return Ok(()); + } + let config = src.config.clone(); println!("Connecting to Astation..."); let mut client = crate::websocket_client::AstationClient::new(); @@ -1294,6 +1373,30 @@ async fn run_pair(save: bool) -> Result<()> { } } +/// `atem pair verify` โ€” verifies this device with the paired Astation over a +/// connection authenticated with the saved session (no pairing). Does nothing +/// when that Astation already verified it, and refuses after a revocation. +/// A cancelled or failed verification keeps the pairing. +async fn run_pair_verify(src: &crate::status::Sources) -> Result<()> { + let astation_id = match crate::status::verify_start(src)? { + crate::status::VerifyStart::AlreadyVerified(message) => { + println!("{message}"); + return Ok(()); + } + crate::status::VerifyStart::Verify(astation_id) => astation_id, + }; + println!("Connecting to Astation {astation_id}..."); + let mut client = crate::memory::unlock::connect_astation(&src.config, &astation_id, "the paired Astation").await?; + let verified = crate::pair_verify::verification_result( + run_device_verification(&mut client, &astation_id).await, + ); + // Held until now so they are checked against the pins this run set. + for status in client.apply_held_encryption().await { + println!("{}", crate::memory::unlock::shown(&status)); + } + verified +} + /// Verifies this device with Astation: commit, receive Astation's keys, /// reveal, compare the safety code on both sides, then wait for Astation's /// signed certificate. Nothing is trusted unless both sides confirm. @@ -1306,7 +1409,6 @@ async fn run_device_verification( use crate::memory::unlock::PairEscrow; use crate::memory::verification::{ AstationKeys, Handshake, KeyPaths, complete_verification, device_keys_for_verification, - set_aside_revoked_keys, }; use crate::websocket_client::AstationMessage; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -1325,14 +1427,9 @@ async fn run_device_verification( std::sync::Arc::from(crate::memory::key_agent::default_agent()); let keys = { let (paths, agent) = (paths.clone(), agent.clone()); - blocking(move || { - // Revoked by the home Astation: the old keys can never unlock, so - // they are set aside and this device verifies with new ones. - if let Some(notice) = set_aside_revoked_keys(&paths, agent.as_ref())? { - println!("{notice}"); - } - device_keys_for_verification(&paths, agent.as_ref()) - }) + // Revoked keys are never set aside here (`atem unpair` does that): + // verification never replaces the keys an Astation trusts. + blocking(move || device_keys_for_verification(&paths, agent.as_ref())) .await? }; let handshake = Handshake::start(keys); @@ -2098,7 +2195,7 @@ mod tests { fn cli_pair_without_save() { let cli = Cli::try_parse_from(["atem", "pair"]).unwrap(); match cli.command { - Some(Commands::Pair { save }) => assert!(!save), + Some(Commands::Pair { save, .. }) => assert!(!save), _ => panic!("expected Pair command"), } } @@ -2107,7 +2204,7 @@ mod tests { fn cli_pair_with_save_flag() { let cli = Cli::try_parse_from(["atem", "pair", "--save"]).unwrap(); match cli.command { - Some(Commands::Pair { save }) => assert!(save), + Some(Commands::Pair { save, .. }) => assert!(save), _ => panic!("expected Pair command with --save"), } } @@ -2214,4 +2311,226 @@ mod tests { _ => panic!("expected vault read"), } } + + // โ”€โ”€ status, pair, pair verify, cred unlock (offline decisions) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + #[test] + fn cli_status_and_pair_verify_parse() { + let cli = Cli::try_parse_from(["atem", "status"]).unwrap(); + assert!(matches!(cli.command, Some(Commands::Status))); + let cli = Cli::try_parse_from(["atem", "pair", "verify"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Pair { save: false, command: Some(PairCommands::Verify) }) + )); + let cli = Cli::try_parse_from(["atem", "pair"]).unwrap(); + assert!(matches!(cli.command, Some(Commands::Pair { save: false, command: None }))); + } + + /// A local listener every Astation URL of the profile points at: a test + /// fails if anything connects to it (`assert_never_dialed`). + fn trap(src: &mut crate::status::Sources) -> std::net::TcpListener { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + src.config.astation_ws = Some(format!("ws://127.0.0.1:{port}/ws")); + src.config.astation_relay_url = Some(format!("http://127.0.0.1:{port}")); + listener + } + + fn assert_never_dialed(listener: &std::net::TcpListener) { + match listener.accept() { + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Ok(_) => panic!("atem connected to Astation"), + Err(error) => panic!("listener failed: {error}"), + } + } + + fn paired_profile(dir: &std::path::Path) { + let path = dir.join("credentials.enc"); + let mut store = crate::credentials::CredentialStore::load_from(&path); + store.upsert(crate::credentials::CredentialEntry::new_paired( + "access".into(), + "refresh".into(), + crate::credentials::CredentialEntry::now_secs() + 3600, + None, + "astation-1".into(), + true, + 1, + )); + store.save_to(&path).unwrap(); + } + + /// cred_state.json: verified with astation-1, revoked by it when `revoked`. + fn trust_file(dir: &std::path::Path, revoked: bool) { + let pin = crate::memory::trust::AstationTrust { + device_id: "device".into(), + data_account: "account".into(), + sign_gen: 1, + astation_sign_pub: "s".into(), + astation_enc_pub: "e".into(), + recovery_sign_pub: "r".into(), + device_pub: "d".into(), + device_sign_pub: "ds".into(), + unlock_auth_pub: "u".into(), + safety_code: "MN3H-A74N-FJE4".into(), + transcript: String::new(), + epoch_floor: 0, + account_state: None, + account_epoch: 0, + }; + let mut store = serde_json::json!({ + "version": 1, + "home_astation": "astation-1", + "astations": {"astation-1": pin}, + }); + if revoked { + store["revoked"] = serde_json::json!({"astation_id": "astation-1", "at": 1_700_000_000u64}); + } + std::fs::write(dir.join("cred_state.json"), store.to_string()).unwrap(); + std::fs::write(dir.join("device_keys.sealed"), b"{}").unwrap(); + } + + fn files(dir: &std::path::Path) -> Vec<(std::path::PathBuf, Vec)> { + let mut files: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|entry| { + let path = entry.unwrap().path(); + let contents = std::fs::read(&path).unwrap(); + (path, contents) + }) + .collect(); + files.sort(); + files + } + + #[tokio::test] + async fn pair_on_a_paired_profile_changes_nothing_and_never_connects() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + let mut src = crate::status::Sources::in_dir(dir.path()); + let listener = trap(&mut src); + let before = files(dir.path()); + run_pair(&src, false).await.unwrap(); + assert_never_dialed(&listener); + assert_eq!(files(dir.path()), before); + // Also while verification is incomplete or after a revocation. + trust_file(dir.path(), true); + let before = files(dir.path()); + run_pair(&src, true).await.unwrap(); + assert_never_dialed(&listener); + assert_eq!(files(dir.path()), before); + } + + #[tokio::test] + async fn pair_verify_without_a_pairing_says_to_pair() { + let dir = tempfile::tempdir().unwrap(); + let mut src = crate::status::Sources::in_dir(dir.path()); + let listener = trap(&mut src); + let error = run_pair_verify(&src).await.unwrap_err(); + assert_eq!(error.to_string(), "Not paired. Run atem pair first."); + assert_never_dialed(&listener); + } + + #[tokio::test] + async fn pair_verify_when_verified_does_nothing_offline() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + trust_file(dir.path(), false); + let mut src = crate::status::Sources::in_dir(dir.path()); + let listener = trap(&mut src); + let before = files(dir.path()); + run_pair_verify(&src).await.unwrap(); + assert_never_dialed(&listener); + assert_eq!(files(dir.path()), before); + } + + #[tokio::test] + async fn pair_verify_on_a_revoked_device_moves_nothing() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + trust_file(dir.path(), true); + let mut src = crate::status::Sources::in_dir(dir.path()); + let listener = trap(&mut src); + let before = files(dir.path()); + let error = run_pair_verify(&src).await.unwrap_err(); + assert_eq!( + error.to_string(), + "This device was revoked by Astation astation-1. Run atem unpair, then atem pair, to set it up again." + ); + assert_never_dialed(&listener); + assert_eq!(files(dir.path()), before); + } + + #[tokio::test] + async fn cred_unlock_on_an_unverified_device_says_to_verify_and_never_connects() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + let mut src = crate::status::Sources::in_dir(dir.path()); + let listener = trap(&mut src); + let error = crate::memory::unlock::unlock_command(&src.keys, &src.config) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "This device isn't verified. Run atem pair verify first."); + assert_never_dialed(&listener); + } + + #[test] + fn cred_status_prints_what_status_prints() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + trust_file(dir.path(), false); + let src = crate::status::Sources::in_dir(dir.path()); + assert_eq!( + crate::memory::unlock::cred_status_text(&src), + crate::status::text(&src) + ); + assert!(crate::status::text(&src).starts_with("Account: signed in\n")); + } + + #[test] + fn unpair_removes_pairings_and_saved_sessions_but_keeps_the_login() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + let creds = dir.path().join("credentials.enc"); + let mut store = crate::credentials::CredentialStore::load_from(&creds); + store.upsert(crate::credentials::CredentialEntry::new_sso( + "a".into(), + "r".into(), + crate::credentials::CredentialEntry::now_secs() + 3600, + Some("me".into()), + )); + store.save_to(&creds).unwrap(); + let sessions = dir.path().join("sessions.json"); + let mut manager = crate::auth::SessionManager::default(); + manager.insert_session(crate::auth::AuthSession::new( + "s".into(), + "t".into(), + "astation-2".into(), + "host".into(), + )); + manager.save_to(&sessions).unwrap(); + let src = crate::status::Sources::in_dir(dir.path()); + assert_eq!(pairings_to_remove(&src), 2); + unpair_profile(&src, None).unwrap(); + assert_eq!(crate::status::pairing(&src), None); + assert_eq!(pairings_to_remove(&src), 0); + let store = crate::credentials::CredentialStore::load_from(&creds); + assert!(store.find_sso().is_some()); + assert_eq!(store.entries.len(), 1); + } + + #[test] + fn unpair_sets_revoked_keys_aside_so_pair_starts_with_new_ones() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + trust_file(dir.path(), true); + let src = crate::status::Sources::in_dir(dir.path()); + let notice = unpair_profile(&src, None).unwrap(); + assert!(notice.unwrap().contains("old keys were moved aside")); + assert!(!dir.path().join("device_keys.sealed").exists()); + let trust = crate::memory::trust::TrustStore::load_from(&src.keys.trust).unwrap(); + assert!(trust.revoked().is_none()); + assert!(trust.verified("astation-1").is_none()); + } } diff --git a/src/main.rs b/src/main.rs index b5ed409..21b81d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod tui; mod vault_client; mod memory; mod pair_verify; +mod status; mod chat; mod websocket_client; // Agent hub โ€” PTY agents launched by `atem agent launch` diff --git a/src/memory/agent_socket.rs b/src/memory/agent_socket.rs index 22b347f..1837396 100644 --- a/src/memory/agent_socket.rs +++ b/src/memory/agent_socket.rs @@ -1987,29 +1987,21 @@ mod tests { } #[test] - fn cred_status_against_an_old_agent_says_to_stop_it() { - use crate::memory::trust::TrustStore; - use crate::memory::unlock::{AgentState, agent_state, status_report}; + fn status_against_an_old_agent_says_to_stop_it() { let dir = short_dir(); let client = KeyAgentClient::at(start_v1_agent(dir.path())); assert!(client.is_running()); - let state = agent_state(Some(&client)); - let report = status_report( - &TrustStore::default(), - "astation-1", - &state, - Some("0a1b2c3d"), - None, - ); - // The report is printed, not an error: the mismatch and what to do. + let profile = tempfile::tempdir().unwrap(); + std::fs::write(profile.path().join("device_keys.sealed"), b"{}").unwrap(); + let src = crate::status::Sources::in_dir(profile.path()); + let credentials = crate::status::gather_with(&src, Some(&client)).credentials.value; + // Shown, not an error: the mismatch and what to do. assert!( - report.contains("Key agent: not answering (the running key agent speaks protocol v1, this atem speaks v2"), - "{report}" + credentials.starts_with("unknown (key agent not answering: the running key agent speaks protocol v1, this atem speaks v2"), + "{credentials}" ); - assert!(report.contains("pkill"), "{report}"); - assert!(report.contains("retry"), "{report}"); - assert!(report.contains("Storage key: 0a1b2c3d"), "{report}"); - assert!(matches!(agent_state(None), AgentState::NotRunning)); + assert!(credentials.contains("pkill"), "{credentials}"); + assert!(credentials.contains("retry"), "{credentials}"); } #[test] diff --git a/src/memory/key_agent.rs b/src/memory/key_agent.rs index e6161f0..48333f6 100644 --- a/src/memory/key_agent.rs +++ b/src/memory/key_agent.rs @@ -68,7 +68,7 @@ pub fn build_id() -> &'static str { } /// Appended to key-file errors, which never stop the agent (it starts locked). -pub const RESET: &str = "To start over: delete ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json, then run `atem pair`. device_keys.sealed also holds the memory encryption key and its older keys: Astation grants the current key again, older keys may not come back."; +pub const RESET: &str = "To start over: delete ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json, then run `atem pair verify`. device_keys.sealed also holds the memory encryption key and its older keys: Astation grants the current key again, older keys may not come back."; /// What the agent serves. Only the same user can reach it (agent_socket.rs). /// Fields that carry secrets are `Zeroizing`. @@ -872,7 +872,7 @@ impl KeyAgent { fn home(&self, astation_id: &str) -> Result { let store = TrustStore::load_from(&self.paths.trust)?; let home = store.home().ok_or_else(|| { - anyhow!("this device has no home Astation yet; run `atem pair` to verify it") + anyhow!("this device has no home Astation yet; run `atem pair verify` to verify it") })?; if home != astation_id { bail!( @@ -882,7 +882,7 @@ impl KeyAgent { store .verified(home) .cloned() - .ok_or_else(|| anyhow!("the home Astation's pins are missing; run `atem pair`")) + .ok_or_else(|| anyhow!("the home Astation's pins are missing; run `atem pair verify`")) } fn begin_unlock(&mut self, astation_id: &str) -> Result { @@ -909,7 +909,7 @@ impl KeyAgent { (Some(file), _) => file, (None, Some(error)) => return Err(error), (None, None) => { - bail!("this device has no sealed keys; run `atem pair` to verify it") + bail!("this device has no sealed keys; run `atem pair verify` to verify it") } }; if sealed.device_id != trust.device_id { diff --git a/src/memory/trust.rs b/src/memory/trust.rs index a114123..104c557 100644 --- a/src/memory/trust.rs +++ b/src/memory/trust.rs @@ -65,7 +65,7 @@ pub struct TrustStore { escrow_unanswered: bool, /// The home Astation answered an unlock with "denied and revoked": it /// dropped this device's storage key, so these keys can never unlock - /// again. `atem pair` then sets them aside and verifies with new ones. + /// again. `atem unpair` then sets them aside; `atem pair` verifies new ones. #[serde(default, skip_serializing_if = "Option::is_none")] revoked: Option, #[serde(default)] @@ -477,14 +477,14 @@ impl TrustStore { pub fn verification_line(&self, astation_id: &str) -> String { if let Some(revoked) = &self.revoked { return format!( - "Verified: revoked by Astation {} ({}); run 'atem pair' to verify this device again", + "Verified: revoked by Astation {} ({}); run 'atem unpair', then 'atem pair', to set this device up again", revoked.astation_id, crate::config::format_unix_timestamp_hhmm_pub(revoked.at) ); } match self.verified(astation_id) { Some(trust) => format!("Verified: yes (safety code {})", trust.safety_code), - None => "Verified: no (run 'atem pair' to verify this device)".to_string(), + None => "Verified: no (run 'atem pair verify' to verify this device)".to_string(), } } } @@ -867,7 +867,7 @@ mod tests { ); assert_eq!( loaded.verification_line("other"), - "Verified: no (run 'atem pair' to verify this device)" + "Verified: no (run 'atem pair verify' to verify this device)" ); assert_eq!( loaded.verification_status(ASTATION), diff --git a/src/memory/unlock.rs b/src/memory/unlock.rs index 1c24d93..4606f89 100644 --- a/src/memory/unlock.rs +++ b/src/memory/unlock.rs @@ -12,7 +12,7 @@ use std::time::Duration; use crate::memory::device_keys::UnlockAuthKey; use crate::memory::encoding::dec; use crate::memory::key_agent::{ - AgentStatus, KeyAgentApi, LOCKED, RESET, blocking, build_unlock_request, default_agent, + KeyAgentApi, LOCKED, RESET, blocking, build_unlock_request, default_agent, running_agent, }; use crate::memory::statements::{SignedWire, StorageRotate}; @@ -324,9 +324,10 @@ pub(crate) async fn unlock_via( bail!("Astation denied the unlock: {}", shown(&reason)); } // Unsigned (it came over the relay), so all it may cause is a - // note in cred_state.json; `atem pair` then sets the old key files - // aside (renamed, never deleted) and verifies with new keys and a - // new safety code. A forged one costs a re-pair, never keys or trust. + // note in cred_state.json; `atem unpair` then sets the old key + // files aside (renamed, never deleted) and `atem pair` verifies with + // new keys and a new safety code. A forged one costs a re-pair, + // never keys or trust. let (path, astation_id, at) = (paths.trust.clone(), astation_id.to_string(), now_secs()); let recorded = blocking(move || { @@ -341,7 +342,7 @@ pub(crate) async fn unlock_via( Err(error) => format!(" (couldn't record it: {error:#})"), }; bail!( - "Astation denied the unlock: {}; this device was revoked; run `atem pair` to verify it again{note}", + "Astation denied the unlock: {}; this device was revoked; run `atem unpair`, then `atem pair`, to set it up again{note}", shown(&reason) ) } @@ -613,7 +614,7 @@ pub(crate) async fn pair_escrow( pub fn escrow_failure_message(error: &anyhow::Error) -> String { if error.downcast_ref::().is_some() { return format!( - "Astation didn't answer the storage-key handover ({error:#}); it may not support storage keys yet. This device keeps its keys in the plain ~/.config/atem/device_keys file (re-sealed whenever the key agent starts) and hands the key over at the next `atem pair` or `atem cred unlock` once Astation does, which deletes that file." + "Astation didn't answer the storage-key handover ({error:#}); it may not support storage keys yet. This device keeps its keys in the plain ~/.config/atem/device_keys file (re-sealed whenever the key agent starts) and hands the key over at the next `atem cred unlock` once Astation does, which deletes that file." ); } format!( @@ -657,94 +658,6 @@ pub(crate) async fn unlock_and_rotate( request_missing_key(link, agent, paths, home).await } -/// What `atem cred status` learned about the key agent. -pub enum AgentState { - NotRunning, - Running(AgentStatus), - Unreachable(String), -} - -/// The running agent's status. An agent that can't answer (an older one -/// speaking another protocol version, say) is reported, not an error: the -/// report carries its message and what to do about it. -pub fn agent_state(agent: Option<&dyn KeyAgentApi>) -> AgentState { - match agent { - None => AgentState::NotRunning, - Some(agent) => match agent.status() { - Ok(status) => AgentState::Running(status), - Err(error) => AgentState::Unreachable(format!("{error:#}")), - }, - } -} - -/// `atem cred status`, for the paired Astation `astation_id`. `key_problem` -/// (from `key_file_problem`) adds the reset instructions. -pub fn status_report( - trust: &TrustStore, - astation_id: &str, - agent: &AgentState, - sealed_kid: Option<&str>, - key_problem: Option<&str>, -) -> String { - let (agent_line, storage_kid, waiting) = match agent { - // Without the agent, only cred_state.json tells whether Astation - // confirmed holding the sealed file's key (until it does, the plain - // device_keys file stays and the next agent re-seals it). - AgentState::NotRunning => ( - "not running (starts with 'atem cred unlock')".to_string(), - sealed_kid.map(str::to_string), - sealed_kid.is_some_and(|kid| trust.escrowed_kid() != Some(kid)), - ), - AgentState::Unreachable(error) => ( - format!("not answering ({error})"), - sealed_kid.map(str::to_string), - sealed_kid.is_some_and(|kid| trust.escrowed_kid() != Some(kid)), - ), - AgentState::Running(status) if status.unlocked => ( - "unlocked".to_string(), - status.storage_kid.clone(), - !status.escrowed, - ), - // A locked agent vouches for nothing: cred_state.json decides, as - // when the agent isn't running. - AgentState::Running(status) => ( - "locked (run 'atem cred unlock')".to_string(), - status.storage_kid.clone(), - status - .storage_kid - .as_deref() - .is_some_and(|kid| trust.escrowed_kid() != Some(kid)), - ), - }; - let storage_line = match (storage_kid, waiting) { - // The first escrow went unanswered: most likely an Astation without - // storage-key support, where `atem cred unlock` can't help yet. - (Some(kid), true) if trust.escrow_unanswered() => format!( - "Storage key: {kid} (not yet held by Astation: it hasn't answered storage-key requests, so it may not support them yet; the key is handed over at the next 'atem pair' or 'atem cred unlock' once it does, and until then this device keeps its plain device_keys file)" - ), - (Some(kid), true) => { - format!("Storage key: {kid} (not yet held by Astation; run 'atem cred unlock')") - } - (Some(kid), false) => format!("Storage key: {kid}"), - (None, _) => "Storage key: none".to_string(), - }; - let agent_line = match agent { - AgentState::Running(status) if status.older_build => { - format!("{agent_line} (started by another atem build; run `atem cred lock`, then `atem cred unlock`)") - } - _ => agent_line, - }; - let mut report = format!( - "{}\nHome Astation: {}\nKey agent: {agent_line}\n{storage_line}\n", - trust.verification_line(astation_id), - trust.home().unwrap_or("none (verify with 'atem pair')"), - ); - if let Some(problem) = key_problem { - report.push_str(&format!("Key files: {problem}\n{RESET}\n")); - } - report -} - /// `atem cred lock`: what happened, or why the agent couldn't be reached. /// The agent wipes its keys and exits; the next `atem cred unlock` starts /// this build's. @@ -764,10 +677,22 @@ pub fn lock_keys_message(agent: Option<&dyn KeyAgentApi>) -> Result { } } -/// `atem cred โ€ฆ` (tier 2: needs a pairing with Astation). +/// `atem cred status`: exactly what `atem status` prints (blocking: may ask +/// a running key agent). +pub fn cred_status_text(src: &crate::status::Sources) -> String { + crate::status::text(src) +} + +/// `atem cred โ€ฆ` (tier 2: needs a pairing with Astation; `status`, the +/// `atem status` alias, works on any profile). pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { use crate::cli::CredCommands; - let paired = crate::auth::require_pairing("atem cred")?; + if let CredCommands::Status = command { + let src = crate::status::Sources::current()?; + print!("{}", blocking(move || Ok(cred_status_text(&src))).await?); + return Ok(()); + } + let _paired = crate::auth::require_pairing("atem cred")?; let paths = KeyPaths::default_paths(); match command { CredCommands::Projects => { @@ -782,28 +707,10 @@ pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { println!("Sent project suggestions to Astation."); Ok(()) } - CredCommands::Status => { - let report = blocking(move || { - let trust = TrustStore::load_from(&paths.trust)?; - let sealed_kid = SealedDeviceKeys::load_from(&paths.device_keys_sealed) - .ok() - .flatten() - .map(|sealed| sealed.storage_kid); - let problem = key_file_problem(&paths, &trust); - let agent = agent_state(running_agent().as_deref()); - Ok(status_report( - &trust, - &paired.astation_id, - &agent, - sealed_kid.as_deref(), - problem.as_deref(), - )) - }) - .await?; - print!("{report}"); - Ok(()) + CredCommands::Status => unreachable!("handled above"), + CredCommands::Unlock => { + unlock_command(&paths, &crate::config::AtemConfig::load()?).await } - CredCommands::Unlock => unlock_command(&paths).await, CredCommands::Lock => { let message = blocking(|| lock_keys_message(running_agent().as_deref())).await?; println!("{message}"); @@ -812,39 +719,50 @@ pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { } } -/// Connects to the home Astation without pairing: locally when the local -/// Astation is the home one, else through its relay identity room. +/// Connects to the home Astation without pairing. async fn connect_home(home: &str) -> Result { - let config = crate::config::AtemConfig::load()?; + connect_astation(&crate::config::AtemConfig::load()?, home, "this device's home Astation").await +} + +/// Connects to `astation_id` (`what` names it in errors) with the saved +/// session, never pairing: locally when the local Astation is that one, +/// else through its relay identity room. +pub(crate) async fn connect_astation( + config: &crate::config::AtemConfig, + astation_id: &str, + what: &str, +) -> Result { let mut local = AstationClient::new(); if local .connect_without_pairing(config.astation_ws()) .await .is_ok() - && local.connected_astation_id() == Some(home) + && local.connected_astation_id() == Some(astation_id) { return Ok(local); } drop(local); let mut client = AstationClient::new(); client - .connect_relay_identity_without_pairing(config.astation_relay_url(), home) + .connect_relay_identity_without_pairing(config.astation_relay_url(), astation_id) .await - .with_context(|| format!("couldn't reach this device's home Astation ({home})"))?; - if client.connected_astation_id() != Some(home) { - bail!("connected to a different Astation than this device's home ({home})"); + .with_context(|| format!("couldn't reach {what} ({astation_id})"))?; + if client.connected_astation_id() != Some(astation_id) { + bail!("connected to a different Astation than {what} ({astation_id})"); } Ok(client) } -async fn unlock_command(paths: &KeyPaths) -> Result<()> { +/// `atem cred unlock`: never pairs or verifies; an unverified device stops +/// here, offline. +pub(crate) async fn unlock_command(paths: &KeyPaths, config: &crate::config::AtemConfig) -> Result<()> { let trust = TrustStore::load_from(&paths.trust)?; let home = match (trust.home(), trust.recorded_home()) { (Some(home), _) => home.to_string(), (None, Some(stale)) => bail!( "This device's home Astation ({stale}) no longer verifies it, so its keys can't be unlocked. {RESET}" ), - (None, None) => bail!("This device isn't verified yet. Run `atem pair` first."), + (None, None) => bail!(crate::status::UNVERIFIED_UNLOCK), }; let agent: Arc = Arc::from(default_agent()); let nothing_to_do = { @@ -857,12 +775,12 @@ async fn unlock_command(paths: &KeyPaths) -> Result<()> { // Astation sends the signed state on connect; applying it finishes // the migration and sends the signed completion report. println!("Finishing the encryption migration with Astationโ€ฆ"); - let mut client = connect_home(&home).await?; + let mut client = connect_astation(config, &home, "this device's home Astation").await?; finish_migration(&mut client, MIGRATION_WAIT).await; } return Ok(()); } - let mut client = connect_home(&home).await?; + let mut client = connect_astation(config, &home, "this device's home Astation").await?; unlock_and_rotate(&mut client, &agent, paths, &home, UNLOCK_TIMEOUT).await?; // The report sent on connect was skipped (the keys were still locked); // send it now, on the same connection. Best effort, like on connect. @@ -989,7 +907,7 @@ mod tests { ASTATION_ID, DEVICE_ID, FakeKeyServer, captured_rotation, sealed_device, set_state, }; use crate::memory::grant::seal_k_grant; - use crate::memory::key_agent::{AgentStatus, RESET}; + use crate::memory::key_agent::RESET; use crate::memory::key_agent::{error_of, test_agent}; use crate::memory::statements::AccountState; use crate::memory::statements::{FakeAstation, SignedWire, StorageAck}; @@ -1429,7 +1347,7 @@ mod tests { let error = error_of(unlock_via(&mut link, &agent, &paths, ASTATION_ID, WAIT).await); assert!(error.contains(reason), "{error}"); assert!( - error.contains("this device was revoked; run `atem pair` to verify it again"), + error.contains("this device was revoked; run `atem unpair`, then `atem pair`, to set it up again"), "{error}" ); let trust = TrustStore::load_from(&paths.trust).unwrap(); @@ -1457,22 +1375,6 @@ mod tests { ); } - #[test] - fn status_shows_a_recorded_revocation() { - let dir = tempfile::tempdir().unwrap(); - let (paths, _server, _) = sealed_device(dir.path(), "0a1b2c3d"); - let mut trust = TrustStore::load_from(&paths.trust).unwrap(); - // 2025-04-01 08:50 UTC - trust.record_revoked(ASTATION_ID, 1743497400); - let report = status_report(&trust, ASTATION_ID, &AgentState::NotRunning, None, None); - assert!( - report.starts_with( - "Verified: revoked by Astation astation-1 (2025-04-01 08:50 UTC); run 'atem pair' to verify this device again\n" - ), - "{report}" - ); - } - #[tokio::test] async fn a_silent_astation_is_an_error() { let dir = tempfile::tempdir().unwrap(); @@ -1687,17 +1589,6 @@ mod tests { (paths, server, agent) } - /// `atem cred status` for `paths` with the agent as it is now. - fn status_now(paths: &KeyPaths, agent: &Arc) -> String { - status_report( - &TrustStore::load_from(&paths.trust).unwrap(), - ASTATION_ID, - &AgentState::Running(agent.status().unwrap()), - None, - None, - ) - } - #[tokio::test] async fn status_says_an_astation_that_never_answered_the_first_escrow_gets_it_later() { let dir = tempfile::tempdir().unwrap(); @@ -1709,21 +1600,17 @@ mod tests { let wait = Duration::from_millis(50); let error = error_of(rotate_via(&mut link, &agent, &paths, ASTATION_ID, wait).await); assert!(error.contains("timed out"), "{error}"); - let report = status_now(&paths, &agent); assert!( - report.contains("Storage key: 0a1b2c3d (not yet held by Astation: it hasn't answered"), - "{report}" + TrustStore::load_from(&paths.trust) + .unwrap() + .escrow_unanswered() ); - assert!(report.contains("once it does"), "{report}"); - assert!(!report.contains("run 'atem cred unlock'"), "{report}"); // Once an Astation takes it, the note goes. link.silent = false; link.hang = false; rotate_via(&mut link, &agent, &paths, ASTATION_ID, WAIT) .await .unwrap(); - let report = status_now(&paths, &agent); - assert!(!report.contains("not yet held"), "{report}"); assert!( !TrustStore::load_from(&paths.trust) .unwrap() @@ -1756,17 +1643,7 @@ mod tests { // it can't. let trust = TrustStore::load_from(&paths.trust).unwrap(); assert!(!trust.escrow_unanswered()); - let waiting = AgentState::Running(AgentStatus { - unlocked: true, - storage_kid: Some("4e5f6a7b".into()), - escrowed: false, - older_build: false, - }); - let report = status_report(&trust, ASTATION_ID, &waiting, None, None); - assert!( - report.contains("(not yet held by Astation; run 'atem cred unlock')"), - "{report}" - ); + assert_eq!(trust.escrowed_kid(), Some("0a1b2c3d")); } #[test] @@ -1912,96 +1789,11 @@ mod tests { assert_eq!(link.abandons, 0); } - #[test] - fn status_report_shows_each_agent_state() { - let dir = tempfile::tempdir().unwrap(); - let (paths, _server, _) = sealed_device(dir.path(), "0a1b2c3d"); - let trust = TrustStore::load_from(&paths.trust).unwrap(); - assert_eq!( - status_report( - &trust, - ASTATION_ID, - &AgentState::NotRunning, - Some("0a1b2c3d"), - None - ), - "Verified: yes (safety code AAAA-BBBB-CCCC)\n\ - Home Astation: astation-1\n\ - Key agent: not running (starts with 'atem cred unlock')\n\ - Storage key: 0a1b2c3d\n" - ); - let waiting = AgentState::Running(AgentStatus { - unlocked: true, - storage_kid: Some("4e5f6a7b".into()), - escrowed: false, - older_build: false, - }); - let report = status_report(&trust, ASTATION_ID, &waiting, None, None); - assert!(report.contains("Key agent: unlocked\n"), "{report}"); - assert!( - report.contains( - "Storage key: 4e5f6a7b (not yet held by Astation; run 'atem cred unlock')" - ), - "{report}" - ); - let locked = AgentState::Running(AgentStatus { - unlocked: false, - storage_kid: Some("0a1b2c3d".into()), - escrowed: false, - older_build: false, - }); - let report = status_report(&trust, ASTATION_ID, &locked, None, None); - assert!( - report.contains("Key agent: locked (run 'atem cred unlock')"), - "{report}" - ); - assert!(report.contains("Storage key: 0a1b2c3d\n"), "{report}"); - // A locked agent's sealed file under a key Astation never confirmed - // is flagged, as when the agent isn't running. - let unconfirmed = AgentState::Running(AgentStatus { - unlocked: false, - storage_kid: Some("9a9b9c9d".into()), - escrowed: false, - older_build: false, - }); - let report = status_report(&trust, ASTATION_ID, &unconfirmed, None, None); - assert!( - report.contains("Storage key: 9a9b9c9d (not yet held by Astation"), - "{report}" - ); - let broken = AgentState::Unreachable("protocol v2".into()); - assert!( - status_report(&trust, ASTATION_ID, &broken, Some("0a1b2c3d"), None) - .contains("Key agent: not answering (protocol v2)") - ); - let report = status_report( - &TrustStore::default(), - ASTATION_ID, - &AgentState::NotRunning, - None, - None, - ); - assert!(report.starts_with("Verified: no"), "{report}"); - assert!(report.contains("Home Astation: none (verify with 'atem pair')")); - assert!(report.contains("Storage key: none")); - let report = status_report( - &trust, - ASTATION_ID, - &AgentState::NotRunning, - None, - Some("unlock_auth_key is missing"), - ); - assert!( - report.ends_with(&format!("Key files: unlock_auth_key is missing\n{RESET}\n")), - "{report}" - ); - } - #[test] fn the_reset_names_every_key_file_and_the_trust_state() { assert_eq!( RESET, - "To start over: delete ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json, then run `atem pair`. device_keys.sealed also holds the memory encryption key and its older keys: Astation grants the current key again, older keys may not come back." + "To start over: delete ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json, then run `atem pair verify`. device_keys.sealed also holds the memory encryption key and its older keys: Astation grants the current key again, older keys may not come back." ); } @@ -2205,23 +1997,6 @@ mod tests { assert_eq!(shown(text), "abcdef"); } - #[test] - fn status_without_the_agent_flags_a_storage_key_astation_never_confirmed() { - let dir = tempfile::tempdir().unwrap(); - let (paths, _server, _) = sealed_device(dir.path(), "0a1b2c3d"); - let mut trust = TrustStore::load_from(&paths.trust).unwrap(); - trust.set_escrowed_kid("ffffffff"); - for agent in [AgentState::NotRunning, AgentState::Unreachable("v2".into())] { - let report = status_report(&trust, ASTATION_ID, &agent, Some("0a1b2c3d"), None); - assert!( - report.contains( - "Storage key: 0a1b2c3d (not yet held by Astation; run 'atem cred unlock')" - ), - "{report}" - ); - } - } - #[tokio::test] async fn unlock_has_nothing_to_ask_only_when_unlocked_escrowed_and_settled() { let dir = tempfile::tempdir().unwrap(); @@ -2277,32 +2052,6 @@ mod tests { ); } - #[test] - fn status_flags_an_agent_of_an_older_build() { - let trust = TrustStore::default(); - for unlocked in [true, false] { - let older = AgentState::Running(AgentStatus { - unlocked, - storage_kid: Some("0a1b2c3d".into()), - escrowed: true, - older_build: true, - }); - let report = status_report(&trust, ASTATION_ID, &older, None, None); - assert!( - report.contains("(started by another atem build; run `atem cred lock`, then `atem cred unlock`)\n"), - "{report}" - ); - } - let current = AgentState::Running(AgentStatus { - unlocked: true, - storage_kid: Some("0a1b2c3d".into()), - escrowed: true, - older_build: false, - }); - let report = status_report(&trust, ASTATION_ID, ¤t, None, None); - assert!(!report.contains("another atem build"), "{report}"); - } - #[test] fn boot_id_is_trimmed_or_empty() { let id = boot_id(); diff --git a/src/memory/verification.rs b/src/memory/verification.rs index 1280a2e..1215ba2 100644 --- a/src/memory/verification.rs +++ b/src/memory/verification.rs @@ -484,7 +484,7 @@ pub fn complete_verification( VerificationKeys::Fresh(_) => { if has_device_keys(paths) { bail!( - "this device already has keys; verify with them (run `atem cred unlock`, then `atem pair` again)" + "this device already has keys; verify with them (run `atem cred unlock`, then `atem pair verify`)" ); } if staged.home_is_set() && staged.home() != Some(astation_id) { @@ -497,7 +497,7 @@ pub fn complete_verification( // Sealed keys never leave the agent: it must hold exactly these. VerificationKeys::Sealed(publics) => { if !agent.status()?.unlocked { - bail!("{LOCKED}, then `atem pair` again"); + bail!("{LOCKED}, then `atem pair verify`"); } if agent.public_keys()? != (publics.device_pub, publics.device_sign_pub) { bail!("the key agent holds other device keys than this verification revealed"); @@ -691,7 +691,7 @@ pub fn key_needed(paths: &KeyPaths, agent: &dyn KeyAgentApi, astation_id: &str) /// Whether this device has keys on disk: a plain `device_keys` file, or any /// sealed file (the current one, or a rotation's `.next` / `.prev`). -fn has_device_keys(paths: &KeyPaths) -> bool { +pub(crate) fn has_device_keys(paths: &KeyPaths) -> bool { [ &paths.device_keys, &paths.device_keys_sealed, @@ -703,7 +703,8 @@ fn has_device_keys(paths: &KeyPaths) -> bool { } /// When the home Astation revoked this device (recorded by `atem cred -/// unlock`) and the key agent is locked, its keys can never unlock again: +/// unlock`) and the key agent (`agent`, the running one; `None` when none +/// runs) is locked, its keys can never unlock again (`atem unpair` calls this): /// renames every key file to `.revoked-` (never deletes), /// forgets that Astation's verification, the home and the escrow state, so /// `device_keys_for_verification` then gives fresh keys. Returns the line to @@ -714,12 +715,20 @@ fn has_device_keys(paths: &KeyPaths) -> bool { /// code the user compares, so a forged "revoked" can cost a re-pair but /// never keys (the files stay on disk) or trust (nothing new is trusted /// without the ceremony). Blocking: call it inside `key_agent::blocking`. -pub fn set_aside_revoked_keys(paths: &KeyPaths, agent: &dyn KeyAgentApi) -> Result> { +pub fn set_aside_revoked_keys( + paths: &KeyPaths, + agent: Option<&dyn KeyAgentApi>, +) -> Result> { let trust = TrustStore::load_from(&paths.trust)?; let Some(revoked) = trust.revoked() else { return Ok(None); }; - if trust.recorded_home() != Some(revoked.astation_id.as_str()) || agent.status()?.unlocked { + if trust.recorded_home() != Some(revoked.astation_id.as_str()) { + return Ok(None); + } + if let Some(agent) = agent + && agent.status()?.unlocked + { return Ok(None); } let stamp = std::time::SystemTime::now() @@ -740,7 +749,7 @@ pub fn set_aside_revoked_keys(paths: &KeyPaths, agent: &dyn KeyAgentApi) -> Resu let name = path.file_name().unwrap_or_default().to_string_lossy(); let aside = path.with_file_name(format!("{name}.revoked-{stamp}")); if aside.symlink_metadata().is_ok() { - bail!("{} already exists; move it away and run `atem pair` again", aside.display()); + bail!("{} already exists; move it away and run `atem unpair` again", aside.display()); } std::fs::rename(path, &aside) .with_context(|| format!("couldn't move {} aside", path.display()))?; @@ -750,7 +759,7 @@ pub fn set_aside_revoked_keys(paths: &KeyPaths, agent: &dyn KeyAgentApi) -> Resu let by = revoked.map_or_else(String::new, |revoked| format!(" by Astation {}", revoked.astation_id)); let dir = paths.trust.parent().map(|dir| dir.display().to_string()).unwrap_or_default(); Ok(Some(format!( - "This device was revoked{by}: its old keys were moved aside ({} renamed to *.revoked-{stamp} in {dir}); verifying it with new keys.", + "This device was revoked{by}: its old keys were moved aside ({} renamed to *.revoked-{stamp} in {dir}); the next `atem pair` verifies it with new keys.", if moved.is_empty() { "no key files".to_string() } else { moved.join(", ") } ))) } @@ -767,7 +776,7 @@ pub fn device_keys_for_verification( return Ok(VerificationKeys::Fresh(DeviceKeys::generate())); } if !agent.status()?.unlocked { - bail!("{LOCKED}, then `atem pair` again"); + bail!("{LOCKED}, then `atem pair verify`"); } let (device_pub, device_sign_pub) = agent.public_keys()?; let unlock_auth = UnlockAuthKey::load_from(&paths.unlock_auth_key)?.ok_or_else(|| { @@ -1811,7 +1820,7 @@ mod ceremony_tests { .unwrap(); // After a reboot the agent is locked, and Astation won't unlock it. let rebooted = test_agent(&paths); - let notice = set_aside_revoked_keys(&paths, &rebooted) + let notice = set_aside_revoked_keys(&paths, Some(&rebooted)) .unwrap() .expect("the old keys are moved aside"); assert!(notice.contains("moved aside"), "{notice}"); @@ -1866,7 +1875,7 @@ mod ceremony_tests { verified_device(&paths, &agent, &fake, EncryptionMode::Off, None); escrow_first(&agent, &fake, &certificate, outcome); let rebooted = test_agent(&paths); - assert_eq!(set_aside_revoked_keys(&paths, &rebooted).unwrap(), None); + assert_eq!(set_aside_revoked_keys(&paths, Some(&rebooted)).unwrap(), None); let error = error_of(device_keys_for_verification(&paths, &rebooted)); assert!(error.contains("atem cred unlock"), "{error}"); // Revoked by an Astation that isn't the home: nothing moves either. @@ -1875,14 +1884,14 @@ mod ceremony_tests { Ok(()) }) .unwrap(); - assert_eq!(set_aside_revoked_keys(&paths, &rebooted).unwrap(), None); + assert_eq!(set_aside_revoked_keys(&paths, Some(&rebooted)).unwrap(), None); // An unlocked agent verifies with the keys it holds, as before. TrustStore::update(&paths.trust, |store| { store.record_revoked(ASTATION_ID, 1_700_000_000); Ok(()) }) .unwrap(); - assert_eq!(set_aside_revoked_keys(&paths, &agent).unwrap(), None); + assert_eq!(set_aside_revoked_keys(&paths, Some(&agent)).unwrap(), None); assert!(matches!( device_keys_for_verification(&paths, &agent).unwrap(), VerificationKeys::Sealed(_) diff --git a/src/pair_verify.rs b/src/pair_verify.rs index 58759c9..ab80162 100644 --- a/src/pair_verify.rs +++ b/src/pair_verify.rs @@ -1,4 +1,4 @@ -//! `atem pair` device verification: the safety-code confirmation (read from +//! `atem pair` and `atem pair verify` device verification: the safety-code confirmation (read from //! the terminal, never from stdin) and what happens when verification fails //! (the pending pin is dropped and Astation is told, unless it cancelled). @@ -201,7 +201,7 @@ pub(crate) fn code_failure(answer: CodeAnswer) -> Option { CodeAnswer::NoTerminal(detail) => Some(VerifyFailure::local( "no terminal to compare the code", anyhow!( - "Device verification needs a terminal to compare the safety code ({detail}); run `atem pair` in an interactive terminal." + "Device verification needs a terminal to compare the safety code ({detail}); run `atem pair verify` in an interactive terminal." ), )), } @@ -310,7 +310,7 @@ pub(crate) async fn request_key_after_verification( } } -/// `atem pair` after the pairing was saved: a failed verification is shown +/// `atem pair` after the pairing was saved, and `atem pair verify`: a failed verification is shown /// in full here, then reported as a short error so the exit status is /// nonzero. pub(crate) fn verification_result(verification: Result<()>) -> Result<()> { @@ -318,7 +318,7 @@ pub(crate) fn verification_result(verification: Result<()>) -> Result<()> { Ok(()) => Ok(()), Err(error) => { eprintln!("\u{26a0}\u{fe0f} {error}"); - eprintln!("Pairing is saved. Run 'atem pair' again to verify this device."); + eprintln!("Pairing is saved. Run 'atem pair verify' to verify this device."); Err(anyhow!("device verification failed")) } } @@ -441,7 +441,7 @@ mod tests { ); let message = failure.error().to_string(); assert!(message.contains("needs a terminal"), "{message}"); - assert!(message.contains("run `atem pair` in an interactive terminal")); + assert!(message.contains("run `atem pair verify` in an interactive terminal")); } #[test] diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..d7c0635 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,663 @@ +//! `atem status` and its `atem cred status` alias: one read-only, offline +//! report of the account, the pairing, this device's verification and its +//! credentials. It never connects anywhere, never starts the key agent (it +//! asks only one that is already running) and never writes a file. Remote +//! facts it can't check offline are shown as unknown and stale. +//! +//! The same facts decide `atem pair` (a no-op once paired), `atem pair +//! verify` (only for an existing pairing, never after a revocation) and the +//! `atem cred unlock` gate. +use anyhow::{Result, anyhow}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use crate::auth::SessionManager; +use crate::config::AtemConfig; +use crate::credentials::{CredentialEntry, CredentialSource, CredentialStore}; +use crate::memory::key_agent::KeyAgentApi; +use crate::memory::trust::TrustStore; +use crate::memory::verification::KeyPaths; + +/// `atem pair` on a profile that is already paired (after the status). +pub const ALREADY_PAIRED: &str = "Already paired. Verify this device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair"; +/// `atem pair verify` without a pairing. +pub const NOT_PAIRED: &str = "Not paired. Run atem pair first."; +/// `atem cred unlock` on a device no Astation verified. +pub const UNVERIFIED_UNLOCK: &str = "This device isn't verified. Run atem pair verify first."; + +/// `atem pair verify` on a device the Astation revoked. +pub fn revoked_message(astation_id: &str) -> String { + format!( + "This device was revoked by Astation {astation_id}. Run atem unpair, then atem pair, to set it up again." + ) +} + +/// `atem pair verify` when the paired Astation already verified this device. +pub fn already_verified_message(safety_code: &str) -> String { + format!("Already verified (safety code {safety_code}).") +} + +/// One status value; `stale` marks cached or unchecked remote information. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Line { + pub value: String, + pub stale: bool, +} + +impl Line { + fn fresh(value: impl Into) -> Self { + Self { + value: value.into(), + stale: false, + } + } + + fn stale(value: impl Into) -> Self { + Self { + value: value.into(), + stale: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusReport { + pub account: Line, + pub pairing: Line, + pub verification: Line, + pub credentials: Line, + pub authorization: Line, + pub relay: Line, + pub local_agents: Line, +} + +/// Where the status is read from. `current()` is this user's profile; +/// `in_dir` a profile directory (tests). +#[derive(Debug, Clone)] +pub struct Sources { + /// Connection settings (`pair` and `pair verify` connect with them; + /// `astation_relay_code` selects the Astation `require_pairing` uses). + pub config: AtemConfig, + pub credentials: PathBuf, + pub sessions: PathBuf, + pub keys: KeyPaths, + /// Ask an already running key agent whether the keys are unlocked + /// (never starts one). Off in tests, so they never reach the user's agent. + pub query_agent: bool, +} + +impl Sources { + /// This user's profile: `config.toml` with its env overrides, the + /// credential store, the saved sessions and the key files. + pub fn current() -> Result { + Ok(Self { + config: AtemConfig::load()?, + credentials: CredentialStore::path(), + sessions: SessionManager::sessions_path()?, + keys: KeyPaths::default_paths(), + query_agent: true, + }) + } + + /// A profile directory: its `config.toml` (no env overrides), and every + /// file in it. Never queries a key agent. + #[cfg(test)] + pub fn in_dir(dir: &Path) -> Self { + let config = std::fs::read_to_string(dir.join("config.toml")) + .ok() + .and_then(|content| toml::from_str(&content).ok()) + .unwrap_or_default(); + Self { + config, + credentials: dir.join("credentials.enc"), + sessions: dir.join("sessions.json"), + keys: KeyPaths::in_dir(dir), + query_agent: false, + } + } +} + +/// The Astation this profile is paired with: the one `require_pairing` +/// resolves when it does, else the first paired one by id; `others` counts +/// the other paired Astations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pairing { + pub astation_id: String, + pub others: usize, +} + +/// The saved sessions, read without touching the file (no permission repair). +fn saved_sessions(path: &Path) -> SessionManager { + std::fs::read(path) + .ok() + .and_then(|raw| serde_json::from_slice(&raw).ok()) + .unwrap_or_default() +} + +fn paired_entries(store: &CredentialStore) -> impl Iterator { + store + .entries + .iter() + .filter(|entry| entry.source == CredentialSource::AstationPaired) +} + +/// Whether this profile is paired: any paired credential entry or saved +/// (valid) Astation session. +pub fn pairing(src: &Sources) -> Option { + let store = CredentialStore::load_from(&src.credentials); + let sessions = saved_sessions(&src.sessions); + let mut ids: BTreeSet = paired_entries(&store) + .map(|entry| { + entry + .astation_id + .clone() + .unwrap_or_else(|| "unknown".into()) + }) + .collect(); + ids.extend( + sessions + .active_sessions() + .into_iter() + .map(|s| s.astation_id.clone()), + ); + // What `require_pairing` resolves: the configured Astation with a session. + let resolved = src + .config + .astation_relay_code + .as_deref() + .filter(|id| sessions.get(id).is_some()) + .map(str::to_string); + let astation_id = resolved.or_else(|| ids.iter().next().cloned())?; + ids.insert(astation_id.clone()); + Some(Pairing { + astation_id, + others: ids.len() - 1, + }) +} + +/// This device's verification with the paired Astation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verification { + Verified { + safety_code: String, + }, + NotVerified, + /// Revoked by this Astation (a revocation wins over any pin). + Revoked { + by: String, + }, + /// `cred_state.json` can't be read. + Unknown(String), +} + +pub fn verification(keys: &KeyPaths, astation_id: &str) -> Verification { + let trust = match TrustStore::load_from(&keys.trust) { + Ok(trust) => trust, + Err(error) => return Verification::Unknown(format!("{error:#}")), + }; + if let Some(revoked) = trust.revoked() { + return Verification::Revoked { + by: revoked.astation_id.clone(), + }; + } + match trust.verified(astation_id) { + Some(entry) => Verification::Verified { + safety_code: entry.safety_code.clone(), + }, + None => Verification::NotVerified, + } +} + +fn account_line(store: &CredentialStore, now: u64) -> Line { + Line::fresh(if store.entries.is_empty() { + "signed out" + } else if store.entries.iter().any(|entry| entry.expires_at > now) { + "signed in" + } else { + "signed in (expired)" + }) +} + +fn credentials_line(keys: &KeyPaths, agent: Option<&dyn KeyAgentApi>) -> Line { + let status = agent.map(|agent| agent.status()); + Line::fresh(match status { + Some(Ok(status)) if status.unlocked => "unlocked", + _ if !crate::memory::verification::has_device_keys(keys) => "not set up", + // No running agent holds them: locked. + None | Some(Ok(_)) => "locked", + Some(Err(error)) => { + return Line::fresh(format!("unknown (key agent not answering: {error:#})")); + } + }) +} + +/// The status from `src`, with `agent` the already running key agent (if any). +pub fn gather_with(src: &Sources, agent: Option<&dyn KeyAgentApi>) -> StatusReport { + let store = CredentialStore::load_from(&src.credentials); + let paired = pairing(src); + let (pairing_line, verification_line) = match &paired { + None => (Line::fresh("not paired"), Line::fresh("โ€”")), + Some(pairing) => { + let more = match pairing.others { + 0 => String::new(), + n => format!(" (+{n} more)"), + }; + let verification = match verification(&src.keys, &pairing.astation_id) { + Verification::Verified { safety_code } => { + format!("verified (safety code {safety_code})") + } + Verification::NotVerified => "not verified".into(), + Verification::Revoked { by } => format!("revoked by {by}"), + Verification::Unknown(error) => format!("unknown ({error})"), + }; + ( + Line::fresh(format!("paired ({}){more}", pairing.astation_id)), + Line::fresh(verification), + ) + } + }; + StatusReport { + account: account_line(&store, CredentialEntry::now_secs()), + pairing: pairing_line, + verification: verification_line, + credentials: credentials_line(&src.keys, agent), + // No freshness contract yet: never "fresh" without proof. + authorization: Line::stale("unknown"), + // Status never connects. + relay: Line::stale("unknown"), + // No shared runtime to ask yet. + local_agents: Line::fresh("unknown"), + } +} + +/// The status from `src`, asking an already running key agent when +/// `src.query_agent` (blocking: socket I/O). +pub fn gather(src: &Sources) -> StatusReport { + let agent = if src.query_agent { + crate::memory::key_agent::running_agent() + } else { + None + }; + gather_with(src, agent.as_deref()) +} + +/// The aligned block `atem status` prints. +pub fn render(report: &StatusReport) -> String { + let rows = [ + ("Account:", &report.account), + ("Pairing:", &report.pairing), + ("Verification:", &report.verification), + ("Credentials:", &report.credentials), + ("Authorization:", &report.authorization), + ("Relay:", &report.relay), + ("Local agents:", &report.local_agents), + ]; + let mut out = String::new(); + for (label, line) in rows { + let stale = if line.stale { " (stale)" } else { "" }; + out.push_str(&format!("{label:<16}{}{stale}\n", line.value)); + } + out +} + +/// `atem status` / `atem cred status` (blocking: may ask a running agent). +pub fn text(src: &Sources) -> String { + render(&gather(src)) +} + +/// `atem pair`: when the profile is already paired, what to print instead +/// of pairing (the status, then `ALREADY_PAIRED`). +pub fn already_paired(src: &Sources) -> Option { + pairing(src)?; + Some(format!("{}{ALREADY_PAIRED}\n", text(src))) +} + +/// What `atem pair verify` does before connecting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyStart { + /// Nothing to do: print this. + AlreadyVerified(String), + /// Run the verification with this Astation. + Verify(String), +} + +/// `atem pair verify`'s offline checks: an existing pairing, not revoked, +/// not already verified with the paired Astation. +pub fn verify_start(src: &Sources) -> Result { + let pairing = pairing(src).ok_or_else(|| anyhow!(NOT_PAIRED))?; + match verification(&src.keys, &pairing.astation_id) { + Verification::Verified { safety_code } => Ok(VerifyStart::AlreadyVerified( + already_verified_message(&safety_code), + )), + Verification::Revoked { by } => Err(anyhow!(revoked_message(&by))), + Verification::Unknown(error) => Err(anyhow!(error)), + Verification::NotVerified => Ok(VerifyStart::Verify(pairing.astation_id)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthSession; + use crate::memory::key_agent::{Reply, Request}; + use crate::memory::trust::AstationTrust; + + const ASTATION: &str = "astation-1"; + + struct UnlockedAgent; + + impl KeyAgentApi for UnlockedAgent { + fn call(&self, request: Request) -> Result { + match request { + Request::Status => Ok(Reply::Status { + unlocked: true, + storage_kid: Some("0a1b2c3d".into()), + escrowed: true, + build: None, + }), + _ => Err(anyhow!("not in this test")), + } + } + } + + struct LockedAgent; + + impl KeyAgentApi for LockedAgent { + fn call(&self, request: Request) -> Result { + match request { + Request::Status => Ok(Reply::Status { + unlocked: false, + storage_kid: None, + escrowed: false, + build: None, + }), + _ => Err(anyhow!("not in this test")), + } + } + } + + fn pin(code: &str) -> AstationTrust { + AstationTrust { + device_id: "device".into(), + data_account: "account".into(), + sign_gen: 1, + astation_sign_pub: "s".into(), + astation_enc_pub: "e".into(), + recovery_sign_pub: "r".into(), + device_pub: "d".into(), + device_sign_pub: "ds".into(), + unlock_auth_pub: "u".into(), + safety_code: code.into(), + transcript: String::new(), + epoch_floor: 0, + account_state: None, + account_epoch: 0, + } + } + + /// Writes `cred_state.json`: verified with `verified` (if any), revoked + /// by `revoked` (if any). + fn write_trust(dir: &Path, verified: Option<&str>, revoked: Option<&str>) { + let mut astations = serde_json::Map::new(); + if let Some(id) = verified { + astations.insert( + id.into(), + serde_json::to_value(pin("MN3H-A74N-FJE4")).unwrap(), + ); + } + let mut store = serde_json::json!({ + "version": 1, + "home_astation": verified, + "astations": astations, + }); + if let Some(id) = revoked { + store["revoked"] = serde_json::json!({"astation_id": id, "at": 1_700_000_000u64}); + } + std::fs::write(dir.join("cred_state.json"), store.to_string()).unwrap(); + } + + fn write_paired(dir: &Path, astation_id: &str, expires_at: u64) { + let mut store = CredentialStore::load_from(&dir.join("credentials.enc")); + store.upsert(CredentialEntry::new_paired( + "access".into(), + "refresh".into(), + expires_at, + Some("me".into()), + astation_id.into(), + true, + 1, + )); + store.save_to(&dir.join("credentials.enc")).unwrap(); + } + + fn write_session(dir: &Path, astation_id: &str) { + let path = dir.join("sessions.json"); + let mut sessions = saved_sessions(&path); + sessions.insert_session(AuthSession::new( + format!("session-{astation_id}"), + "token".into(), + astation_id.into(), + "host".into(), + )); + sessions.save_to(&path).unwrap(); + } + + fn write_device_keys(dir: &Path) { + std::fs::write(dir.join("device_keys.sealed"), b"{}").unwrap(); + } + + fn later() -> u64 { + CredentialEntry::now_secs() + 3600 + } + + fn report(dir: &Path) -> StatusReport { + gather_with(&Sources::in_dir(dir), None) + } + + #[test] + fn render_aligns_every_label_and_marks_stale_lines() { + let report = StatusReport { + account: Line::fresh("signed in"), + pairing: Line::fresh("paired (astation-1)"), + verification: Line::fresh("verified (safety code MN3H-A74N-FJE4)"), + credentials: Line::fresh("locked"), + authorization: Line::stale("unknown"), + relay: Line::stale("unknown"), + local_agents: Line::fresh("unknown"), + }; + assert_eq!( + render(&report), + "Account: signed in\n\ + Pairing: paired (astation-1)\n\ + Verification: verified (safety code MN3H-A74N-FJE4)\n\ + Credentials: locked\n\ + Authorization: unknown (stale)\n\ + Relay: unknown (stale)\n\ + Local agents: unknown\n" + ); + } + + #[test] + fn an_empty_profile_is_signed_out_unpaired_and_not_set_up() { + let dir = tempfile::tempdir().unwrap(); + let report = report(dir.path()); + assert_eq!(report.account.value, "signed out"); + assert_eq!(report.pairing.value, "not paired"); + assert_eq!(report.verification.value, "โ€”"); + assert_eq!(report.credentials.value, "not set up"); + assert_eq!(report.authorization, Line::stale("unknown")); + assert_eq!(report.relay, Line::stale("unknown")); + assert_eq!(report.local_agents, Line::fresh("unknown")); + assert_eq!(pairing(&Sources::in_dir(dir.path())), None); + } + + #[test] + fn paired_but_unverified() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + let report = report(dir.path()); + assert_eq!(report.account.value, "signed in"); + assert_eq!(report.pairing.value, "paired (astation-1)"); + assert_eq!(report.verification.value, "not verified"); + assert_eq!(report.credentials.value, "not set up"); + } + + #[test] + fn paired_and_verified_shows_the_safety_code_and_lock_state() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_trust(dir.path(), Some(ASTATION), None); + write_device_keys(dir.path()); + let src = Sources::in_dir(dir.path()); + let report = gather_with(&src, None); + assert_eq!( + report.verification.value, + "verified (safety code MN3H-A74N-FJE4)" + ); + // No running agent holds the keys. + assert_eq!(report.credentials.value, "locked"); + assert_eq!( + gather_with(&src, Some(&LockedAgent)).credentials.value, + "locked" + ); + assert_eq!( + gather_with(&src, Some(&UnlockedAgent)).credentials.value, + "unlocked" + ); + } + + #[test] + fn revoked_is_shown_with_the_revoking_astation() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_trust(dir.path(), Some(ASTATION), Some(ASTATION)); + assert_eq!( + report(dir.path()).verification.value, + "revoked by astation-1" + ); + } + + #[test] + fn expired_credentials_still_count_as_signed_in() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, 1); + assert_eq!(report(dir.path()).account.value, "signed in (expired)"); + } + + #[test] + fn a_saved_session_alone_is_a_pairing_and_the_configured_one_wins() { + let dir = tempfile::tempdir().unwrap(); + write_session(dir.path(), "astation-0"); + let src = Sources::in_dir(dir.path()); + assert_eq!( + pairing(&src), + Some(Pairing { + astation_id: "astation-0".into(), + others: 0 + }) + ); + write_paired(dir.path(), "astation-2", later()); + write_session(dir.path(), "astation-2"); + std::fs::write( + dir.path().join("config.toml"), + "astation_relay_code = \"astation-2\"\n", + ) + .unwrap(); + let src = Sources::in_dir(dir.path()); + assert_eq!( + pairing(&src), + Some(Pairing { + astation_id: "astation-2".into(), + others: 1 + }) + ); + assert_eq!( + report(dir.path()).pairing.value, + "paired (astation-2) (+1 more)" + ); + } + + #[test] + fn gathering_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_session(dir.path(), ASTATION); + write_trust(dir.path(), Some(ASTATION), None); + let before = snapshot(dir.path()); + let _ = text(&Sources::in_dir(dir.path())); + assert_eq!(snapshot(dir.path()), before); + } + + #[test] + fn already_paired_prints_the_status_and_the_next_steps() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(already_paired(&Sources::in_dir(dir.path())), None); + write_paired(dir.path(), ASTATION, later()); + let src = Sources::in_dir(dir.path()); + assert_eq!( + already_paired(&src).unwrap(), + format!("{}{ALREADY_PAIRED}\n", text(&src)) + ); + } + + #[test] + fn pair_verify_needs_a_pairing() { + let dir = tempfile::tempdir().unwrap(); + let error = verify_start(&Sources::in_dir(dir.path())).unwrap_err(); + assert_eq!(error.to_string(), "Not paired. Run atem pair first."); + } + + #[test] + fn pair_verify_does_nothing_when_already_verified() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_trust(dir.path(), Some(ASTATION), None); + assert_eq!( + verify_start(&Sources::in_dir(dir.path())).unwrap(), + VerifyStart::AlreadyVerified("Already verified (safety code MN3H-A74N-FJE4).".into()) + ); + } + + #[test] + fn pair_verify_refuses_a_revoked_device() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_trust(dir.path(), Some(ASTATION), Some(ASTATION)); + let error = verify_start(&Sources::in_dir(dir.path())).unwrap_err(); + assert_eq!( + error.to_string(), + "This device was revoked by Astation astation-1. Run atem unpair, then atem pair, to set it up again." + ); + } + + #[test] + fn pair_verify_verifies_the_paired_astation() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + // Verified with another Astation only. + write_trust(dir.path(), Some("astation-9"), None); + assert_eq!( + verify_start(&Sources::in_dir(dir.path())).unwrap(), + VerifyStart::Verify(ASTATION.into()) + ); + } + + /// Every file under `dir` with its contents (to prove nothing changed). + pub(crate) fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec)> { + let mut files = Vec::new(); + let mut pending = vec![dir.to_path_buf()]; + while let Some(next) = pending.pop() { + for entry in std::fs::read_dir(&next).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else { + let contents = std::fs::read(&path).unwrap(); + files.push((path, contents)); + } + } + } + files.sort(); + files + } +} diff --git a/src/tui/draw.rs b/src/tui/draw.rs index 190f3f7..4a3179e 100644 --- a/src/tui/draw.rs +++ b/src/tui/draw.rs @@ -105,8 +105,8 @@ pub(crate) fn paired_status_line( use crate::memory::trust::VerificationStatus; let suffix = match status { Some(VerificationStatus::Verified) => " \u{b7} verified", - Some(VerificationStatus::NotVerified) => " \u{b7} not verified \u{2014} run `atem pair`", - Some(VerificationStatus::Revoked) => " \u{b7} revoked \u{2014} run `atem pair`", + Some(VerificationStatus::NotVerified) => " \u{b7} not verified \u{2014} run `atem pair verify`", + Some(VerificationStatus::Revoked) => " \u{b7} revoked \u{2014} run `atem unpair`, then `atem pair`", None => "", }; format!("\u{1f517} Paired (SSO: {login_id}){suffix}") @@ -790,11 +790,11 @@ mod paired_status_tests { ); assert_eq!( paired_status_line("me", Some(VerificationStatus::NotVerified)), - "\u{1f517} Paired (SSO: me) \u{b7} not verified \u{2014} run `atem pair`" + "\u{1f517} Paired (SSO: me) \u{b7} not verified \u{2014} run `atem pair verify`" ); assert_eq!( paired_status_line("me", Some(VerificationStatus::Revoked)), - "\u{1f517} Paired (SSO: me) \u{b7} revoked \u{2014} run `atem pair`" + "\u{1f517} Paired (SSO: me) \u{b7} revoked \u{2014} run `atem unpair`, then `atem pair`" ); assert_eq!(paired_status_line("me", None), "\u{1f517} Paired (SSO: me)"); } From d446abf454c762642956906323fae5013f6d18dc Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 19:59:14 +0000 Subject: [PATCH 3/7] fix(cli): status shows expired sessions and broken keys; drop atem cred status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - atem status: "paired (, session expired)"; "paired (no Astation id)"; revoked only when the paired Astation revoked; Credentials shows broken () with the reset steps, an older agent build, and an unlocked storage key Astation doesn't hold yet. - atem cred status removed; atem status is the only status command. - A paired profile whose session expired or was refused is told to run atem unpair, then atem pair (pairing gate, memory sync/history, TUI re-authentication, connect errors, pair verify). - pair verify refuses a pairing without an Astation id. - unpair: a failure to set revoked keys aside, or a revocation left recorded, is a notice; the unpair still completes. ๐Ÿค– Built with SMT --- AGENTS.md | 16 +- README.md | 1 - designs/e2e-encryption.md | 26 +-- docs/credentials.md | 8 +- npm/README.md | 1 - src/app/reauth.rs | 11 +- src/auth.rs | 27 ++- src/cli.rs | 111 +++++++--- src/memory/agent_socket.rs | 10 +- src/memory/cmd.rs | 32 ++- src/memory/key_agent.rs | 2 +- src/memory/unlock.rs | 39 ++-- src/memory/verification.rs | 15 +- src/status.rs | 400 ++++++++++++++++++++++++++++++------- src/tui/draw.rs | 8 +- src/websocket_client.rs | 11 +- 16 files changed, 550 insertions(+), 168 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fbd8446..0bd352d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ src/ โ”œโ”€โ”€ command.rs # Task queue and stream buffer for voice commands โ”œโ”€โ”€ dispatch.rs # Work item dispatcher for mark tasks โ”œโ”€โ”€ pair_verify.rs # atem pair / atem pair verify device verification: /dev/tty code confirmation, verifyAbort on failure -โ”œโ”€โ”€ status.rs # atem status (and the atem cred status alias): read-only, offline; also decides pair (no-op when paired), pair verify and the cred unlock gate +โ”œโ”€โ”€ status.rs # atem status: read-only, offline; also decides pair (no-op when paired), pair verify and the cred unlock gate โ”œโ”€โ”€ vault_client.rs # atem vault CLI client (relay /api/vault: request builders, renderers, executor) โ”œโ”€โ”€ memory/ # Atem Memory: shared memory + skills across agents and machines โ”‚ โ”œโ”€โ”€ model.rs # Memory/Skill types, hashing @@ -115,7 +115,7 @@ src/ โ”‚ โ”œโ”€โ”€ storage_key.rs # storage key + device_keys.sealed (XChaCha20-Poly1305); unlock-grant/ack checks โ”‚ โ”œโ”€โ”€ key_agent.rs # key agent state: unlock, rotation, abandon, K installed and used in memory (Crypt) โ”‚ โ”œโ”€โ”€ agent_socket.rs # `atem key-agent` Unix socket server + client (v2 JSON lines, same UID only) -โ”‚ โ”œโ”€โ”€ unlock.rs # `atem cred projects|status|unlock|lock` (status = `atem status`); unlock/rotation carried over the Astation link +โ”‚ โ”œโ”€โ”€ unlock.rs # `atem cred projects|unlock|lock`; unlock/rotation carried over the Astation link โ”‚ โ”œโ”€โ”€ migration.rs # migration read-back check + atem-migration-complete-v1 proof input โ”‚ โ”œโ”€โ”€ fake_astation.rs # (test-only) Astation's side of unlock and rotation, mirrors the design doc โ”‚ โ”œโ”€โ”€ kat_tests.rs # (test-only) known-answer vectors listed in designs/e2e-encryption.md @@ -207,7 +207,7 @@ Key methods: **Atem Memory** (`src/memory/`): `atem sync`, `atem memory โ€ฆ`, `atem skill โ€ฆ`. Agents learn from each other across agents and machines. Claude's saved memories are harvested (an edited file becomes a replacement, a deleted file an invalidation), Codex saves facts with `atem memory add --agent codex` and replaces outdated ones with `atem memory replace ""` (its block shows each fact's short id), and skills are versioned directories (`atem skill history`/`atem skill restore` read old versions from the relay). Facts are never edited in place: `memory replace`/`memory invalidate` keep the history (`valid_at`, `invalid_at`, `superseded_by`; see `memory list --history`), invalidation is final, and only valid facts are injected โ€” a block that leaves facts out says how many and points to `atem memory search`, which is local FTS5 (trigram, BM25) over `knowledge.db`. Everything is synced through the relay (`/api/memory`, `/api/skills`, Astation pairing-session auth) with an offline SQLite store, then applied as a managed block in `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, and `/CLAUDE.local.md`, and as skills in `.claude/skills` and `.agents/skills`. Credential values are never stored: names only. Tracked files are never written. See `designs/atem-memory.md`. -**Key agent** (`src/memory/key_agent.rs`, `agent_socket.rs`, `unlock.rs`): this device's keys live sealed in `device_keys.sealed` under a storage key only the home Astation (the first one this device verified with) holds. The hidden `atem key-agent` (same binary, started on demand, detached; Unix socket, same-UID peers only, `"v": 2` JSON lines; `PR_SET_DUMPABLE=0` on Linux, and `mlockall` there when `RLIMIT_MEMLOCK` is unlimited or โ‰ฅ 512 MiB; one agent per key directory via a `flock` on `key_agent.lock`; an agent whose socket disappears, e.g. logind removing `$XDG_RUNTIME_DIR`, wipes its keys and exits within ~5 s) holds the unlocked keys; while it is locked, grants are ignored (`encryptionMode`/`keyGrant` arriving while atem waits for another reply are applied, not dropped, and after an unlock atem sends `keyRequest` if the signed state still needs `K`; an already unlocked `atem cred unlock` still connects while the signed state is `enabling`/`disabling`, to finish the migration and send the signed completion, since `atem sync` never talks to Astation). `atem cred unlock` asks it for a single-use key, sends an `unlockRequest` signed by `unlock_auth_key`, and hands Astation's Touch-ID-approved, signed `unlockGrant` straight back to the agent; the agent then rotates the storage key in three crash-safe phases (`.next` โ†’ `storageKeyAck` โ†’ promote, `.prev` kept until the next unlock โ†’ `storageKeyConfirm`), abandoning a stale pending key if needed. `atem pair` escrows the first storage key in the same run; until Astation is known to hold it, a fresh device keeps a plain `device_keys` file (like a step-1 device), which the agent re-seals at each start and deletes at the first confirmed escrow, so an agent that stops first loses nothing; `atem cred lock` wipes the agent and stops it (always allowed: the agent stops listening, removes its socket and `agent.socket`, gives requests in flight up to 1 s, clears its pid from `key_agent.lock` and exits; the CLI waits up to 2 s for it to go, and an agent from an older atem that only wipes is reported as still running until the next `atem cred unlock` replaces it); `atem cred status` is `atem status` (see SSO auth). An `unlockDenied` with `revoked: true` (or "revoked" in its reason) is recorded in `cred_state.json` (`atem status` shows "revoked by "; `atem pair verify` then refuses); `atem unpair` with the agent locked (or not running) renames the old key files to `.revoked-` and forgets that Astation's verification, home and escrow state, so the next `atem pair` verifies with new keys (the signal is unsigned: a forged one costs a re-pair, never keys or trust; verification itself never sets keys aside). Since step 2b `K` lives only in the agent: it installs granted keys into the sealed payload (`InstallGrant`; a payload with keys is `version: 2`, so a step-2a binary refuses it), does every seal/open/keyed hash for `EncryptionContext` in batched `Crypt` requests (no reply carries `K`), takes the mode and kid from the newest signed account state across every verified Astation naming the account (one helper, `verification::effective_state`, for install, `Crypt`, `account_mode` and `key_needed`), serializes key payloads into exactly sized wiped buffers, and moves a leftover `data_keys.enc` in at the first unlock (then deletes it, unless an entry of a verified account can't be parsed: then the valid keys move and the file stays; an unreadable one is renamed aside, never deleted); a locked agent keeps sync changes queued until `atem cred unlock`. A key agent left running from before the upgrade speaks protocol v1 and must be stopped (the error says how); a restart before the first escrow loses a post-2b `K`, which is requested again. After the upgrade a verified device needs `atem cred unlock` once. The status reply carries the agent's `build` (`key_agent::build_id`: version + the executable's mtime and size when the agent started, so every rebuild differs; absent from an older agent, which counts as older). A command that may start the agent compares it with its own once: a locked agent of another build is asked to stop (one too old for that gets SIGTERM, only when its pid is confirmed as this user's `atem key-agent` through `key_agent.lock`, else the error gives the `pgrep`/`pkill` lines) and this build's agent is started; it holds no keys, so nothing is lost. An unlocked one is never restarted: the command warns once ("The key agent was started by another atem build; run `atem cred lock`, then `atem cred unlock` to use this one.") and uses it, a request it rejects as unknown gets that hint instead of the raw error (builds are compared for equality only: a dev build and the npm release of the same version differ, and neither counts as newer). The unlocked agent also signs what atem reports to Astation: `CredentialProjects` (the HPKE credential-project report plus optional original-case `labels`, `credential_projects.rs`; sent best effort after each authenticated connection, on `credentialProjectListRequest`, and by `atem cred projects`) and `MigrationComplete` (the `atem-migration-complete-v1` completion with its `K` possession proof, sent only after the read-back pass in `migration.rs` finds every protected field migrated). See `designs/e2e-encryption.md` "Keys on disk (atem)" and "Astation work for step 2a", and `docs/credential-project-reporting.md`. +**Key agent** (`src/memory/key_agent.rs`, `agent_socket.rs`, `unlock.rs`): this device's keys live sealed in `device_keys.sealed` under a storage key only the home Astation (the first one this device verified with) holds. The hidden `atem key-agent` (same binary, started on demand, detached; Unix socket, same-UID peers only, `"v": 2` JSON lines; `PR_SET_DUMPABLE=0` on Linux, and `mlockall` there when `RLIMIT_MEMLOCK` is unlimited or โ‰ฅ 512 MiB; one agent per key directory via a `flock` on `key_agent.lock`; an agent whose socket disappears, e.g. logind removing `$XDG_RUNTIME_DIR`, wipes its keys and exits within ~5 s) holds the unlocked keys; while it is locked, grants are ignored (`encryptionMode`/`keyGrant` arriving while atem waits for another reply are applied, not dropped, and after an unlock atem sends `keyRequest` if the signed state still needs `K`; an already unlocked `atem cred unlock` still connects while the signed state is `enabling`/`disabling`, to finish the migration and send the signed completion, since `atem sync` never talks to Astation). `atem cred unlock` asks it for a single-use key, sends an `unlockRequest` signed by `unlock_auth_key`, and hands Astation's Touch-ID-approved, signed `unlockGrant` straight back to the agent; the agent then rotates the storage key in three crash-safe phases (`.next` โ†’ `storageKeyAck` โ†’ promote, `.prev` kept until the next unlock โ†’ `storageKeyConfirm`), abandoning a stale pending key if needed. `atem pair` escrows the first storage key in the same run; until Astation is known to hold it, a fresh device keeps a plain `device_keys` file (like a step-1 device), which the agent re-seals at each start and deletes at the first confirmed escrow, so an agent that stops first loses nothing; `atem cred lock` wipes the agent and stops it (always allowed: the agent stops listening, removes its socket and `agent.socket`, gives requests in flight up to 1 s, clears its pid from `key_agent.lock` and exits; the CLI waits up to 2 s for it to go, and an agent from an older atem that only wipes is reported as still running until the next `atem cred unlock` replaces it); The key state is shown by `atem status` (see SSO auth). An `unlockDenied` with `revoked: true` (or "revoked" in its reason) is recorded in `cred_state.json` (`atem status` shows "revoked by "; `atem pair verify` then refuses); `atem unpair` with the agent locked (or not running) renames the old key files to `.revoked-` and forgets that Astation's verification, home and escrow state, so the next `atem pair` verifies with new keys (the signal is unsigned: a forged one costs a re-pair, never keys or trust; verification itself never sets keys aside). Since step 2b `K` lives only in the agent: it installs granted keys into the sealed payload (`InstallGrant`; a payload with keys is `version: 2`, so a step-2a binary refuses it), does every seal/open/keyed hash for `EncryptionContext` in batched `Crypt` requests (no reply carries `K`), takes the mode and kid from the newest signed account state across every verified Astation naming the account (one helper, `verification::effective_state`, for install, `Crypt`, `account_mode` and `key_needed`), serializes key payloads into exactly sized wiped buffers, and moves a leftover `data_keys.enc` in at the first unlock (then deletes it, unless an entry of a verified account can't be parsed: then the valid keys move and the file stays; an unreadable one is renamed aside, never deleted); a locked agent keeps sync changes queued until `atem cred unlock`. A key agent left running from before the upgrade speaks protocol v1 and must be stopped (the error says how); a restart before the first escrow loses a post-2b `K`, which is requested again. After the upgrade a verified device needs `atem cred unlock` once. The status reply carries the agent's `build` (`key_agent::build_id`: version + the executable's mtime and size when the agent started, so every rebuild differs; absent from an older agent, which counts as older). A command that may start the agent compares it with its own once: a locked agent of another build is asked to stop (one too old for that gets SIGTERM, only when its pid is confirmed as this user's `atem key-agent` through `key_agent.lock`, else the error gives the `pgrep`/`pkill` lines) and this build's agent is started; it holds no keys, so nothing is lost. An unlocked one is never restarted: the command warns once ("The key agent was started by another atem build; run `atem cred lock`, then `atem cred unlock` to use this one.") and uses it, a request it rejects as unknown gets that hint instead of the raw error, and `atem status` adds `(started by another atem build; run atem cred lock, then atem cred unlock)` to its Credentials line (builds are compared for equality only: a dev build and the npm release of the same version differ, and neither counts as newer). The unlocked agent also signs what atem reports to Astation: `CredentialProjects` (the HPKE credential-project report plus optional original-case `labels`, `credential_projects.rs`; sent best effort after each authenticated connection, on `credentialProjectListRequest`, and by `atem cred projects`) and `MigrationComplete` (the `atem-migration-complete-v1` completion with its `K` possession proof, sent only after the read-back pass in `migration.rs` finds every protected field migrated). See `designs/e2e-encryption.md` "Keys on disk (atem)" and "Astation work for step 2a", and `docs/credential-project-reporting.md`. **Claude Code Integration** (`claude_client.rs`): Manages Claude Code as a PTY subprocess using `portable-pty`. Includes terminal output parsing via `vt100`, session recording, and resize handling. @@ -252,11 +252,11 @@ Key methods: **SSO auth** (`sso_auth.rs`): - `atem login` โ€” OAuth 2.0 + PKCE browser flow against `sso2.agora.io`; writes an `sso` entry to `credentials.enc` - `atem logout` โ€” removes the `sso` entry from `credentials.enc` -- `atem status` (tier 0, `src/status.rs`) โ€” read-only and offline: `Account` (signed in / signed in (expired) / signed out), `Pairing` (`paired ()` โ€” the one `require_pairing` resolves, else the first paired one, `(+N more)` for others โ€” or `not paired`), `Verification` (`verified (safety code โ€ฆ)` / `not verified` / `revoked by ` / `โ€”`), `Credentials` (`unlocked` / `locked` / `not set up`; asks only an already running key agent, never starts one; no agent = locked), `Authorization` and `Relay` `unknown (stale)`, `Local agents` `unknown`. Never connects, never writes. `atem cred status` prints exactly the same. "Paired" = any paired credential entry or valid saved Astation session in `sessions.json` +- `atem status` (tier 0, `src/status.rs`; the only status command) โ€” read-only and offline: `Account` (signed in / signed in (expired) / signed out), `Pairing` (`paired ()` โ€” the one `require_pairing` resolves, else the first paired one, `(+N more)` for others; `paired (, session expired)` when its saved session expired; `paired (no Astation id)`; or `not paired`), `Verification` (`verified (safety code โ€ฆ)` / `not verified` / `revoked by ` only when that is the paired Astation / `โ€”`), `Credentials` (`unlocked` / `locked` / `not set up` / `broken ()` with `key_agent::RESET` printed after the block; plus `(storage key not yet held by Astation; run atem cred unlock)` for an unlocked, unescrowed agent and `(started by another atem build; run atem cred lock, then atem cred unlock)` for another build's agent; asks only an already running key agent, never starts one; no agent = locked), `Authorization` and `Relay` `unknown (stale)`, `Local agents` `unknown`. Never connects, never writes. "Paired" = any paired credential entry or valid saved Astation session in `sessions.json`. A paired profile whose session expired or was refused gets `The saved Astation session expired or was refused. Run `atem unpair`, then `atem pair`.` instead of "run `atem pair`" (pairing gate, memory sync/history, TUI re-authentication, connect errors, `pair verify`) - `atem pair [--save]` โ€” on an already paired profile (even unverified or revoked) prints the status plus `Already paired. Verify this device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair` and exits 0 without connecting or changing anything. Otherwise: connect to Astation, send `PairSavePreference`, wait for `CredentialSync`, write paired entry, then verify the device: commit-then-reveal (`verifyCommit` โ†’ `verifyKeys` โ†’ `verifyReveal`), both sides show a 12-character safety code, the user answers `codes match? [y/N]` (read from `/dev/tty`, flushed first, never stdin; no terminal fails the verification) and confirms on Astation with Touch ID, and atem applies the signed `deviceVerified` atomically (pins, device keys, signed state, grants; nothing on failure). If `K` is needed but missing it sends `keyRequest`. Astations that don't answer within 15s leave the device as it was (see `designs/e2e-encryption.md`). Any later failure (code refused, no terminal, 300 s Touch ID timeout, lost connection, apply error) drops the pending pin and sends `verifyAbort` with delivery confirmed (not when Astation itself aborted); the pairing stays saved and `atem pair` exits nonzero (`atem pair verify` retries). A failed `keyRequest` after success is only a warning (`atem cred unlock` asks again). Logic and tests: `src/pair_verify.rs`. On the home Astation's first verification the fresh keys are sealed and the first storage key is escrowed (`storageKeyRotate` with an empty old kid, then confirmed) in the same run -- `atem pair verify` โ€” needs a pairing (`Not paired. Run atem pair first.`, exit 1); already verified with the paired Astation โ†’ `Already verified (safety code โ€ฆ).`, no connection; revoked โ†’ `This device was revoked by Astation . Run atem unpair, then atem pair, to set it up again.`, exit 1, nothing moved. Otherwise runs the same verification as `atem pair` (`run_device_verification`) over a connection authenticated with the saved session (local Astation if it is the paired one, else its relay identity room; never pairing). A cancelled or failed verification keeps the pairing -- `atem cred projects|status|unlock|lock` (tier 2; `status` works on any profile) โ€” send the current Git project and the account's remembered project names to the home Astation's credential picker (keys must be unlocked; connects within 15 s; see `docs/credential-project-reporting.md`); `atem status`; Touch ID unlock through the home Astation (waits up to 300 s, then rotates the storage key; when already unlocked it still finishes a pending migration; an unverified device stops offline with `This device isn't verified. Run atem pair verify first.`, and it never starts pairing or verification); wipe the unlocked keys and stop the agent. `atem key-agent` is the hidden agent process itself -- `atem unpair [--yes]` โ€” remove all paired credential entries and saved Astation sessions (the SSO login stays), and set revoked keys aside (see Key agent). It, `atem memory purge` and `atem skill purge` confirm first (`cli::confirm_destructive`): `--yes` skips the question, and without a terminal they refuse unless `--yes` is given. The purge prompts show the id and scope only, never the content, since a purged item may hold a credential +- `atem pair verify` โ€” needs a pairing (`Not paired. Run atem pair first.`, exit 1; a paired entry without an Astation id: `The saved pairing has no Astation id. Run `atem unpair`, then `atem pair`.`); already verified with the paired Astation โ†’ `Already verified (safety code โ€ฆ).`, no connection; revoked โ†’ `This device was revoked by Astation . Run atem unpair, then atem pair, to set it up again.`, exit 1, nothing moved. Otherwise runs the same verification as `atem pair` (`run_device_verification`) over a connection authenticated with the saved session (local Astation if it is the paired one, else its relay identity room; never pairing). A cancelled or failed verification keeps the pairing +- `atem cred projects|unlock|lock` (tier 2; the key state is shown by `atem status`) โ€” send the current Git project and the account's remembered project names to the home Astation's credential picker (keys must be unlocked; connects within 15 s; see `docs/credential-project-reporting.md`); Touch ID unlock through the home Astation (waits up to 300 s, then rotates the storage key; when already unlocked it still finishes a pending migration; an unverified device stops offline with `This device isn't verified. Run atem pair verify first.`, and it never starts pairing or verification); wipe the unlocked keys and stop the agent. `atem key-agent` is the hidden agent process itself +- `atem unpair [--yes]` โ€” remove all paired credential entries and saved Astation sessions (the SSO login stays), and set revoked keys aside (see Key agent); a failure to set them aside, or a revocation left recorded (agent unlocked, or not the home Astation), is printed as a notice and the unpair still completes. It, `atem memory purge` and `atem skill purge` confirm first (`cli::confirm_destructive`): `--yes` skips the question, and without a terminal they refuse unless `--yes` is given. The purge prompts show the id and scope only, never the content, since a purged item may hold a credential - `valid_token(connected_astation_id, sso_url)` โ€” resolves via priority chain and returns an access token. Within 60s of expiry, an `sso` entry refreshes itself; a paired entry never does (Agora rotates refresh tokens, so it would invalidate Astation's copy): atem connects to Astation, which sends a fresh `credentialSync` on connect. **BFF API** (`agora_api.rs`): @@ -307,7 +307,7 @@ with Astation unlocks the full set (tier 2) โ€” Astation is the control plane. | Tier | Needs | Commands | |---|---|---| -| 0 | โ€” | status (and `cred status`), serv files, config, token with AGORA_APP_ID/CERT env, `project use `, `project show` (local cache) | +| 0 | โ€” | status, serv files, config, token with AGORA_APP_ID/CERT env, `project use `, `project show` (local cache) | | 1 | `atem login` | `project list`, `project use `, token (active project), serv rtc/convo/webhooks | | 2 | paired with Astation | vault, sync, memory, skill, cred, and Astation-driven remote agent control, voice coding, mark tasks | diff --git a/README.md b/README.md index f9c50df..a8f60aa 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,6 @@ authorization and relay show as `unknown (stale)`. ### Device keys (`atem cred`) ```bash -atem cred status # Same as `atem status` atem cred unlock # Unlock this device's keys (approve with Touch ID on your Mac) atem cred lock # Wipe unlocked keys and stop the key agent atem cred projects # Send known Git projects to Astation's credential picker (keys must be unlocked) diff --git a/designs/e2e-encryption.md b/designs/e2e-encryption.md index 766949f..26db552 100644 --- a/designs/e2e-encryption.md +++ b/designs/e2e-encryption.md @@ -458,7 +458,7 @@ macOS login keychain is locked in SSH sessions, and most atems run over SSH. unlocked one is never restarted silently: the command warns that `atem cred lock`, then `atem cred unlock` switches to the new agent, keeps using the old one, and turns its rejection of a request it doesn't know - into the same hint. `atem cred status` marks it "(started by another atem + into the same hint. `atem status` marks it "(started by another atem build; โ€ฆ)". Builds are compared for equality only: a dev build and the npm release of the same version differ, and neither counts as newer. - **Home Astation.** One storage key per device, held by the device's first @@ -484,12 +484,13 @@ macOS login keychain is locked in SSH sessions, and most atems run over SSH. device keeps its plain keys, as in step 1. - **`escrowed_storage_kid`** in `cred_state.json` is the last storage kid Astation is known to hold (set at every confirmed escrow or rotation and - by an unlock that opened the current file). `atem cred status` uses it to - flag a sealed file Astation may not hold, and new storage kids never + by an unlock that opened the current file). It tells an escrowed sealed + file from one Astation may not hold, and new storage kids never reuse it, the current kid, or any kid the device ever abandoned (`abandoned_kids`). `escrow_unanswered` records that the home Astation - never answered the first escrow (cleared by the first confirmed one), so - `atem cred status` can say the key goes over once Astation supports it. + never answered the first escrow (cleared by the first confirmed one). + `atem status` flags an unlocked agent whose storage key Astation doesn't + hold yet ("storage key not yet held by Astation; run atem cred unlock"). - **Rotation at every unlock, crash-safe in three phases.** (1) The agent writes `device_keys.sealed.next` under a new storage key and atem sends `storageKeyRotate`. (2) Astation stores the new key as pending, keeps the @@ -568,7 +569,7 @@ macOS login keychain is locked in SSH sessions, and most atems run over SSH. `data_keys.enc` are serialized into one exactly sized, wiped buffer, so no unwiped reallocation copy of `K` or the device keys is left behind. A client that gets a hang-up from a running v1 agent (for example while writing a large - first request) reports the stop-and-retry hint, and `atem cred status` + first request) reports the stop-and-retry hint, and `atem status` against a v1 agent prints the version-mismatch line and the same hint instead of failing. - **Mode and kid** come only from the latest signed account state in @@ -647,7 +648,7 @@ macOS login keychain is locked in SSH sessions, and most atems run over SSH. be re-grantable. - A step-2a binary refuses a v2 `device_keys.sealed` (see above). To downgrade after a `K` was installed, follow the reset steps that - `atem cred status` prints, or wait for a fresh grant. + `atem status` prints, or wait for a fresh grant. - A key agent left running across the upgrade speaks protocol v1; the new atem tells you to stop it. After the upgrade a verified device needs `atem cred unlock` once (protocol v2 agent starts locked) before sync @@ -1196,7 +1197,7 @@ atem cred get [--project | --global] # value only โ†’ stdout; current project, then global atem cred list [--all] # name, scope, version, updated; never values -atem cred status # verified? safety code, locked?, unlock mode, granted projects +atem status # verified? safety code, locked? (the only status command) atem cred unlock # ask Astation to unlock now (get also triggers it) atem cred lock # wipe keys from agent memory now and stop the agent atem cred add [--global] @@ -1519,9 +1520,10 @@ characters. An `unlockDenied` with `revoked: true` (or, from an Astation that doesn't send the flag yet, "revoked" or "does not belong to this verified device" in `reason`, the answer of an Astation that has forgotten the device) is recorded in `cred_state.json`; -`atem cred status` shows it, and the next `atem pair` with the agent locked -renames the old key files to `.revoked-` and verifies with new -keys. The signal is unsigned too, but acting on it only renames old files +`atem status` shows it ("revoked by " for the paired Astation) and +`atem pair verify` refuses; `atem unpair` with the agent locked renames the +old key files to `.revoked-`, and the next `atem pair` verifies +with new keys. Verification itself never sets keys aside. The signal is unsigned too, but acting on it only renames old files aside and re-verifies with a fresh safety code, so a forged "revoked" can cost a re-pair but never keys or trust. @@ -1669,7 +1671,7 @@ Nothing new is stored. If this device's keys can't be unlocked (the sealed file or `unlock_auth_key` is damaged or missing, or Astation lost the storage key), -`atem cred status` and +`atem status` and `atem cred unlock` print: > To start over: delete ~/.config/atem/device_keys, device_keys.sealed, diff --git a/docs/credentials.md b/docs/credentials.md index 315f516..b1b8b74 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -40,7 +40,7 @@ running after the CLI has been updated; see the upgrade steps below. the pairing command open until it reports device verification. 4. If Astation asks you to save a recovery kit, save the complete kit and confirm. Finish any key-escrow/unlock approval requested by the pairing - command, then run `atem cred status`. + command, then run `atem status`. The status must say the device is verified. **Connected** or **Paired with Astation** alone confirms neither device verification nor key-agent unlock. @@ -51,7 +51,7 @@ the relay or remove the Mac approval required to unlock sealed device keys. ## Unlock and upgrade the key agent ```sh -atem cred status +atem status atem cred unlock ``` @@ -67,7 +67,7 @@ development build next to the npm one. With the atem you want to use, run: ```sh atem cred lock atem cred unlock -atem cred status +atem status ``` Lock wipes keys from memory and stops the agent. Unlock starts the current @@ -138,7 +138,7 @@ by account-data encryption. See the [encryption design](../designs/e2e-encryptio ## Current availability -`atem cred status`, `unlock`, `lock` and `projects` are implemented. The +`atem status` and `atem cred unlock`, `lock` and `projects` are implemented. The planned `atem cred get/list` service for retrieving values from Astation's Credentials editor is not implemented. Use that editor on the Mac to add, update, copy or delete its values with Touch ID. Memory and skills must contain diff --git a/npm/README.md b/npm/README.md index 9f86daf..94df5d8 100644 --- a/npm/README.md +++ b/npm/README.md @@ -71,7 +71,6 @@ authorization and relay show as `unknown (stale)`. ### Device keys (`atem cred`) ```bash -atem cred status # Same as `atem status` atem cred unlock # Unlock this device's keys (approve with Touch ID on your Mac) atem cred lock # Wipe unlocked keys and stop the key agent atem cred projects # Send known Git projects to Astation's credential picker (keys must be unlocked) diff --git a/src/app/reauth.rs b/src/app/reauth.rs index 0f29148..608e3ef 100644 --- a/src/app/reauth.rs +++ b/src/app/reauth.rs @@ -40,10 +40,11 @@ impl App { Ok(None) => {} Ok(Some(AuthResponse::Authenticated)) => self.finish_astation_reauth().await, Ok(Some(AuthResponse::Denied(reason))) => self.fail_astation_reauth(format!( - "Astation refused to re-authenticate this device ({reason}); run `atem pair`" + "Astation refused to re-authenticate this device ({reason}). {}", + reauth_advice() )), Ok(Some(AuthResponse::SessionExpired)) => { - self.fail_astation_reauth("Astation session expired; run `atem pair`".to_string()) + self.fail_astation_reauth(format!("Astation session expired. {}", reauth_advice())) } Err(error) => { self.fail_astation_reauth(format!("Astation re-authentication failed: {error}")) @@ -129,6 +130,12 @@ impl App { } } +/// What to do after Astation refused or expired the session: `atem unpair` +/// first on a paired profile (`atem pair` alone only shows the status). +fn reauth_advice() -> String { + crate::status::pairing_advice(crate::status::current_profile_paired(), "Run `atem pair`.") +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/auth.rs b/src/auth.rs index fb3428c..f0b7ead 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -69,10 +69,28 @@ pub fn pairing_gate_message(feature: &str) -> String { ) } +/// The tier-2 gate message for `paired`: a paired profile without a usable +/// session can't pair again with `atem pair` (it only shows the status). +pub fn pairing_gate_message_for(feature: &str, paired: bool) -> String { + if paired { + format!( + "{feature} can't use your Astation pairing. {}", + crate::status::SESSION_GONE + ) + } else { + pairing_gate_message(feature) + } +} + /// The tier-2 gate: a local check with no network. Fails with an actionable /// message when this machine isn't paired with an Astation. pub fn require_pairing(feature: &str) -> Result { - pairing_session().map_err(|_| anyhow!(pairing_gate_message(feature))) + pairing_session().map_err(|_| { + anyhow!(pairing_gate_message_for( + feature, + crate::status::current_profile_paired() + )) + }) } /// The tier-1 gate message: `feature` refused because there's no `atem @@ -209,6 +227,13 @@ impl SessionManager { self.sessions.keys().cloned().collect() } + /// Whether the saved session for `astation_id` exists but expired. + pub(crate) fn expired(&self, astation_id: &str) -> bool { + self.sessions + .get(astation_id) + .is_some_and(|s| !s.is_valid()) + } + /// Forgets every saved session (`atem unpair`). pub(crate) fn clear(&mut self) { self.sessions.clear(); diff --git a/src/cli.rs b/src/cli.rs index 7c0bcd2..2fdebe2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -91,7 +91,7 @@ pub enum Commands { #[command(subcommand)] command: SkillCommands, }, - /// This device's keys: unlock with Touch ID on your Mac, lock, status (see designs/e2e-encryption.md) + /// This device's keys: unlock with Touch ID on your Mac, lock (status: `atem status`; see designs/e2e-encryption.md) Cred { #[command(subcommand)] command: CredCommands, @@ -406,8 +406,6 @@ pub enum PairCommands { pub enum CredCommands { /// Send known Git projects to Astation's credential picker (keys must be unlocked) Projects, - /// Same as `atem status` - Status, /// Unlock this device's keys (approve with Touch ID on your Mac) Unlock, /// Wipe the unlocked keys from memory and stop the key agent @@ -924,7 +922,7 @@ pub async fn handle_cli_command(command: Commands) -> Result<()> { println!("Cancelled; nothing was removed."); return Ok(()); } - let notice = { + let notices = { let src = src.clone(); crate::memory::key_agent::blocking(move || { let agent = crate::memory::key_agent::running_agent(); @@ -933,7 +931,7 @@ pub async fn handle_cli_command(command: Commands) -> Result<()> { .await? }; println!("Unpaired ({count} session{plural} removed)."); - if let Some(notice) = notice { + for notice in notices { println!("{notice}"); } Ok(()) @@ -1189,11 +1187,13 @@ fn revocation_recorded(src: &crate::status::Sources) -> bool { /// entries and the saved Astation sessions (the login stays), and, when an /// Astation revoked this device and `agent` (the running key agent, if any) /// doesn't hold its keys unlocked, sets the revoked keys aside so the next -/// `atem pair` verifies with new ones. Returns that notice. Blocking. +/// `atem pair` verifies with new ones. Returns the notices to print: keys set +/// aside, a revocation left recorded, or a failure to set them aside (the +/// pairing is removed regardless). Blocking. fn unpair_profile( src: &crate::status::Sources, agent: Option<&dyn crate::memory::key_agent::KeyAgentApi>, -) -> Result> { +) -> Result> { let mut store = crate::credentials::CredentialStore::load_from(&src.credentials); let before = store.entries.len(); store @@ -1207,7 +1207,27 @@ fn unpair_profile( sessions.clear(); sessions.save_to(&src.sessions)?; } - crate::memory::verification::set_aside_revoked_keys(&src.keys, agent) + // Unpaired from here on: what happens to revoked keys is only reported. + let mut notices = Vec::new(); + match crate::memory::verification::set_aside_revoked_keys(&src.keys, agent) { + Ok(Some(notice)) => notices.push(notice), + Ok(None) => {} + Err(error) => notices.push(format!("Couldn't set the revoked keys aside: {error:#}")), + } + if let Ok(trust) = crate::memory::trust::TrustStore::load_from(&src.keys.trust) + && let Some(revoked) = trust.revoked() + { + let why = if trust.recorded_home() != Some(revoked.astation_id.as_str()) { + "it isn't this device's home Astation" + } else { + "the key agent holds them unlocked; run `atem cred lock`, then `atem unpair` again" + }; + notices.push(format!( + "Astation {}'s revocation of this device is still recorded and its keys were not set aside ({why}).", + revoked.astation_id + )); + } + Ok(notices) } /// `atem pair [--save]` โ€” when the profile is already paired, prints its status @@ -1386,7 +1406,9 @@ async fn run_pair_verify(src: &crate::status::Sources) -> Result<()> { crate::status::VerifyStart::Verify(astation_id) => astation_id, }; println!("Connecting to Astation {astation_id}..."); - let mut client = crate::memory::unlock::connect_astation(&src.config, &astation_id, "the paired Astation").await?; + let mut client = crate::memory::unlock::connect_astation(&src.config, &astation_id, "the paired Astation") + .await + .map_err(session_advice)?; let verified = crate::pair_verify::verification_result( run_device_verification(&mut client, &astation_id).await, ); @@ -1397,6 +1419,20 @@ async fn run_pair_verify(src: &crate::status::Sources) -> Result<()> { verified } +/// `atem pair verify`'s connection failed: when the saved session was refused +/// or is missing, says what to do (`atem pair` alone would only show the status). +fn session_advice(error: anyhow::Error) -> anyhow::Error { + let text = format!("{error:#}"); + let refused = ["Pairing required", "Authentication denied", "no saved session"] + .iter() + .any(|sign| text.contains(sign)); + if refused && !text.contains(crate::status::SESSION_GONE) { + anyhow::anyhow!("{text}. {}", crate::status::SESSION_GONE) + } else { + error + } +} + /// Verifies this device with Astation: commit, receive Astation's keys, /// reveal, compare the safety code on both sides, then wait for Astation's /// signed certificate. Nothing is trusted unless both sides confirm. @@ -2220,7 +2256,7 @@ mod tests { #[test] fn cred_commands_parse() { - for (arg, expected) in [("status", "Status"), ("unlock", "Unlock"), ("lock", "Lock")] { + for (arg, expected) in [("unlock", "Unlock"), ("lock", "Lock")] { let cli = Cli::try_parse_from(["atem", "cred", arg]).unwrap(); match cli.command { Some(Commands::Cred { command }) => assert_eq!(format!("{command:?}"), expected), @@ -2475,19 +2511,6 @@ mod tests { assert_never_dialed(&listener); } - #[test] - fn cred_status_prints_what_status_prints() { - let dir = tempfile::tempdir().unwrap(); - paired_profile(dir.path()); - trust_file(dir.path(), false); - let src = crate::status::Sources::in_dir(dir.path()); - assert_eq!( - crate::memory::unlock::cred_status_text(&src), - crate::status::text(&src) - ); - assert!(crate::status::text(&src).starts_with("Account: signed in\n")); - } - #[test] fn unpair_removes_pairings_and_saved_sessions_but_keeps_the_login() { let dir = tempfile::tempdir().unwrap(); @@ -2512,7 +2535,7 @@ mod tests { manager.save_to(&sessions).unwrap(); let src = crate::status::Sources::in_dir(dir.path()); assert_eq!(pairings_to_remove(&src), 2); - unpair_profile(&src, None).unwrap(); + assert!(unpair_profile(&src, None).unwrap().is_empty()); assert_eq!(crate::status::pairing(&src), None); assert_eq!(pairings_to_remove(&src), 0); let store = crate::credentials::CredentialStore::load_from(&creds); @@ -2526,11 +2549,47 @@ mod tests { paired_profile(dir.path()); trust_file(dir.path(), true); let src = crate::status::Sources::in_dir(dir.path()); - let notice = unpair_profile(&src, None).unwrap(); - assert!(notice.unwrap().contains("old keys were moved aside")); + let notices = unpair_profile(&src, None).unwrap(); + assert_eq!(notices.len(), 1, "{notices:?}"); + assert!(notices[0].contains("old keys were moved aside"), "{notices:?}"); assert!(!dir.path().join("device_keys.sealed").exists()); let trust = crate::memory::trust::TrustStore::load_from(&src.keys.trust).unwrap(); assert!(trust.revoked().is_none()); assert!(trust.verified("astation-1").is_none()); } + + #[test] + fn unpair_says_when_a_revocation_stays_recorded() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + trust_file(dir.path(), false); + let src = crate::status::Sources::in_dir(dir.path()); + crate::memory::trust::TrustStore::update(&src.keys.trust, |trust| { + trust.record_revoked("astation-9", 1_700_000_000); + Ok(()) + }) + .unwrap(); + let notices = unpair_profile(&src, None).unwrap(); + assert_eq!(notices.len(), 1, "{notices:?}"); + assert!(notices[0].contains("Astation astation-9's revocation of this device is still recorded"), "{notices:?}"); + assert!(dir.path().join("device_keys.sealed").exists()); + assert_eq!(crate::status::pairing(&src), None); + } + + #[test] + fn unpair_still_unpairs_when_the_keys_cant_be_set_aside() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + std::fs::write(dir.path().join("cred_state.json"), b"not json").unwrap(); + let src = crate::status::Sources::in_dir(dir.path()); + let notices = unpair_profile(&src, None).unwrap(); + assert_eq!(notices.len(), 1, "{notices:?}"); + assert!(notices[0].starts_with("Couldn't set the revoked keys aside:"), "{notices:?}"); + assert_eq!(crate::status::pairing(&src), None); + } + + #[test] + fn cred_status_is_gone() { + assert!(Cli::try_parse_from(["atem", "cred", "status"]).is_err()); + } } diff --git a/src/memory/agent_socket.rs b/src/memory/agent_socket.rs index 1837396..f3513a9 100644 --- a/src/memory/agent_socket.rs +++ b/src/memory/agent_socket.rs @@ -258,7 +258,7 @@ struct CryptedReply { /// What to do about a running agent of another protocol version. fn version_mismatch(version: u64) -> anyhow::Error { anyhow!( - "the running key agent speaks protocol v{version}, this atem speaks v{PROTOCOL_VERSION}; check `atem cred status` first (a storage key not yet held by Astation needs `atem cred unlock` from the atem that started the agent), then stop it with `pkill -u \"$USER\" -f 'atem key-agent'` (its keys stay sealed on disk) and retry" + "the running key agent speaks protocol v{version}, this atem speaks v{PROTOCOL_VERSION}; check `atem status` first (a storage key not yet held by Astation needs `atem cred unlock` from the atem that started the agent), then stop it with `pkill -u \"$USER\" -f 'atem key-agent'` (its keys stay sealed on disk) and retry" ) } @@ -872,7 +872,7 @@ pub async fn run_key_agent() -> Result<()> { }; let watch = Watch::new(&socket, &paths.agent_socket, ORPHAN_CHECK)?; // A key-file error never stops the agent: it starts locked and says why - // on `atem cred status` (see KeyAgent::new). + // on `atem status` (see KeyAgent::new). let agent = KeyAgent::new(paths)?; eprintln!( "atem key agent (protocol v{PROTOCOL_VERSION}, pid {}) listening on {}", @@ -1473,7 +1473,7 @@ mod tests { let hint = format!("{:#}", older_agent.err().unwrap()); // Check first that Astation holds the storage key, then stop it. let (status, pkill) = ( - hint.find("atem cred status").expect(&hint), + hint.find("atem status").expect(&hint), hint.find("pkill").expect(&hint), ); assert!(status < pkill, "{hint}"); @@ -1994,7 +1994,9 @@ mod tests { let profile = tempfile::tempdir().unwrap(); std::fs::write(profile.path().join("device_keys.sealed"), b"{}").unwrap(); let src = crate::status::Sources::in_dir(profile.path()); - let credentials = crate::status::gather_with(&src, Some(&client)).credentials.value; + let credentials = crate::status::gather_with(&src, Some(&client)) + .credentials + .value; // Shown, not an error: the mismatch and what to do. assert!( credentials.starts_with("unknown (key agent not answering: the running key agent speaks protocol v1, this atem speaks v2"), diff --git a/src/memory/cmd.rs b/src/memory/cmd.rs index 5ca44cf..a4aa6f8 100644 --- a/src/memory/cmd.rs +++ b/src/memory/cmd.rs @@ -118,6 +118,26 @@ fn build_ctx(allow_tracked: bool) -> Result { const NOT_CONFIGURED_MSG: &str = "No Astation configured โ€” set astation_relay_code (or ASTATION_RELAY_CODE); changes stay queued."; const NOT_PAIRED_MSG: &str = "Not paired with your Astation โ€” run `atem pair`; changes stay queued."; +/// `NOT_PAIRED_MSG`, or for a paired profile whose session expired or was +/// refused, what to do instead (`atem pair` would only show the status). +fn not_paired_msg(paired: bool) -> String { + if paired { + format!("{} Changes stay queued.", crate::status::SESSION_GONE) + } else { + NOT_PAIRED_MSG.to_string() + } +} + +/// The error for skill history/restore without a usable pairing. +fn history_unpaired() -> anyhow::Error { + anyhow!(not_paired_history_msg(crate::status::current_profile_paired())) +} + +/// `NOT_PAIRED_HISTORY_MSG`, or the advice for a paired profile. +fn not_paired_history_msg(paired: bool) -> String { + crate::status::pairing_advice(paired, NOT_PAIRED_HISTORY_MSG) +} + /// `Err` when no relay client can be built (no Astation configured, or no /// pairing session). `crate::auth::require_pairing` should already have /// caught this before any of these callers run, but `client()` resolves the @@ -144,7 +164,7 @@ fn astation_status_line(astation_id: &str) -> String { fn sync_status_line(out: &SyncOutcome, problem: Option<&PairingProblem>) -> String { match problem { Some(PairingProblem::NotConfigured) => NOT_CONFIGURED_MSG.to_string(), - Some(PairingProblem::NotPaired) => NOT_PAIRED_MSG.to_string(), + Some(PairingProblem::NotPaired) => not_paired_msg(crate::status::current_profile_paired()), None if out.offline => "Relay unreachable โ€” changes stay queued and sync next time.".to_string(), None if out.relay_error => format!( "Sync incomplete โ€” pushed {} change(s), pulled {} update(s); the rest stay queued (see notes).", @@ -753,7 +773,7 @@ pub async fn handle_skill(command: SkillCommands) -> Result<()> { check_history_name(&name)?; let scope = skill_scope(&scope)?; let project = project_for_scope(scope, &ctx, None)?; - let c = client().await.map_err(|_| anyhow!(NOT_PAIRED_HISTORY_MSG))?; + let c = client().await.map_err(|_| history_unpaired())?; let versions = c.skill_versions(scope, &project, &name).await .map_err(|e| sync::skill_history_error(e, &name, None))?; if versions.is_empty() { @@ -767,7 +787,7 @@ pub async fn handle_skill(command: SkillCommands) -> Result<()> { check_history_name(&name)?; let scope = skill_scope(&scope)?; let project = project_for_scope(scope, &ctx, None)?; - let c = client().await.map_err(|_| anyhow!(NOT_PAIRED_HISTORY_MSG))?; + let c = client().await.map_err(|_| history_unpaired())?; let restored = sync::restore_skill(&store, &c, &ctx.atem_id, scope, &project, &name, version).await?; println!("Restored {} v{} as v{}.", name, version, restored.version); let report = best_effort_sync_report(&store, &ctx).await; @@ -1043,6 +1063,12 @@ mod tests { assert_eq!(sync_status_line(&offline, Some(&PairingProblem::NotPaired)), NOT_PAIRED_MSG); assert!(NOT_CONFIGURED_MSG.contains("astation_relay_code") && NOT_CONFIGURED_MSG.contains("stay queued")); assert!(NOT_PAIRED_MSG.contains("atem pair") && NOT_PAIRED_MSG.contains("stay queued")); + assert_eq!( + not_paired_msg(true), + "The saved Astation session expired or was refused. Run `atem unpair`, then `atem pair`. Changes stay queued." + ); + assert_eq!(not_paired_history_msg(false), NOT_PAIRED_HISTORY_MSG); + assert_eq!(not_paired_history_msg(true), crate::status::SESSION_GONE); assert!(sync_status_line(&offline, None).starts_with("Relay unreachable")); let done = SyncOutcome { pushed: 2, pulled: 3, ..Default::default() }; assert_eq!(sync_status_line(&done, None), "Pushed 2 change(s), pulled 3 update(s)."); diff --git a/src/memory/key_agent.rs b/src/memory/key_agent.rs index 48333f6..3631836 100644 --- a/src/memory/key_agent.rs +++ b/src/memory/key_agent.rs @@ -1029,7 +1029,7 @@ impl KeyAgent { } if source != SealedFile::Prev { // The unlock proves Astation holds the key the current file is - // now sealed under: record it (`atem cred status` reads it). + // now sealed under: record it (`atem status` reads it). TrustStore::update(&self.paths.trust, |store| { store.set_escrowed_kid(&granted.storage_kid); Ok(()) diff --git a/src/memory/unlock.rs b/src/memory/unlock.rs index 4606f89..e403abe 100644 --- a/src/memory/unlock.rs +++ b/src/memory/unlock.rs @@ -12,8 +12,7 @@ use std::time::Duration; use crate::memory::device_keys::UnlockAuthKey; use crate::memory::encoding::dec; use crate::memory::key_agent::{ - KeyAgentApi, LOCKED, RESET, blocking, build_unlock_request, default_agent, - running_agent, + KeyAgentApi, LOCKED, RESET, blocking, build_unlock_request, default_agent, running_agent, }; use crate::memory::statements::{SignedWire, StorageRotate}; use crate::memory::storage_key::{ @@ -454,7 +453,7 @@ async fn send_rotation( Ok(reply) => reply, Err(error) => { if error.downcast_ref::().is_some() { - // Before any escrow was confirmed, `atem cred status` + // Before any escrow was confirmed, `atem status` // says Astation may not take storage keys yet. let path = paths.trust.clone(); let _ = blocking(move || { @@ -677,22 +676,11 @@ pub fn lock_keys_message(agent: Option<&dyn KeyAgentApi>) -> Result { } } -/// `atem cred status`: exactly what `atem status` prints (blocking: may ask -/// a running key agent). -pub fn cred_status_text(src: &crate::status::Sources) -> String { - crate::status::text(src) -} - -/// `atem cred โ€ฆ` (tier 2: needs a pairing with Astation; `status`, the -/// `atem status` alias, works on any profile). +/// `atem cred โ€ฆ` (tier 2: needs a pairing with Astation; the status is +/// `atem status`). pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { use crate::cli::CredCommands; - if let CredCommands::Status = command { - let src = crate::status::Sources::current()?; - print!("{}", blocking(move || Ok(cred_status_text(&src))).await?); - return Ok(()); - } - let _paired = crate::auth::require_pairing("atem cred")?; + crate::auth::require_pairing("atem cred")?; let paths = KeyPaths::default_paths(); match command { CredCommands::Projects => { @@ -707,10 +695,7 @@ pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { println!("Sent project suggestions to Astation."); Ok(()) } - CredCommands::Status => unreachable!("handled above"), - CredCommands::Unlock => { - unlock_command(&paths, &crate::config::AtemConfig::load()?).await - } + CredCommands::Unlock => unlock_command(&paths, &crate::config::AtemConfig::load()?).await, CredCommands::Lock => { let message = blocking(|| lock_keys_message(running_agent().as_deref())).await?; println!("{message}"); @@ -721,7 +706,12 @@ pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { /// Connects to the home Astation without pairing. async fn connect_home(home: &str) -> Result { - connect_astation(&crate::config::AtemConfig::load()?, home, "this device's home Astation").await + connect_astation( + &crate::config::AtemConfig::load()?, + home, + "this device's home Astation", + ) + .await } /// Connects to `astation_id` (`what` names it in errors) with the saved @@ -755,7 +745,10 @@ pub(crate) async fn connect_astation( /// `atem cred unlock`: never pairs or verifies; an unverified device stops /// here, offline. -pub(crate) async fn unlock_command(paths: &KeyPaths, config: &crate::config::AtemConfig) -> Result<()> { +pub(crate) async fn unlock_command( + paths: &KeyPaths, + config: &crate::config::AtemConfig, +) -> Result<()> { let trust = TrustStore::load_from(&paths.trust)?; let home = match (trust.home(), trust.recorded_home()) { (Some(home), _) => home.to_string(), diff --git a/src/memory/verification.rs b/src/memory/verification.rs index 1215ba2..dec76f3 100644 --- a/src/memory/verification.rs +++ b/src/memory/verification.rs @@ -749,7 +749,10 @@ pub fn set_aside_revoked_keys( let name = path.file_name().unwrap_or_default().to_string_lossy(); let aside = path.with_file_name(format!("{name}.revoked-{stamp}")); if aside.symlink_metadata().is_ok() { - bail!("{} already exists; move it away and run `atem unpair` again", aside.display()); + bail!( + "{} already exists; move it away and run `atem unpair` again", + aside.display() + ); } std::fs::rename(path, &aside) .with_context(|| format!("couldn't move {} aside", path.display()))?; @@ -1875,7 +1878,10 @@ mod ceremony_tests { verified_device(&paths, &agent, &fake, EncryptionMode::Off, None); escrow_first(&agent, &fake, &certificate, outcome); let rebooted = test_agent(&paths); - assert_eq!(set_aside_revoked_keys(&paths, Some(&rebooted)).unwrap(), None); + assert_eq!( + set_aside_revoked_keys(&paths, Some(&rebooted)).unwrap(), + None + ); let error = error_of(device_keys_for_verification(&paths, &rebooted)); assert!(error.contains("atem cred unlock"), "{error}"); // Revoked by an Astation that isn't the home: nothing moves either. @@ -1884,7 +1890,10 @@ mod ceremony_tests { Ok(()) }) .unwrap(); - assert_eq!(set_aside_revoked_keys(&paths, Some(&rebooted)).unwrap(), None); + assert_eq!( + set_aside_revoked_keys(&paths, Some(&rebooted)).unwrap(), + None + ); // An unlocked agent verifies with the keys it holds, as before. TrustStore::update(&paths.trust, |store| { store.record_revoked(ASTATION_ID, 1_700_000_000); diff --git a/src/status.rs b/src/status.rs index d7c0635..881bc48 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,4 +1,4 @@ -//! `atem status` and its `atem cred status` alias: one read-only, offline +//! `atem status`: one read-only, offline //! report of the account, the pairing, this device's verification and its //! credentials. It never connects anywhere, never starts the key agent (it //! asks only one that is already running) and never writes a file. Remote @@ -24,6 +24,16 @@ pub const ALREADY_PAIRED: &str = "Already paired. Verify this device: atem pair pub const NOT_PAIRED: &str = "Not paired. Run atem pair first."; /// `atem cred unlock` on a device no Astation verified. pub const UNVERIFIED_UNLOCK: &str = "This device isn't verified. Run atem pair verify first."; +/// `atem pair verify` when the saved pairing doesn't name its Astation. +pub const NO_ASTATION_ID: &str = + "The saved pairing has no Astation id. Run `atem unpair`, then `atem pair`."; +/// What to do when a paired profile can't authenticate: `atem pair` alone +/// would only print the status. +pub const SESSION_GONE: &str = + "The saved Astation session expired or was refused. Run `atem unpair`, then `atem pair`."; +/// Credentials note: the running key agent was started by another build. +const OLDER_BUILD_NOTE: &str = + " (started by another atem build; run atem cred lock, then atem cred unlock)"; /// `atem pair verify` on a device the Astation revoked. pub fn revoked_message(astation_id: &str) -> String { @@ -69,6 +79,8 @@ pub struct StatusReport { pub authorization: Line, pub relay: Line, pub local_agents: Line, + /// Printed after the block (e.g. the reset steps for broken key files). + pub notes: Vec, } /// Where the status is read from. `current()` is this user's profile; @@ -118,12 +130,14 @@ impl Sources { } /// The Astation this profile is paired with: the one `require_pairing` -/// resolves when it does, else the first paired one by id; `others` counts -/// the other paired Astations. +/// resolves when it does, else the first paired one by id (`None`: a paired +/// credential entry that doesn't name its Astation); `others` counts the +/// other pairings; `session_expired` when its saved session expired. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Pairing { - pub astation_id: String, + pub astation_id: Option, pub others: usize, + pub session_expired: bool, } /// The saved sessions, read without touching the file (no permission repair). @@ -146,14 +160,16 @@ fn paired_entries(store: &CredentialStore) -> impl Iterator Option { let store = CredentialStore::load_from(&src.credentials); let sessions = saved_sessions(&src.sessions); - let mut ids: BTreeSet = paired_entries(&store) - .map(|entry| { - entry - .astation_id - .clone() - .unwrap_or_else(|| "unknown".into()) - }) - .collect(); + let mut ids = BTreeSet::new(); + let mut without_id = 0; + for entry in paired_entries(&store) { + match &entry.astation_id { + Some(id) => { + ids.insert(id.clone()); + } + None => without_id += 1, + } + } ids.extend( sessions .active_sessions() @@ -167,14 +183,55 @@ pub fn pairing(src: &Sources) -> Option { .as_deref() .filter(|id| sessions.get(id).is_some()) .map(str::to_string); - let astation_id = resolved.or_else(|| ids.iter().next().cloned())?; - ids.insert(astation_id.clone()); + let astation_id = resolved.or_else(|| ids.iter().next().cloned()); + if astation_id.is_none() && without_id == 0 { + return None; + } + if let Some(id) = &astation_id { + ids.insert(id.clone()); + } + let session_expired = astation_id + .as_deref() + .is_some_and(|id| sessions.expired(id)); Some(Pairing { astation_id, - others: ids.len() - 1, + others: ids.len() + without_id - 1, + session_expired, }) } +/// Whether this profile holds any pairing state `atem unpair` removes: a +/// paired credential entry or a saved session, expired ones included. +pub fn profile_paired(src: &Sources) -> bool { + paired_entries(&CredentialStore::load_from(&src.credentials)) + .next() + .is_some() + || !saved_sessions(&src.sessions).astation_ids().is_empty() +} + +/// `profile_paired` for this user's profile. Tests never read the user's +/// profile: there it is always `false`. +pub fn current_profile_paired() -> bool { + #[cfg(test)] + { + false + } + #[cfg(not(test))] + { + Sources::current().is_ok_and(|src| profile_paired(&src)) + } +} + +/// The advice for a profile that can't authenticate: `SESSION_GONE` when +/// it is paired (`atem pair` would only print the status), else `fallback`. +pub fn pairing_advice(paired: bool, fallback: &str) -> String { + if paired { + SESSION_GONE.to_string() + } else { + fallback.to_string() + } +} + /// This device's verification with the paired Astation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Verification { @@ -182,7 +239,7 @@ pub enum Verification { safety_code: String, }, NotVerified, - /// Revoked by this Astation (a revocation wins over any pin). + /// Revoked by this Astation (its revocation wins over its pin). Revoked { by: String, }, @@ -195,7 +252,10 @@ pub fn verification(keys: &KeyPaths, astation_id: &str) -> Verification { Ok(trust) => trust, Err(error) => return Verification::Unknown(format!("{error:#}")), }; - if let Some(revoked) = trust.revoked() { + if let Some(revoked) = trust + .revoked() + .filter(|revoked| revoked.astation_id == astation_id) + { return Verification::Revoked { by: revoked.astation_id.clone(), }; @@ -218,55 +278,94 @@ fn account_line(store: &CredentialStore, now: u64) -> Line { }) } -fn credentials_line(keys: &KeyPaths, agent: Option<&dyn KeyAgentApi>) -> Line { - let status = agent.map(|agent| agent.status()); - Line::fresh(match status { - Some(Ok(status)) if status.unlocked => "unlocked", - _ if !crate::memory::verification::has_device_keys(keys) => "not set up", - // No running agent holds them: locked. - None | Some(Ok(_)) => "locked", +/// The credentials line, and the notes printed after the block. +fn credentials_line(keys: &KeyPaths, agent: Option<&dyn KeyAgentApi>) -> (Line, Vec) { + let status = match agent.map(|agent| agent.status()) { + None => None, + Some(Ok(status)) => Some(status), Some(Err(error)) => { - return Line::fresh(format!("unknown (key agent not answering: {error:#})")); + let line = format!("unknown (key agent not answering: {error:#})"); + return (Line::fresh(line), Vec::new()); } - }) + }; + let older = if status.as_ref().is_some_and(|status| status.older_build) { + OLDER_BUILD_NOTE + } else { + "" + }; + let value = match &status { + Some(status) if status.unlocked && !status.escrowed => { + "unlocked (storage key not yet held by Astation; run atem cred unlock)" + } + Some(status) if status.unlocked => "unlocked", + _ => { + let problem = TrustStore::load_from(&keys.trust) + .ok() + .and_then(|trust| crate::memory::unlock::key_file_problem(keys, &trust)); + if let Some(problem) = problem { + let line = format!("broken ({problem}){older}"); + let reset = crate::memory::key_agent::RESET.to_string(); + return (Line::fresh(line), vec![reset]); + } + if crate::memory::verification::has_device_keys(keys) { + // No running agent holds them unlocked: locked. + "locked" + } else { + "not set up" + } + } + }; + (Line::fresh(format!("{value}{older}")), Vec::new()) +} + +/// The pairing and verification lines. +fn pairing_lines(src: &Sources, pairing: Option<&Pairing>) -> (Line, Line) { + let Some(pairing) = pairing else { + return (Line::fresh("not paired"), Line::fresh("โ€”")); + }; + let more = match pairing.others { + 0 => String::new(), + n => format!(" (+{n} more)"), + }; + let Some(astation_id) = &pairing.astation_id else { + let line = format!("paired (no Astation id){more}"); + return (Line::fresh(line), Line::fresh("โ€”")); + }; + let expired = if pairing.session_expired { + ", session expired" + } else { + "" + }; + let verification = match verification(&src.keys, astation_id) { + Verification::Verified { safety_code } => format!("verified (safety code {safety_code})"), + Verification::NotVerified => "not verified".into(), + Verification::Revoked { by } => format!("revoked by {by}"), + Verification::Unknown(error) => format!("unknown ({error})"), + }; + ( + Line::fresh(format!("paired ({astation_id}{expired}){more}")), + Line::fresh(verification), + ) } /// The status from `src`, with `agent` the already running key agent (if any). pub fn gather_with(src: &Sources, agent: Option<&dyn KeyAgentApi>) -> StatusReport { let store = CredentialStore::load_from(&src.credentials); let paired = pairing(src); - let (pairing_line, verification_line) = match &paired { - None => (Line::fresh("not paired"), Line::fresh("โ€”")), - Some(pairing) => { - let more = match pairing.others { - 0 => String::new(), - n => format!(" (+{n} more)"), - }; - let verification = match verification(&src.keys, &pairing.astation_id) { - Verification::Verified { safety_code } => { - format!("verified (safety code {safety_code})") - } - Verification::NotVerified => "not verified".into(), - Verification::Revoked { by } => format!("revoked by {by}"), - Verification::Unknown(error) => format!("unknown ({error})"), - }; - ( - Line::fresh(format!("paired ({}){more}", pairing.astation_id)), - Line::fresh(verification), - ) - } - }; + let (pairing_line, verification_line) = pairing_lines(src, paired.as_ref()); + let (credentials, notes) = credentials_line(&src.keys, agent); StatusReport { account: account_line(&store, CredentialEntry::now_secs()), pairing: pairing_line, verification: verification_line, - credentials: credentials_line(&src.keys, agent), + credentials, // No freshness contract yet: never "fresh" without proof. authorization: Line::stale("unknown"), // Status never connects. relay: Line::stale("unknown"), // No shared runtime to ask yet. local_agents: Line::fresh("unknown"), + notes, } } @@ -297,10 +396,14 @@ pub fn render(report: &StatusReport) -> String { let stale = if line.stale { " (stale)" } else { "" }; out.push_str(&format!("{label:<16}{}{stale}\n", line.value)); } + for note in &report.notes { + out.push_str(note); + out.push('\n'); + } out } -/// `atem status` / `atem cred status` (blocking: may ask a running agent). +/// `atem status` (blocking: may ask a running agent). pub fn text(src: &Sources) -> String { render(&gather(src)) } @@ -325,13 +428,14 @@ pub enum VerifyStart { /// not already verified with the paired Astation. pub fn verify_start(src: &Sources) -> Result { let pairing = pairing(src).ok_or_else(|| anyhow!(NOT_PAIRED))?; - match verification(&src.keys, &pairing.astation_id) { + let astation_id = pairing.astation_id.ok_or_else(|| anyhow!(NO_ASTATION_ID))?; + match verification(&src.keys, &astation_id) { Verification::Verified { safety_code } => Ok(VerifyStart::AlreadyVerified( already_verified_message(&safety_code), )), Verification::Revoked { by } => Err(anyhow!(revoked_message(&by))), Verification::Unknown(error) => Err(anyhow!(error)), - Verification::NotVerified => Ok(VerifyStart::Verify(pairing.astation_id)), + Verification::NotVerified => Ok(VerifyStart::Verify(astation_id)), } } @@ -344,32 +448,31 @@ mod tests { const ASTATION: &str = "astation-1"; - struct UnlockedAgent; + /// A running key agent that answers `Status` as told. + struct StubAgent { + unlocked: bool, + escrowed: bool, + build: Option, + } - impl KeyAgentApi for UnlockedAgent { - fn call(&self, request: Request) -> Result { - match request { - Request::Status => Ok(Reply::Status { - unlocked: true, - storage_kid: Some("0a1b2c3d".into()), - escrowed: true, - build: None, - }), - _ => Err(anyhow!("not in this test")), + impl StubAgent { + fn new(unlocked: bool) -> Self { + Self { + unlocked, + escrowed: true, + build: Some(crate::memory::key_agent::build_id().to_string()), } } } - struct LockedAgent; - - impl KeyAgentApi for LockedAgent { + impl KeyAgentApi for StubAgent { fn call(&self, request: Request) -> Result { match request { Request::Status => Ok(Reply::Status { - unlocked: false, - storage_kid: None, - escrowed: false, - build: None, + unlocked: self.unlocked, + storage_kid: Some("0a1b2c3d".into()), + escrowed: self.escrowed, + build: self.build.clone(), }), _ => Err(anyhow!("not in this test")), } @@ -442,8 +545,10 @@ mod tests { sessions.save_to(&path).unwrap(); } + /// A plain `device_keys` file: keys on disk that `key_file_problem` + /// accepts (the agent seals it when it starts). fn write_device_keys(dir: &Path) { - std::fs::write(dir.join("device_keys.sealed"), b"{}").unwrap(); + std::fs::write(dir.join("device_keys"), b"{}").unwrap(); } fn later() -> u64 { @@ -464,6 +569,7 @@ mod tests { authorization: Line::stale("unknown"), relay: Line::stale("unknown"), local_agents: Line::fresh("unknown"), + notes: Vec::new(), }; assert_eq!( render(&report), @@ -517,11 +623,15 @@ mod tests { // No running agent holds the keys. assert_eq!(report.credentials.value, "locked"); assert_eq!( - gather_with(&src, Some(&LockedAgent)).credentials.value, + gather_with(&src, Some(&StubAgent::new(false))) + .credentials + .value, "locked" ); assert_eq!( - gather_with(&src, Some(&UnlockedAgent)).credentials.value, + gather_with(&src, Some(&StubAgent::new(true))) + .credentials + .value, "unlocked" ); } @@ -552,8 +662,9 @@ mod tests { assert_eq!( pairing(&src), Some(Pairing { - astation_id: "astation-0".into(), - others: 0 + astation_id: Some("astation-0".into()), + others: 0, + session_expired: false, }) ); write_paired(dir.path(), "astation-2", later()); @@ -567,8 +678,9 @@ mod tests { assert_eq!( pairing(&src), Some(Pairing { - astation_id: "astation-2".into(), - others: 1 + astation_id: Some("astation-2".into()), + others: 1, + session_expired: false, }) ); assert_eq!( @@ -642,6 +754,142 @@ mod tests { ); } + #[test] + fn an_expired_session_is_shown_and_advises_unpair_then_pair() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + let path = dir.path().join("sessions.json"); + let mut session = AuthSession::new("s".into(), "t".into(), ASTATION.into(), "host".into()); + session.last_activity = 0; + let mut sessions = SessionManager::default(); + sessions.insert_session(session); + sessions.save_to(&path).unwrap(); + let src = Sources::in_dir(dir.path()); + assert_eq!( + report(dir.path()).pairing.value, + "paired (astation-1, session expired)" + ); + assert!(profile_paired(&src)); + assert_eq!( + pairing_advice(true, "run `atem pair`"), + "The saved Astation session expired or was refused. Run `atem unpair`, then `atem pair`." + ); + assert_eq!(pairing_advice(false, "run `atem pair`"), "run `atem pair`"); + assert_eq!( + crate::auth::pairing_gate_message_for("Vault", true), + format!("Vault can't use your Astation pairing. {SESSION_GONE}") + ); + assert_eq!( + crate::auth::pairing_gate_message_for("Vault", false), + crate::auth::pairing_gate_message("Vault") + ); + // An expired session alone (no paired credentials) is no pairing, + // but still a profile to unpair first. + let alone = tempfile::tempdir().unwrap(); + sessions + .save_to(&alone.path().join("sessions.json")) + .unwrap(); + let alone = Sources::in_dir(alone.path()); + assert_eq!(pairing(&alone), None); + assert!(profile_paired(&alone)); + assert!(!profile_paired(&Sources::in_dir( + tempfile::tempdir().unwrap().path() + ))); + } + + #[test] + fn a_pairing_without_an_astation_id_is_never_verified_against_unknown() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.enc"); + let mut entry = CredentialEntry::new_paired( + "a".into(), + "r".into(), + later(), + None, + ASTATION.into(), + true, + 1, + ); + entry.astation_id = None; + CredentialStore { + entries: vec![entry], + } + .save_to(&path) + .unwrap(); + let src = Sources::in_dir(dir.path()); + assert_eq!(report(dir.path()).pairing.value, "paired (no Astation id)"); + assert_eq!(report(dir.path()).verification.value, "โ€”"); + assert_eq!( + verify_start(&src).unwrap_err().to_string(), + "The saved pairing has no Astation id. Run `atem unpair`, then `atem pair`." + ); + } + + #[test] + fn a_revocation_by_another_astation_is_not_this_pairings() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_trust(dir.path(), Some(ASTATION), Some("astation-9")); + let src = Sources::in_dir(dir.path()); + assert_eq!( + report(dir.path()).verification.value, + "verified (safety code MN3H-A74N-FJE4)" + ); + assert!(matches!( + verify_start(&src).unwrap(), + VerifyStart::AlreadyVerified(_) + )); + } + + #[test] + fn broken_key_files_are_shown_with_the_reset_steps() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + // Verified with a home Astation, but no key files at all. + write_trust(dir.path(), Some(ASTATION), None); + let report = report(dir.path()); + assert_eq!(report.credentials.value, "broken (no sealed keys on disk)"); + let text = render(&report); + assert!( + text.ends_with(&format!( + "Local agents: unknown\n{}\n", + crate::memory::key_agent::RESET + )), + "{text}" + ); + } + + #[test] + fn the_running_agent_adds_what_to_do() { + let dir = tempfile::tempdir().unwrap(); + write_paired(dir.path(), ASTATION, later()); + write_trust(dir.path(), Some(ASTATION), None); + write_device_keys(dir.path()); + let src = Sources::in_dir(dir.path()); + let older = StubAgent { + build: Some("other".into()), + ..StubAgent::new(false) + }; + assert_eq!( + gather_with(&src, Some(&older)).credentials.value, + "locked (started by another atem build; run atem cred lock, then atem cred unlock)" + ); + let waiting = StubAgent { + escrowed: false, + ..StubAgent::new(true) + }; + assert_eq!( + gather_with(&src, Some(&waiting)).credentials.value, + "unlocked (storage key not yet held by Astation; run atem cred unlock)" + ); + // Unlocked keys need no reset note. + assert!( + gather_with(&src, Some(&StubAgent::new(true))) + .notes + .is_empty() + ); + } + /// Every file under `dir` with its contents (to prove nothing changed). pub(crate) fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec)> { let mut files = Vec::new(); diff --git a/src/tui/draw.rs b/src/tui/draw.rs index 4a3179e..54974a5 100644 --- a/src/tui/draw.rs +++ b/src/tui/draw.rs @@ -105,8 +105,12 @@ pub(crate) fn paired_status_line( use crate::memory::trust::VerificationStatus; let suffix = match status { Some(VerificationStatus::Verified) => " \u{b7} verified", - Some(VerificationStatus::NotVerified) => " \u{b7} not verified \u{2014} run `atem pair verify`", - Some(VerificationStatus::Revoked) => " \u{b7} revoked \u{2014} run `atem unpair`, then `atem pair`", + Some(VerificationStatus::NotVerified) => { + " \u{b7} not verified \u{2014} run `atem pair verify`" + } + Some(VerificationStatus::Revoked) => { + " \u{b7} revoked \u{2014} run `atem unpair`, then `atem pair`" + } None => "", }; format!("\u{1f517} Paired (SSO: {login_id}){suffix}") diff --git a/src/websocket_client.rs b/src/websocket_client.rs index d68160e..44a2613 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -746,6 +746,9 @@ impl AstationClient { let hostname = crate::auth::get_hostname(); let atem_id = self.resolved_atem_id(&hostname); if !allow_pairing { + if crate::status::current_profile_paired() { + return Err(anyhow!("Pairing required. {}", crate::status::SESSION_GONE)); + } return Err(anyhow!("Pairing required; run 'atem pair'")); } @@ -887,7 +890,13 @@ impl AstationClient { } let proof = self .auth_proof(&challenge)? - .ok_or_else(|| anyhow!("no saved session for this Astation; run `atem pair`"))?; + .ok_or_else(|| { + if crate::status::current_profile_paired() { + anyhow!("no saved session for this Astation. {}", crate::status::SESSION_GONE) + } else { + anyhow!("no saved session for this Astation; run `atem pair`") + } + })?; self.send_message(proof).await?; self.answered_challenge = Some((challenge.astation_id, challenge.challenge)); Ok(()) From c4f26b0e8a6e4997d4f49797185ec6f95f4f700e Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 20:06:47 +0000 Subject: [PATCH 4/7] refactor(cli): pass pairing state in instead of a test hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status::current_profile_paired has no test branch; advice builders take paired: bool (reauth_advice, memory sync/history, auth gate message) and AstationClient advises from its profile (with_profile; None = this user's profile, tests use their temp dir). - Connect errors for a refused, missing or pairing-required session are a typed SessionError; pair verify's session_advice downcasts it instead of matching error text. - connect_astation and unlock_command take the profile's Sources. ๐Ÿค– Built with SMT --- src/app/reauth.rs | 33 +++++++-- src/cli.rs | 57 ++++++++++++--- src/memory/cmd.rs | 37 ++++++---- src/memory/unlock.rs | 31 ++++----- src/status.rs | 15 ++-- src/websocket_client.rs | 150 +++++++++++++++++++++++++++++++++++++--- 6 files changed, 254 insertions(+), 69 deletions(-) diff --git a/src/app/reauth.rs b/src/app/reauth.rs index 608e3ef..781c334 100644 --- a/src/app/reauth.rs +++ b/src/app/reauth.rs @@ -41,11 +41,12 @@ impl App { Ok(Some(AuthResponse::Authenticated)) => self.finish_astation_reauth().await, Ok(Some(AuthResponse::Denied(reason))) => self.fail_astation_reauth(format!( "Astation refused to re-authenticate this device ({reason}). {}", - reauth_advice() + reauth_advice(self.astation_client.profile_paired()) + )), + Ok(Some(AuthResponse::SessionExpired)) => self.fail_astation_reauth(format!( + "Astation session expired. {}", + reauth_advice(self.astation_client.profile_paired()) )), - Ok(Some(AuthResponse::SessionExpired)) => { - self.fail_astation_reauth(format!("Astation session expired. {}", reauth_advice())) - } Err(error) => { self.fail_astation_reauth(format!("Astation re-authentication failed: {error}")) } @@ -131,9 +132,9 @@ impl App { } /// What to do after Astation refused or expired the session: `atem unpair` -/// first on a paired profile (`atem pair` alone only shows the status). -fn reauth_advice() -> String { - crate::status::pairing_advice(crate::status::current_profile_paired(), "Run `atem pair`.") +/// first on a `paired` profile (`atem pair` alone only shows the status). +fn reauth_advice(paired: bool) -> String { + crate::status::pairing_advice(paired, "Run `atem pair`.") } #[cfg(test)] @@ -466,6 +467,24 @@ mod tests { assert_fails_closed(&mut app, &mut peer, "atem pair").await; } + #[tokio::test] + async fn a_paired_profile_is_told_to_unpair_then_pair() { + // The client's profile (a temp dir) holds a saved session: paired. + let (mut app, mut peer) = connected_app().await; + peer.send + .send(auth_required(ASTATION, &"b".repeat(64))) + .unwrap(); + next_auth(&mut app, &mut peer).await; + peer.send + .send(status( + "auth", + &[("status", "denied"), ("message", "revoked")], + )) + .unwrap(); + assert_fails_closed(&mut app, &mut peer, crate::status::SESSION_GONE).await; + assert_eq!(reauth_advice(false), "Run `atem pair`."); + } + #[tokio::test] async fn unanswered_proof_times_out_and_closes_for_reconnect() { let (mut app, mut peer) = connected_app().await; diff --git a/src/cli.rs b/src/cli.rs index 2fdebe2..87ca5f9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1406,7 +1406,8 @@ async fn run_pair_verify(src: &crate::status::Sources) -> Result<()> { crate::status::VerifyStart::Verify(astation_id) => astation_id, }; println!("Connecting to Astation {astation_id}..."); - let mut client = crate::memory::unlock::connect_astation(&src.config, &astation_id, "the paired Astation") + let what = "the paired Astation"; + let mut client = crate::memory::unlock::connect_astation(src, &astation_id, what) .await .map_err(session_advice)?; let verified = crate::pair_verify::verification_result( @@ -1420,14 +1421,19 @@ async fn run_pair_verify(src: &crate::status::Sources) -> Result<()> { } /// `atem pair verify`'s connection failed: when the saved session was refused -/// or is missing, says what to do (`atem pair` alone would only show the status). +/// or is missing (a `SessionError` the connect path returned), says what to do +/// (`atem pair` alone would only show the status), unless it already did. fn session_advice(error: anyhow::Error) -> anyhow::Error { - let text = format!("{error:#}"); - let refused = ["Pairing required", "Authentication denied", "no saved session"] - .iter() - .any(|sign| text.contains(sign)); - if refused && !text.contains(crate::status::SESSION_GONE) { - anyhow::anyhow!("{text}. {}", crate::status::SESSION_GONE) + use crate::websocket_client::{SessionError, SessionProblem}; + let advise = error.downcast_ref::().is_some_and(|session| { + !session.advised + && matches!( + session.problem, + SessionProblem::Refused | SessionProblem::Missing | SessionProblem::PairingRequired + ) + }); + if advise { + anyhow::anyhow!("{error:#}. {}", crate::status::SESSION_GONE) } else { error } @@ -2504,7 +2510,7 @@ mod tests { paired_profile(dir.path()); let mut src = crate::status::Sources::in_dir(dir.path()); let listener = trap(&mut src); - let error = crate::memory::unlock::unlock_command(&src.keys, &src.config) + let error = crate::memory::unlock::unlock_command(&src) .await .unwrap_err(); assert_eq!(error.to_string(), "This device isn't verified. Run atem pair verify first."); @@ -2592,4 +2598,37 @@ mod tests { fn cred_status_is_gone() { assert!(Cli::try_parse_from(["atem", "cred", "status"]).is_err()); } + + #[test] + fn pair_verify_adds_the_advice_only_to_session_errors() { + use crate::websocket_client::{SessionError, SessionProblem}; + let refused = anyhow::Error::new(SessionError::new( + SessionProblem::Refused, + false, + "Authentication denied: no".into(), + )) + .context("couldn't reach the paired Astation (a-1)"); + assert_eq!( + format!("{:#}", session_advice(refused)), + format!( + "couldn't reach the paired Astation (a-1): Authentication denied: no. {}", + crate::status::SESSION_GONE + ) + ); + let advised = anyhow::Error::new(SessionError::new( + SessionProblem::PairingRequired, + true, + format!("Pairing required. {}", crate::status::SESSION_GONE), + )); + assert_eq!( + format!("{:#}", session_advice(advised)), + format!("Pairing required. {}", crate::status::SESSION_GONE) + ); + // Not a session problem (even if the text looks like one): unchanged. + let network = anyhow::anyhow!("Pairing required? WebSocket connection timed out after 5s"); + assert_eq!( + format!("{:#}", session_advice(network)), + "Pairing required? WebSocket connection timed out after 5s" + ); + } } diff --git a/src/memory/cmd.rs b/src/memory/cmd.rs index a4aa6f8..23e2007 100644 --- a/src/memory/cmd.rs +++ b/src/memory/cmd.rs @@ -129,8 +129,8 @@ fn not_paired_msg(paired: bool) -> String { } /// The error for skill history/restore without a usable pairing. -fn history_unpaired() -> anyhow::Error { - anyhow!(not_paired_history_msg(crate::status::current_profile_paired())) +fn history_unpaired(paired: bool) -> anyhow::Error { + anyhow!(not_paired_history_msg(paired)) } /// `NOT_PAIRED_HISTORY_MSG`, or the advice for a paired profile. @@ -161,10 +161,11 @@ fn astation_status_line(astation_id: &str) -> String { } /// `problem`: why `client()` couldn't build a client, if it couldn't. -fn sync_status_line(out: &SyncOutcome, problem: Option<&PairingProblem>) -> String { +/// `paired`: whether this profile is paired (`status::profile_paired`). +fn sync_status_line(out: &SyncOutcome, problem: Option<&PairingProblem>, paired: bool) -> String { match problem { Some(PairingProblem::NotConfigured) => NOT_CONFIGURED_MSG.to_string(), - Some(PairingProblem::NotPaired) => not_paired_msg(crate::status::current_profile_paired()), + Some(PairingProblem::NotPaired) => not_paired_msg(paired), None if out.offline => "Relay unreachable โ€” changes stay queued and sync next time.".to_string(), None if out.relay_error => format!( "Sync incomplete โ€” pushed {} change(s), pulled {} update(s); the rest stay queued (see notes).", @@ -174,14 +175,14 @@ fn sync_status_line(out: &SyncOutcome, problem: Option<&PairingProblem>) -> Stri } } -fn print_outcome(out: &SyncOutcome, problem: Option<&PairingProblem>) { +fn print_outcome(out: &SyncOutcome, problem: Option<&PairingProblem>, paired: bool) { if let Some(h) = &out.harvest { println!("Harvested from Claude: {} new, {} outdated", h.added, h.invalidated); for hb in &h.held_back { println!(" held back (possible credential): {}", hb); } } - println!("{}", sync_status_line(out, problem)); + println!("{}", sync_status_line(out, problem, paired)); for n in &out.notes { println!("note: {}", n); } @@ -486,7 +487,7 @@ pub async fn handle_sync(no_harvest: bool, allow_tracked: bool) -> Result<()> { let c = client().await; let opts = SyncOptions { harvest: !no_harvest && harvest_enabled() }; let out = sync::run_sync(&store, c.as_ref().ok(), &ctx, &opts).await?; - print_outcome(&out, c.as_ref().err()); + print_outcome(&out, c.as_ref().err(), crate::status::current_profile_paired()); Ok(()) } @@ -773,7 +774,7 @@ pub async fn handle_skill(command: SkillCommands) -> Result<()> { check_history_name(&name)?; let scope = skill_scope(&scope)?; let project = project_for_scope(scope, &ctx, None)?; - let c = client().await.map_err(|_| history_unpaired())?; + let c = client().await.map_err(|_| history_unpaired(crate::status::current_profile_paired()))?; let versions = c.skill_versions(scope, &project, &name).await .map_err(|e| sync::skill_history_error(e, &name, None))?; if versions.is_empty() { @@ -787,7 +788,7 @@ pub async fn handle_skill(command: SkillCommands) -> Result<()> { check_history_name(&name)?; let scope = skill_scope(&scope)?; let project = project_for_scope(scope, &ctx, None)?; - let c = client().await.map_err(|_| history_unpaired())?; + let c = client().await.map_err(|_| history_unpaired(crate::status::current_profile_paired()))?; let restored = sync::restore_skill(&store, &c, &ctx.atem_id, scope, &project, &name, version).await?; println!("Restored {} v{} as v{}.", name, version, restored.version); let report = best_effort_sync_report(&store, &ctx).await; @@ -1059,8 +1060,16 @@ mod tests { #[test] fn sync_status_line_distinguishes_pairing_problems_from_offline() { let offline = SyncOutcome { offline: true, ..Default::default() }; - assert_eq!(sync_status_line(&offline, Some(&PairingProblem::NotConfigured)), NOT_CONFIGURED_MSG); - assert_eq!(sync_status_line(&offline, Some(&PairingProblem::NotPaired)), NOT_PAIRED_MSG); + assert_eq!(sync_status_line(&offline, Some(&PairingProblem::NotConfigured), false), NOT_CONFIGURED_MSG); + assert_eq!(sync_status_line(&offline, Some(&PairingProblem::NotPaired), false), NOT_PAIRED_MSG); + // A paired profile (temp dir with a saved session) is told to unpair first. + let dir = tempfile::tempdir().unwrap(); + let mut sessions = crate::auth::SessionManager::default(); + sessions.insert_session(crate::auth::AuthSession::new("s".into(), "t".into(), "a-1".into(), "h".into())); + sessions.save_to(&dir.path().join("sessions.json")).unwrap(); + let paired = crate::status::profile_paired(&crate::status::Sources::in_dir(dir.path())); + assert_eq!(sync_status_line(&offline, Some(&PairingProblem::NotPaired), paired), not_paired_msg(true)); + assert_eq!(history_unpaired(paired).to_string(), crate::status::SESSION_GONE); assert!(NOT_CONFIGURED_MSG.contains("astation_relay_code") && NOT_CONFIGURED_MSG.contains("stay queued")); assert!(NOT_PAIRED_MSG.contains("atem pair") && NOT_PAIRED_MSG.contains("stay queued")); assert_eq!( @@ -1069,11 +1078,11 @@ mod tests { ); assert_eq!(not_paired_history_msg(false), NOT_PAIRED_HISTORY_MSG); assert_eq!(not_paired_history_msg(true), crate::status::SESSION_GONE); - assert!(sync_status_line(&offline, None).starts_with("Relay unreachable")); + assert!(sync_status_line(&offline, None, false).starts_with("Relay unreachable")); let done = SyncOutcome { pushed: 2, pulled: 3, ..Default::default() }; - assert_eq!(sync_status_line(&done, None), "Pushed 2 change(s), pulled 3 update(s)."); + assert_eq!(sync_status_line(&done, None, false), "Pushed 2 change(s), pulled 3 update(s)."); let partial = SyncOutcome { pushed: 1, relay_error: true, ..Default::default() }; - assert!(sync_status_line(&partial, None).starts_with("Sync incomplete โ€” pushed 1 change(s)")); + assert!(sync_status_line(&partial, None, false).starts_with("Sync incomplete โ€” pushed 1 change(s)")); } #[test] diff --git a/src/memory/unlock.rs b/src/memory/unlock.rs index e403abe..6c02a3e 100644 --- a/src/memory/unlock.rs +++ b/src/memory/unlock.rs @@ -695,7 +695,7 @@ pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { println!("Sent project suggestions to Astation."); Ok(()) } - CredCommands::Unlock => unlock_command(&paths, &crate::config::AtemConfig::load()?).await, + CredCommands::Unlock => unlock_command(&crate::status::Sources::current()?).await, CredCommands::Lock => { let message = blocking(|| lock_keys_message(running_agent().as_deref())).await?; println!("{message}"); @@ -706,23 +706,20 @@ pub async fn handle_cred(command: crate::cli::CredCommands) -> Result<()> { /// Connects to the home Astation without pairing. async fn connect_home(home: &str) -> Result { - connect_astation( - &crate::config::AtemConfig::load()?, - home, - "this device's home Astation", - ) - .await + let src = crate::status::Sources::current()?; + connect_astation(&src, home, "this device's home Astation").await } /// Connects to `astation_id` (`what` names it in errors) with the saved -/// session, never pairing: locally when the local Astation is that one, -/// else through its relay identity room. +/// session of the profile `src`, never pairing: locally when the local +/// Astation is that one, else through its relay identity room. pub(crate) async fn connect_astation( - config: &crate::config::AtemConfig, + src: &crate::status::Sources, astation_id: &str, what: &str, ) -> Result { - let mut local = AstationClient::new(); + let config = &src.config; + let mut local = AstationClient::new().with_profile(src.clone()); if local .connect_without_pairing(config.astation_ws()) .await @@ -732,7 +729,7 @@ pub(crate) async fn connect_astation( return Ok(local); } drop(local); - let mut client = AstationClient::new(); + let mut client = AstationClient::new().with_profile(src.clone()); client .connect_relay_identity_without_pairing(config.astation_relay_url(), astation_id) .await @@ -745,10 +742,8 @@ pub(crate) async fn connect_astation( /// `atem cred unlock`: never pairs or verifies; an unverified device stops /// here, offline. -pub(crate) async fn unlock_command( - paths: &KeyPaths, - config: &crate::config::AtemConfig, -) -> Result<()> { +pub(crate) async fn unlock_command(src: &crate::status::Sources) -> Result<()> { + let paths = &src.keys; let trust = TrustStore::load_from(&paths.trust)?; let home = match (trust.home(), trust.recorded_home()) { (Some(home), _) => home.to_string(), @@ -768,12 +763,12 @@ pub(crate) async fn unlock_command( // Astation sends the signed state on connect; applying it finishes // the migration and sends the signed completion report. println!("Finishing the encryption migration with Astationโ€ฆ"); - let mut client = connect_astation(config, &home, "this device's home Astation").await?; + let mut client = connect_astation(src, &home, "this device's home Astation").await?; finish_migration(&mut client, MIGRATION_WAIT).await; } return Ok(()); } - let mut client = connect_astation(config, &home, "this device's home Astation").await?; + let mut client = connect_astation(src, &home, "this device's home Astation").await?; unlock_and_rotate(&mut client, &agent, paths, &home, UNLOCK_TIMEOUT).await?; // The report sent on connect was skipped (the keys were still locked); // send it now, on the same connection. Best effort, like on connect. diff --git a/src/status.rs b/src/status.rs index 881bc48..69099f8 100644 --- a/src/status.rs +++ b/src/status.rs @@ -209,17 +209,10 @@ pub fn profile_paired(src: &Sources) -> bool { || !saved_sessions(&src.sessions).astation_ids().is_empty() } -/// `profile_paired` for this user's profile. Tests never read the user's -/// profile: there it is always `false`. +/// `profile_paired` for this user's profile (production entry points only; +/// everything below them takes `paired` or a `Sources`). pub fn current_profile_paired() -> bool { - #[cfg(test)] - { - false - } - #[cfg(not(test))] - { - Sources::current().is_ok_and(|src| profile_paired(&src)) - } + Sources::current().is_ok_and(|src| profile_paired(&src)) } /// The advice for a profile that can't authenticate: `SESSION_GONE` when @@ -776,7 +769,7 @@ mod tests { ); assert_eq!(pairing_advice(false, "run `atem pair`"), "run `atem pair`"); assert_eq!( - crate::auth::pairing_gate_message_for("Vault", true), + crate::auth::pairing_gate_message_for("Vault", profile_paired(&src)), format!("Vault can't use your Astation pairing. {SESSION_GONE}") ); assert_eq!( diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 44a2613..8d4904d 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -468,8 +468,45 @@ pub struct AstationClient { /// before Astation said who it is (a pairing-code relay connection never /// authenticates), or during device verification. held_encryption: Vec, + /// The profile whose pairing errors advise on (`with_profile`); `None` + /// is this user's profile. + profile: Option, } +/// Why a connection couldn't authenticate with the saved session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionProblem { + /// Astation denied the saved session. + Refused, + /// No saved session for this Astation. + Missing, + /// The session didn't authenticate and pairing isn't allowed here. + PairingRequired, +} + +/// A `SessionProblem` with its message; `advised` when the message already +/// says to run `atem unpair`, then `atem pair` (a paired profile). +#[derive(Debug)] +pub struct SessionError { + pub problem: SessionProblem, + pub advised: bool, + message: String, +} + +impl SessionError { + pub fn new(problem: SessionProblem, advised: bool, message: String) -> Self { + Self { problem, advised, message } + } +} + +impl std::fmt::Display for SessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for SessionError {} + /// Status of an encryption message held for later; internal, not shown. pub const HOLDING_ENCRYPTION: &str = "Holding an encryption message until Astation's identity is known"; @@ -493,6 +530,21 @@ impl AstationClient { connected_astation_id: None, answered_challenge: None, held_encryption: Vec::new(), + profile: None, + } + } + + /// Advise on pairing errors from `profile` instead of this user's profile. + pub(crate) fn with_profile(mut self, profile: crate::status::Sources) -> Self { + self.profile = Some(profile); + self + } + + /// Whether the client's profile is paired (`status::profile_paired`). + pub(crate) fn profile_paired(&self) -> bool { + match &self.profile { + Some(profile) => crate::status::profile_paired(profile), + None => crate::status::current_profile_paired(), } } @@ -505,6 +557,9 @@ impl AstationClient { pub(crate) fn new_with_test_state(atem_id: &str, session_path: std::path::PathBuf) -> Self { let mut client = Self::new(); client.atem_id_override = Some(atem_id.to_string()); + if let Some(dir) = session_path.parent() { + client.profile = Some(crate::status::Sources::in_dir(dir)); + } client.relay_code_path_override = Some(session_path.with_file_name("relay-code")); client.session_path_override = Some(session_path); client @@ -738,7 +793,8 @@ impl AstationClient { // Fall through to pairing. Some(AuthResponse::SessionExpired) | None => {} Some(AuthResponse::Denied(msg)) => { - return Err(anyhow!("Authentication denied: {}", msg)); + let message = format!("Authentication denied: {}", msg); + return Err(SessionError::new(SessionProblem::Refused, false, message).into()); } } } @@ -746,10 +802,13 @@ impl AstationClient { let hostname = crate::auth::get_hostname(); let atem_id = self.resolved_atem_id(&hostname); if !allow_pairing { - if crate::status::current_profile_paired() { - return Err(anyhow!("Pairing required. {}", crate::status::SESSION_GONE)); - } - return Err(anyhow!("Pairing required; run 'atem pair'")); + let paired = self.profile_paired(); + let message = if paired { + format!("Pairing required. {}", crate::status::SESSION_GONE) + } else { + "Pairing required; run 'atem pair'".to_string() + }; + return Err(SessionError::new(SessionProblem::PairingRequired, paired, message).into()); } // Session auth failed or no session - use explicit pairing. @@ -891,11 +950,13 @@ impl AstationClient { let proof = self .auth_proof(&challenge)? .ok_or_else(|| { - if crate::status::current_profile_paired() { - anyhow!("no saved session for this Astation. {}", crate::status::SESSION_GONE) + let paired = self.profile_paired(); + let message = if paired { + format!("no saved session for this Astation. {}", crate::status::SESSION_GONE) } else { - anyhow!("no saved session for this Astation; run `atem pair`") - } + "no saved session for this Astation; run `atem pair`".to_string() + }; + anyhow::Error::from(SessionError::new(SessionProblem::Missing, paired, message)) })?; self.send_message(proof).await?; self.answered_challenge = Some((challenge.astation_id, challenge.challenge)); @@ -1034,7 +1095,8 @@ impl AstationClient { pub async fn report_credential_projects(&self) -> Result<()> { use crate::memory::key_agent::{blocking, running_agent}; let astation_id = self.connected_astation_id.clone().ok_or_else(|| anyhow!("Astation identity is not known"))?; - crate::auth::require_pairing("credential project discovery")?; + crate::auth::pairing_session() + .map_err(|_| anyhow!("this machine isn't paired with an Astation"))?; let report = blocking(move || { let agent = running_agent().ok_or_else(|| anyhow!("key agent is not running"))?; if !agent.status()?.unlocked { return Err(anyhow!("key agent is locked")); } @@ -2191,6 +2253,74 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn a_paired_profile_without_a_usable_session_is_told_to_unpair_then_pair() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test Astation"); + let address = listener.local_addr().unwrap(); + let astation_id = format!("astation-proof-only-{}", uuid::Uuid::new_v4()); + + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("test Astation did not accept"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("WebSocket handshake failed"); + let auth_required = AstationMessage::StatusUpdate { + status: "auth_required".to_string(), + data: std::collections::HashMap::from([ + ("astation_id".to_string(), astation_id), + ("challenge".to_string(), "d".repeat(64)), + ("transport".to_string(), "lan".to_string()), + ("protocol".to_string(), "2".to_string()), + ]), + }; + socket + .send(Message::Text( + serde_json::to_string(&auth_required).unwrap(), + )) + .await + .unwrap(); + + match tokio::time::timeout(Duration::from_millis(250), socket.next()).await { + Err(_) | Ok(None) | Ok(Some(Err(_))) | Ok(Some(Ok(Message::Close(_)))) => {} + Ok(Some(Ok(Message::Text(text)))) => { + panic!("background reconnect unexpectedly sent pairing data: {text}") + } + Ok(Some(other)) => panic!("unexpected WebSocket frame: {other:?}"), + } + }); + + let (state, mut client) = isolated_client("atem-test-proof-only"); + // A paired credential entry in the client's profile (a temp dir). + let mut store = crate::credentials::CredentialStore { entries: Vec::new() }; + store.upsert(crate::credentials::CredentialEntry::new_paired( + "a".into(), + "r".into(), + u64::MAX, + None, + "astation-x".into(), + true, + 1, + )); + store.save_to(&state.path().join("credentials.enc")).unwrap(); + let error = client + .connect_without_pairing(&format!("ws://{address}")) + .await + .expect_err("proof-only connection unexpectedly started pairing"); + assert_eq!( + error.to_string(), + format!("Pairing required. {}", crate::status::SESSION_GONE) + ); + let session = error.downcast_ref::().expect("typed session error"); + assert_eq!(session.problem, SessionProblem::PairingRequired); + assert!(session.advised); + server.await.unwrap(); + } + #[tokio::test] async fn practical_websocket_bounds_authentication_after_challenge() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") From 99fa4ace592058351d7ced2661bba4e8409a604f Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 21:14:03 +0000 Subject: [PATCH 5/7] fix(cred): recovery moves key files aside instead of deleting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset advice for broken key files no longer says to delete them: move them into a backup folder (device_keys.sealed holds the memory key and its older keys), then atem unpair and atem pair, so the new trust is set up explicitly. Per Astation's handoff (item 5). ๐Ÿค– Built with SMT --- designs/e2e-encryption.md | 15 ++++++++++----- src/memory/key_agent.rs | 2 +- src/memory/unlock.rs | 2 +- src/memory/verification.rs | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/designs/e2e-encryption.md b/designs/e2e-encryption.md index 26db552..48676ba 100644 --- a/designs/e2e-encryption.md +++ b/designs/e2e-encryption.md @@ -1674,11 +1674,16 @@ If this device's keys can't be unlocked (the sealed file or `atem status` and `atem cred unlock` print: -> To start over: delete ~/.config/atem/device_keys, device_keys.sealed, -> device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and -> cred_state.json, then run `atem pair`. device_keys.sealed also holds the -> memory encryption key and its older keys: Astation grants the current key -> again, older keys may not come back. +> To set this device up again: move ~/.config/atem/device_keys, +> device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, +> unlock_auth_key and cred_state.json into a backup folder (don't delete them: +> device_keys.sealed holds the memory encryption key and its older keys, which +> older data may need), then run `atem unpair` and `atem pair`. Astation grants +> the current memory key again; older keys come back only from that backup. + +Key files are never deleted as routine recovery: setting a device up again +moves them aside, and `atem unpair` / `atem pair` establish the new trust +explicitly (verification never replaces trusted keys on its own). An Astation rolled back or restored from a backup to an older storage key counts as having lost it: the storage key rotates at every unlock, so the diff --git a/src/memory/key_agent.rs b/src/memory/key_agent.rs index 3631836..c319af2 100644 --- a/src/memory/key_agent.rs +++ b/src/memory/key_agent.rs @@ -68,7 +68,7 @@ pub fn build_id() -> &'static str { } /// Appended to key-file errors, which never stop the agent (it starts locked). -pub const RESET: &str = "To start over: delete ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json, then run `atem pair verify`. device_keys.sealed also holds the memory encryption key and its older keys: Astation grants the current key again, older keys may not come back."; +pub const RESET: &str = "To set this device up again: move ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json into a backup folder (don't delete them: device_keys.sealed holds the memory encryption key and its older keys, which older data may need), then run `atem unpair` and `atem pair`. Astation grants the current memory key again; older keys come back only from that backup."; /// What the agent serves. Only the same user can reach it (agent_socket.rs). /// Fields that carry secrets are `Zeroizing`. diff --git a/src/memory/unlock.rs b/src/memory/unlock.rs index 6c02a3e..fc8382f 100644 --- a/src/memory/unlock.rs +++ b/src/memory/unlock.rs @@ -1781,7 +1781,7 @@ mod tests { fn the_reset_names_every_key_file_and_the_trust_state() { assert_eq!( RESET, - "To start over: delete ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json, then run `atem pair verify`. device_keys.sealed also holds the memory encryption key and its older keys: Astation grants the current key again, older keys may not come back." + "To set this device up again: move ~/.config/atem/device_keys, device_keys.sealed, device_keys.sealed.next, device_keys.sealed.prev, unlock_auth_key and cred_state.json into a backup folder (don't delete them: device_keys.sealed holds the memory encryption key and its older keys, which older data may need), then run `atem unpair` and `atem pair`. Astation grants the current memory key again; older keys come back only from that backup." ); } diff --git a/src/memory/verification.rs b/src/memory/verification.rs index dec76f3..72f9dd3 100644 --- a/src/memory/verification.rs +++ b/src/memory/verification.rs @@ -489,7 +489,7 @@ pub fn complete_verification( } if staged.home_is_set() && staged.home() != Some(astation_id) { bail!( - "this device's home Astation is {}; verify with it, or start over (delete ~/.config/atem/cred_state.json too)", + "this device's home Astation is {}; verify with it, or set the device up again (move ~/.config/atem/cred_state.json into a backup folder too, then `atem unpair` and `atem pair`)", staged.recorded_home().unwrap_or_default() ); } From bf0df23daa87a3a0deff8460dd0527ef05e75853 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 21:33:00 +0000 Subject: [PATCH 6/7] feat(pairing): host trust generation and replies bound to the pairing they started under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pairing.json (0600, atomic, flock on pairing.lock): host_trust_gen plus the pairing binding (random id, Astation, generation). atem pair binds in the next generation; atem unpair commits the next generation without a binding before it removes credentials and sessions. A pairing from before the file is generation 1, computed by readers and recorded on first capture; atem status stays read-only and shows "trust gen N". - Asynchronous Astation work captures the state and the session its proof used, and applies replies only while both are unchanged, under the lock: connect and re-authentication (no session refresh or relay code for a replaced pairing), credentialSync, encryptionMode/keyGrant, deviceVerified, unlock grants, storage-key acks/confirms and credential-project reports (key-agent requests through BoundAgent). Encryption messages on a connection without a binding yet are held. - Unpair keeps memory, vault data, trust pins and keys. ๐Ÿค– Built with SMT --- AGENTS.md | 5 +- src/app.rs | 31 +-- src/app/reauth.rs | 89 ++++++- src/cli.rs | 122 +++++++--- src/main.rs | 1 + src/memory/unlock.rs | 99 ++++++++ src/pair_verify.rs | 73 ++++++ src/pairing_binding.rs | 502 ++++++++++++++++++++++++++++++++++++++++ src/sso_auth.rs | 3 + src/status.rs | 63 ++++- src/websocket_client.rs | 338 ++++++++++++++++++++++++--- 11 files changed, 1247 insertions(+), 79 deletions(-) create mode 100644 src/pairing_binding.rs diff --git a/AGENTS.md b/AGENTS.md index 0bd352d..6887fcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,7 @@ src/ โ”œโ”€โ”€ dispatch.rs # Work item dispatcher for mark tasks โ”œโ”€โ”€ pair_verify.rs # atem pair / atem pair verify device verification: /dev/tty code confirmation, verifyAbort on failure โ”œโ”€โ”€ status.rs # atem status: read-only, offline; also decides pair (no-op when paired), pair verify and the cred unlock gate +โ”œโ”€โ”€ pairing_binding.rs # pairing.json: host trust generation + pairing binding; Captured (async Astation work applies replies only under the pairing it started under), BoundAgent โ”œโ”€โ”€ vault_client.rs # atem vault CLI client (relay /api/vault: request builders, renderers, executor) โ”œโ”€โ”€ memory/ # Atem Memory: shared memory + skills across agents and machines โ”‚ โ”œโ”€โ”€ model.rs # Memory/Skill types, hashing @@ -225,6 +226,7 @@ Key methods: | `credentials.enc` | SSO + paired tokens (multi-entry `Vec`) | AES-256-GCM (machine-bound) | | `project_cache.enc` | All projects + `current_app_id` (selected project reference) | AES-256-GCM (machine-bound) | | `sessions.json` | Per-Astation device session IDs and tokens | None (chmod 0600) | +| `pairing.json` | `{version, host_trust_gen, binding_id?, astation_id?, created_at?}`: the host trust generation (never decreases or repeats) and the current pairing binding (random id per `atem pair`; `atem unpair` commits the next generation without one before removing anything); a pairing from before the file is generation 1 (`legacy-`), computed by readers and recorded by the first capture; writes hold an `flock` on `pairing.lock` | None (chmod 0600, written atomically) | | `knowledge.db` | Atem Memory local store: memories (with `deleted_at`/`valid_at`/`invalid_at`/`superseded_by`), the `memories_fts` FTS5 trigram search index, skills, harvest map, local `replacements`, and the pending-sync queue (SQLite, mode 0600; holds no secrets โ€” credentials are refused before storing; migrated in place on open) | None | | `device_keys` | Plain device keys: a build-step-1 device's, or a freshly verified device's until its first escrow; the key agent seals them into `device_keys.sealed` at each start while this file exists, and deletes it once the home Astation is known to hold the storage key | None (chmod 0600, written atomically) | | `device_keys.sealed` | This device's X25519 device key, Ed25519 signing key and account keys (`K` + previous, step 2b) (`{version, device_id, storage_kid, nonce, ciphertext}`); `device_keys.sealed.next` exists only mid-rotation, `device_keys.sealed.prev` (the replaced file) until an unlock proves Astation holds the current key | XChaCha20-Poly1305 under the storage key (held only by the home Astation, replaced at every unlock) | @@ -256,7 +258,8 @@ Key methods: - `atem pair [--save]` โ€” on an already paired profile (even unverified or revoked) prints the status plus `Already paired. Verify this device: atem pair verify ยท Replace the pairing: atem unpair, then atem pair` and exits 0 without connecting or changing anything. Otherwise: connect to Astation, send `PairSavePreference`, wait for `CredentialSync`, write paired entry, then verify the device: commit-then-reveal (`verifyCommit` โ†’ `verifyKeys` โ†’ `verifyReveal`), both sides show a 12-character safety code, the user answers `codes match? [y/N]` (read from `/dev/tty`, flushed first, never stdin; no terminal fails the verification) and confirms on Astation with Touch ID, and atem applies the signed `deviceVerified` atomically (pins, device keys, signed state, grants; nothing on failure). If `K` is needed but missing it sends `keyRequest`. Astations that don't answer within 15s leave the device as it was (see `designs/e2e-encryption.md`). Any later failure (code refused, no terminal, 300 s Touch ID timeout, lost connection, apply error) drops the pending pin and sends `verifyAbort` with delivery confirmed (not when Astation itself aborted); the pairing stays saved and `atem pair` exits nonzero (`atem pair verify` retries). A failed `keyRequest` after success is only a warning (`atem cred unlock` asks again). Logic and tests: `src/pair_verify.rs`. On the home Astation's first verification the fresh keys are sealed and the first storage key is escrowed (`storageKeyRotate` with an empty old kid, then confirmed) in the same run - `atem pair verify` โ€” needs a pairing (`Not paired. Run atem pair first.`, exit 1; a paired entry without an Astation id: `The saved pairing has no Astation id. Run `atem unpair`, then `atem pair`.`); already verified with the paired Astation โ†’ `Already verified (safety code โ€ฆ).`, no connection; revoked โ†’ `This device was revoked by Astation . Run atem unpair, then atem pair, to set it up again.`, exit 1, nothing moved. Otherwise runs the same verification as `atem pair` (`run_device_verification`) over a connection authenticated with the saved session (local Astation if it is the paired one, else its relay identity room; never pairing). A cancelled or failed verification keeps the pairing - `atem cred projects|unlock|lock` (tier 2; the key state is shown by `atem status`) โ€” send the current Git project and the account's remembered project names to the home Astation's credential picker (keys must be unlocked; connects within 15 s; see `docs/credential-project-reporting.md`); Touch ID unlock through the home Astation (waits up to 300 s, then rotates the storage key; when already unlocked it still finishes a pending migration; an unverified device stops offline with `This device isn't verified. Run atem pair verify first.`, and it never starts pairing or verification); wipe the unlocked keys and stop the agent. `atem key-agent` is the hidden agent process itself -- `atem unpair [--yes]` โ€” remove all paired credential entries and saved Astation sessions (the SSO login stays), and set revoked keys aside (see Key agent); a failure to set them aside, or a revocation left recorded (agent unlocked, or not the home Astation), is printed as a notice and the unpair still completes. It, `atem memory purge` and `atem skill purge` confirm first (`cli::confirm_destructive`): `--yes` skips the question, and without a terminal they refuse unless `--yes` is given. The purge prompts show the id and scope only, never the content, since a purged item may hold a credential +- Host trust generation (`pairing_binding.rs`): asynchronous Astation work (connect and re-authentication, `atem pair`/`pair verify`, `atem cred unlock` rotation/escrow, credential-project reports, `credentialSync`, `encryptionMode`/`keyGrant`) captures `pairing.json`'s state and the saved session its proof used when it starts (`AstationClient::capture`), and applies a reply only while both are unchanged, under `pairing.lock` (`Captured::apply`; key-agent requests through `BoundAgent`). A reply that arrives after `atem unpair` or a new `atem pair` is dropped (`pairing_binding::CHANGED`): no session refresh, no relay code, no credentials, pins, grants or storage-key promotion. Encryption messages on a connection without a binding yet are held. `atem status` shows `paired (, trust gen N)` +- `atem unpair [--yes]` โ€” first commits the next host trust generation (no binding), then removes all paired credential entries and saved Astation sessions (the SSO login, memory, vault data, trust pins and keys stay), and set revoked keys aside (see Key agent); a failure to set them aside, or a revocation left recorded (agent unlocked, or not the home Astation), is printed as a notice and the unpair still completes. It, `atem memory purge` and `atem skill purge` confirm first (`cli::confirm_destructive`): `--yes` skips the question, and without a terminal they refuse unless `--yes` is given. The purge prompts show the id and scope only, never the content, since a purged item may hold a credential - `valid_token(connected_astation_id, sso_url)` โ€” resolves via priority chain and returns an access token. Within 60s of expiry, an `sso` entry refreshes itself; a paired entry never does (Agora rotates refresh tokens, so it would invalidate Astation's copy): atem connects to Astation, which sends a fresh `credentialSync` on connect. **BFF API** (`agora_api.rs`): diff --git a/src/app.rs b/src/app.rs index 9049b22..18889d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1602,19 +1602,24 @@ impl App { astation_id, save_credentials, } => { - let mut store = crate::credentials::CredentialStore::load(); - let now = crate::credentials::CredentialEntry::now_secs(); - store.upsert(crate::credentials::CredentialEntry::new_paired( - access_token, - refresh_token, - expires_at, - login_id.clone(), - astation_id.clone(), - save_credentials, - now, - )); - if let Err(e) = store.save() { - self.status_message = Some(format!("Failed to save paired credentials: {}", e)); + // Only for the pairing this connection authenticated under: + // never re-creates a pairing removed (or replaced) meanwhile. + let saved = self.astation_client.apply_bound(&astation_id, || { + let mut store = crate::credentials::CredentialStore::load(); + let now = crate::credentials::CredentialEntry::now_secs(); + store.upsert(crate::credentials::CredentialEntry::new_paired( + access_token, + refresh_token, + expires_at, + login_id.clone(), + astation_id.clone(), + save_credentials, + now, + )); + store.save() + }); + if let Err(e) = saved { + self.status_message = Some(format!("Paired credentials not saved: {e:#}")); } else { self.connected_astation_id = Some(astation_id.clone()); let preview = login_id.as_deref().unwrap_or("โ€”"); diff --git a/src/app/reauth.rs b/src/app/reauth.rs index 781c334..e36f681 100644 --- a/src/app/reauth.rs +++ b/src/app/reauth.rs @@ -121,9 +121,14 @@ impl App { /// Back online; report state as a fresh connect does. Remote viewers /// subscribe again (view-only); no lease or input is resumed. async fn finish_astation_reauth(&mut self) { + // A success for a pairing or session replaced meanwhile (atem unpair, + // atem pair) is dropped: nothing is refreshed and the link stays down. + if let Err(error) = self.astation_client.reauthenticated() { + self.fail_astation_reauth(format!("Astation re-authentication ignored: {error:#}")); + return; + } self.astation_reauth = None; self.astation_connected = true; - self.astation_client.reauthenticated(); self.rebuild_menu(); self.status_message = Some("Astation reconnected".to_string()); let agents = self.agent_registry.all(); @@ -596,6 +601,88 @@ mod tests { assert_eq!(session.token, TOKEN); } + /// A re-authentication success that arrives after `atem unpair` (and + /// `atem pair` again, with a new session) belongs to the old pairing: it + /// is dropped, the link stays down, and nothing is refreshed or written. + #[tokio::test] + async fn a_delayed_reauth_success_after_unpair_or_re_pair_is_ignored() { + let replies = [ + status("authenticated", &[("method", "session_proof")]), + status("auth", &[("status", "granted")]), + ]; + for re_pair in [false, true] { + for reply in replies.clone() { + let (mut app, mut peer) = connected_app().await; + peer.send + .send(auth_required(ASTATION, &"b".repeat(64))) + .unwrap(); + next_auth(&mut app, &mut peer).await; + let dir = peer._state.path().to_path_buf(); + let src = crate::status::Sources::in_dir(&dir); + crate::pairing_binding::commit_unpair(&src).unwrap(); + let sessions_path = dir.join("sessions.json"); + if re_pair { + let before = crate::pairing_binding::capture(&src).unwrap(); + crate::pairing_binding::bind_new(&src, &before, ASTATION).unwrap(); + let mut sessions = crate::auth::SessionManager::default(); + sessions.insert_session(crate::auth::AuthSession::new( + "new-session".into(), + "new-token".into(), + ASTATION.into(), + "test-machine".into(), + )); + sessions.save_to(&sessions_path).unwrap(); + } + let before = crate::status::tests::snapshot(&dir); + peer.send.send(reply).unwrap(); + let frames = until_closed(&mut app, &mut peer).await; + assert!(!app.astation_connected); + assert!(app.astation_reauth.is_none()); + assert!( + !frames + .iter() + .any(|frame| matches!(frame, AstationMessage::AgentListResponse { .. })), + "{frames:?}" + ); + assert_eq!( + crate::status::tests::snapshot(&dir), + before, + "re_pair {re_pair}" + ); + assert!( + app.terminal_ui + .messages + .iter() + .any(|m| m.contains(crate::pairing_binding::CHANGED)), + "{:?}", + app.terminal_ui.messages + ); + } + } + } + + /// Credentials Astation sends for a pairing removed meanwhile are + /// dropped: they never re-create it. + #[tokio::test] + async fn credentials_for_a_pairing_removed_meanwhile_are_dropped() { + let (mut app, peer) = connected_app().await; + let dir = peer._state.path().to_path_buf(); + crate::pairing_binding::commit_unpair(&crate::status::Sources::in_dir(&dir)).unwrap(); + let before = crate::status::tests::snapshot(&dir); + app.handle_astation_message(AstationMessage::CredentialSync { + access_token: "access".into(), + refresh_token: "refresh".into(), + expires_at: 1, + login_id: None, + astation_id: ASTATION.into(), + save_credentials: true, + }) + .await; + let shown = app.status_message.clone().unwrap_or_default(); + assert!(shown.contains(crate::pairing_binding::CHANGED), "{shown}"); + assert_eq!(crate::status::tests::snapshot(&dir), before); + } + #[tokio::test] async fn auth_required_from_another_astation_is_not_answered() { let (mut app, mut peer) = connected_app().await; diff --git a/src/cli.rs b/src/cli.rs index 87ca5f9..3c89a43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1183,8 +1183,11 @@ fn revocation_recorded(src: &crate::status::Sources) -> bool { .is_ok_and(|trust| trust.revoked().is_some()) } -/// `atem unpair` after the confirmation: removes the paired credential -/// entries and the saved Astation sessions (the login stays), and, when an +/// `atem unpair` after the confirmation: first durably commits the next host +/// trust generation without a pairing binding (so work started under the old +/// pairing can't apply anything, even if what follows is interrupted), then +/// removes the paired credential entries and the saved Astation sessions (the +/// login, memory, vault data, trust pins and keys stay), and, when an /// Astation revoked this device and `agent` (the running key agent, if any) /// doesn't hold its keys unlocked, sets the revoked keys aside so the next /// `atem pair` verifies with new ones. Returns the notices to print: keys set @@ -1194,6 +1197,7 @@ fn unpair_profile( src: &crate::status::Sources, agent: Option<&dyn crate::memory::key_agent::KeyAgentApi>, ) -> Result> { + crate::pairing_binding::commit_unpair(src)?; let mut store = crate::credentials::CredentialStore::load_from(&src.credentials); let before = store.entries.len(); store @@ -1244,10 +1248,16 @@ async fn run_pair(src: &crate::status::Sources, save: bool) -> Result<()> { print!("{text}"); return Ok(()); } + // The trust state this pairing starts from: its binding is created only + // if nothing changed meanwhile (another atem pair or atem unpair). + let before = { + let src = src.clone(); + crate::memory::key_agent::blocking(move || crate::pairing_binding::capture(&src)).await? + }; let config = src.config.clone(); println!("Connecting to Astation..."); - let mut client = crate::websocket_client::AstationClient::new(); + let mut client = crate::websocket_client::AstationClient::new().with_profile(src.clone()); // Try local-first-then-relay pairing flow. Returns pairing code (or "local"). let result = client.connect_with_pairing(&config).await?; @@ -1335,7 +1345,8 @@ async fn run_pair(src: &crate::status::Sources, save: bool) -> Result<()> { // encryption messages it held are applied on the authenticated one. let held = client.take_held_encryption(); drop(client); - let mut identity_client = crate::websocket_client::AstationClient::new(); + let mut identity_client = + crate::websocket_client::AstationClient::new().with_profile(src.clone()); identity_client .connect_relay_identity(config.astation_relay_url(), &astation_id) .await @@ -1352,19 +1363,25 @@ async fn run_pair(src: &crate::status::Sources, save: bool) -> Result<()> { } else { client }; - crate::config::AtemConfig::store_astation_relay_code(&astation_id); - let mut store = crate::credentials::CredentialStore::load(); - let now = crate::credentials::CredentialEntry::now_secs(); - store.upsert(crate::credentials::CredentialEntry::new_paired( - access_token, - refresh_token, - expires_at, - login_id.clone(), - astation_id, - requested_save_credentials, - now, - )); - store.save()?; + // The new pairing's binding, in the next host trust generation; + // the connection goes on under it. + let after = crate::pairing_binding::bind_new(src, &before, &astation_id)?; + active_client.adopt_binding(&before, &after)?; + active_client.apply_bound(&astation_id, || { + crate::config::AtemConfig::store_astation_relay_code(&astation_id); + let mut store = crate::credentials::CredentialStore::load(); + let now = crate::credentials::CredentialEntry::now_secs(); + store.upsert(crate::credentials::CredentialEntry::new_paired( + access_token, + refresh_token, + expires_at, + login_id.clone(), + astation_id.clone(), + requested_save_credentials, + now, + )); + store.save() + })?; match login_id { Some(id) => println!("Paired with Astation. (SSO: {})", id), None => println!("Paired with Astation."), @@ -1450,7 +1467,7 @@ async fn run_device_verification( use crate::memory::trust::TrustStore; use crate::memory::unlock::PairEscrow; use crate::memory::verification::{ - AstationKeys, Handshake, KeyPaths, complete_verification, device_keys_for_verification, + AstationKeys, Handshake, KeyPaths, device_keys_for_verification, }; use crate::websocket_client::AstationMessage; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -1461,12 +1478,23 @@ async fn run_device_verification( use tokio::time::timeout; let paths = KeyPaths::default_paths(); + // The pairing this verification runs under: every key-agent request, + // the pending pin, Astation's certificate and the escrow apply only while + // it is current. + let capture = client + .capture() + .cloned() + .ok_or_else(|| anyhow::anyhow!(crate::pairing_binding::NO_BINDING))?; + capture.bound_to(astation_id)?; // The device id is this atem's relay identity (`atem_id`): Astation checks it // equals the id the connection authenticated as. let device_id = crate::websocket_client::resolved_atem_id(&crate::auth::get_hostname()); // The agent client blocks (socket I/O, autostart): call it off the runtime. - let agent: std::sync::Arc = + let raw_agent: std::sync::Arc = std::sync::Arc::from(crate::memory::key_agent::default_agent()); + let agent: std::sync::Arc = std::sync::Arc::new( + crate::pairing_binding::BoundAgent::new(capture.clone(), raw_agent.clone())?, + ); let keys = { let (paths, agent) = (paths.clone(), agent.clone()); // Revoked keys are never set aside here (`atem unpair` does that): @@ -1521,9 +1549,11 @@ async fn run_device_verification( let code = handshake.safety_code(&astation); let transcript = handshake.transcript(&astation); - TrustStore::update(&paths.trust, |trust| { - trust.set_pending(astation_id, &device_id, handshake.keys(), &astation, &code, &transcript); - Ok(()) + capture.apply(|| { + TrustStore::update(&paths.trust, |trust| { + trust.set_pending(astation_id, &device_id, handshake.keys(), &astation, &code, &transcript); + Ok(()) + }) })?; // From here on every failure drops the pending pin and, unless Astation @@ -1545,18 +1575,18 @@ async fn run_device_verification( } }; let applied = { - let (paths, agent) = (paths.clone(), agent.clone()); + // The raw agent: the whole apply holds the pairing lock already. + let (paths, agent, capture) = (paths.clone(), raw_agent.clone(), capture.clone()); let astation_id = astation_id.to_string(); let keys = handshake.into_keys(); blocking(move || { - complete_verification( + crate::pair_verify::apply_device_verified( + &capture, &paths, agent.as_ref(), &astation_id, keys, - &reply.device_verified, - &reply.account_state, - &reply.grants, + &reply, ) }) .await @@ -2594,6 +2624,44 @@ mod tests { assert_eq!(crate::status::pairing(&src), None); } + #[test] + fn unpair_commits_the_next_generation_before_removing_anything() { + let dir = tempfile::tempdir().unwrap(); + let old = crate::pairing_binding::tests::bound_profile(dir.path(), "astation-1"); + let src = crate::status::Sources::in_dir(dir.path()); + // Removing the sessions fails (sessions.json is a directory): the + // unpair is interrupted after its commit. + std::fs::create_dir(&src.sessions).unwrap(); + assert!(unpair_profile(&src, None).is_err()); + let state = crate::pairing_binding::load(&src).unwrap(); + assert_eq!(state.host_trust_gen, 2); + assert_eq!(state.binding, None); + assert!(old.check().is_err(), "the old binding is invalidated"); + // Retried: the next generation again, never a repeat. + std::fs::remove_dir(&src.sessions).unwrap(); + unpair_profile(&src, None).unwrap(); + assert_eq!(crate::pairing_binding::load(&src).unwrap().host_trust_gen, 3); + assert_eq!(crate::status::pairing(&src), None); + } + + #[test] + fn unpair_keeps_keys_trust_and_memory_data() { + let dir = tempfile::tempdir().unwrap(); + paired_profile(dir.path()); + trust_file(dir.path(), false); + std::fs::write(dir.path().join("knowledge.db"), b"memories").unwrap(); + std::fs::write(dir.path().join("project_names.json"), b"{}").unwrap(); + let src = crate::status::Sources::in_dir(dir.path()); + let kept = ["cred_state.json", "device_keys.sealed", "knowledge.db", "project_names.json"] + .map(|name| (name, std::fs::read(dir.path().join(name)).unwrap())); + assert!(unpair_profile(&src, None).unwrap().is_empty()); + for (name, contents) in kept { + assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), contents, "{name}"); + } + // The migrated pairing was generation 1. + assert_eq!(crate::pairing_binding::load(&src).unwrap().host_trust_gen, 2); + } + #[test] fn cred_status_is_gone() { assert!(Cli::try_parse_from(["atem", "cred", "status"]).is_err()); diff --git a/src/main.rs b/src/main.rs index 21b81d6..5104c8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ mod tui; mod vault_client; mod memory; mod pair_verify; +mod pairing_binding; mod status; mod chat; mod websocket_client; diff --git a/src/memory/unlock.rs b/src/memory/unlock.rs index fc8382f..3204056 100644 --- a/src/memory/unlock.rs +++ b/src/memory/unlock.rs @@ -769,6 +769,15 @@ pub(crate) async fn unlock_command(src: &crate::status::Sources) -> Result<()> { return Ok(()); } let mut client = connect_astation(src, &home, "this device's home Astation").await?; + // Every key-agent request from here on (finishing the unlock, rotation, + // escrow) runs only while the pairing this connection authenticated + // under is current: a grant or ack arriving after `atem unpair` is dropped. + let capture = client + .capture() + .cloned() + .ok_or_else(|| anyhow!(crate::pairing_binding::NO_BINDING))?; + let agent: Arc = + Arc::new(crate::pairing_binding::BoundAgent::new(capture, agent)?); unlock_and_rotate(&mut client, &agent, paths, &home, UNLOCK_TIMEOUT).await?; // The report sent on connect was skipped (the keys were still locked); // send it now, on the same connection. Best effort, like on connect. @@ -1299,6 +1308,96 @@ mod tests { ); } + /// A link that runs `atem unpair` (commits the next host trust + /// generation) right after it sends `when`, before Astation's answer. + struct UnpairOnSend { + inner: ScriptedLink, + src: crate::status::Sources, + when: fn(&AstationMessage) -> bool, + confirms: usize, + } + + impl AstationLink for UnpairOnSend { + async fn send(&mut self, message: AstationMessage) -> Result<()> { + let unpair = (self.when)(&message); + if matches!(message, AstationMessage::StorageKeyConfirm { .. }) { + self.confirms += 1; + } + self.inner.send(message).await?; + if unpair { + crate::pairing_binding::commit_unpair(&self.src)?; + } + Ok(()) + } + + async fn recv(&mut self) -> Option { + self.inner.recv().await + } + + async fn apply_encryption(&mut self, message: &AstationMessage) -> Result> { + self.inner.apply_encryption(message).await + } + } + + /// `agent` bound to a fresh pairing of the profile in `dir`. + fn bound( + dir: &std::path::Path, + agent: Arc, + ) -> (Arc, crate::status::Sources) { + let capture = crate::pairing_binding::tests::bound_profile(dir, ASTATION_ID); + let src = capture.sources().clone(); + let agent = crate::pairing_binding::BoundAgent::new(capture, agent).unwrap(); + (Arc::new(agent), src) + } + + #[tokio::test] + async fn an_unlock_grant_for_an_old_pairing_is_dropped() { + let dir = tempfile::tempdir().unwrap(); + let (paths, server, _) = sealed_device(dir.path(), "0a1b2c3d"); + let inner = agent(&paths); + let (agent, src) = bound(dir.path(), inner.clone()); + let mut link = UnpairOnSend { + inner: ScriptedLink::new(server), + src, + when: |message| matches!(message, AstationMessage::UnlockRequest { .. }), + confirms: 0, + }; + let error = error_of(unlock_via(&mut link, &agent, &paths, ASTATION_ID, WAIT).await); + assert!(error.contains(crate::pairing_binding::CHANGED), "{error}"); + assert!(!inner.status().unwrap().unlocked, "the grant was dropped"); + } + + #[tokio::test] + async fn a_storage_key_ack_for_an_old_pairing_is_dropped() { + let dir = tempfile::tempdir().unwrap(); + let (paths, server, inner) = fresh_device(dir.path()); + let escrow = inner.begin_rotation(ASTATION_ID).unwrap(); + let sealed_before = std::fs::read(&paths.device_keys_sealed).unwrap(); + let (agent, src) = bound(dir.path(), inner.clone()); + let mut link = UnpairOnSend { + inner: ScriptedLink::new(server), + src, + when: |message| matches!(message, AstationMessage::StorageKeyRotate { .. }), + confirms: 0, + }; + let error = pair_escrow(&mut link, &agent, &paths, ASTATION_ID, Some(escrow.clone())) + .await + .unwrap_err(); + assert!( + format!("{error:#}").contains(crate::pairing_binding::CHANGED), + "{error:#}" + ); + // Not promoted, not confirmed: the rotation stays pending for the + // next unlock to resend or abandon, and the sealed file is unchanged. + assert_eq!(link.confirms, 0); + assert_eq!(inner.pending_rotation().unwrap(), Some(escrow)); + assert!(!inner.status().unwrap().escrowed); + assert_eq!( + std::fs::read(&paths.device_keys_sealed).unwrap(), + sealed_before + ); + } + #[tokio::test] async fn a_denied_unlock_leaves_the_keys_locked() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/pair_verify.rs b/src/pair_verify.rs index ab80162..c66dd07 100644 --- a/src/pair_verify.rs +++ b/src/pair_verify.rs @@ -231,6 +231,33 @@ pub(crate) struct DeviceVerifiedReply { pub grants: Vec, } +/// Applies Astation's `deviceVerified` (`complete_verification`) only while +/// the pairing the verification started under is current, under the pairing +/// lock: a certificate that arrives after `atem unpair` (or a new `atem +/// pair`) is dropped and nothing is pinned. `agent` must not be a +/// `BoundAgent` (the lock is already held). +pub(crate) fn apply_device_verified( + capture: &crate::pairing_binding::Captured, + paths: &crate::memory::verification::KeyPaths, + agent: &dyn crate::memory::key_agent::KeyAgentApi, + astation_id: &str, + keys: crate::memory::verification::VerificationKeys, + reply: &DeviceVerifiedReply, +) -> Result { + capture.bound_to(astation_id)?; + capture.apply(|| { + crate::memory::verification::complete_verification( + paths, + agent, + astation_id, + keys, + &reply.device_verified, + &reply.account_state, + &reply.grants, + ) + }) +} + /// Waits up to `wait` for Astation's `deviceVerified`. pub(crate) async fn await_device_verified( client: &mut AstationClient, @@ -640,4 +667,50 @@ mod tests { let error = verification_result(Err(anyhow!("codes didn't match"))).unwrap_err(); assert_eq!(error.to_string(), "device verification failed"); } + + /// Astation's certificate for a verification whose pairing was removed + /// (or replaced) meanwhile is never applied. + #[test] + fn a_delayed_device_verified_for_an_old_binding_is_not_applied() { + use crate::memory::key_agent::{KeyAgentApi, Reply, Request}; + struct NeverCalled; + impl KeyAgentApi for NeverCalled { + fn call(&self, _: Request) -> Result { + panic!("the key agent was asked to apply a stale verification") + } + } + let wire = || SignedWire { + statement: "c3RhdGVtZW50".into(), + signature: "c2lnbmF0dXJl".into(), + }; + for re_pair in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let capture = crate::pairing_binding::tests::bound_profile(dir.path(), "astation-1"); + let src = capture.sources().clone(); + crate::pairing_binding::commit_unpair(&src).unwrap(); + if re_pair { + let before = crate::pairing_binding::capture(&src).unwrap(); + crate::pairing_binding::bind_new(&src, &before, "astation-1").unwrap(); + } + let before = crate::status::tests::snapshot(dir.path()); + let reply = DeviceVerifiedReply { + device_verified: wire(), + account_state: wire(), + grants: Vec::new(), + }; + let keys = crate::memory::device_keys::DeviceKeys::generate().into(); + let error = apply_device_verified( + &capture, + &src.keys, + &NeverCalled, + "astation-1", + keys, + &reply, + ) + .unwrap_err(); + assert_eq!(error.to_string(), crate::pairing_binding::CHANGED); + assert_eq!(crate::status::tests::snapshot(dir.path()), before); + assert!(!src.keys.trust.exists()); + } + } } diff --git a/src/pairing_binding.rs b/src/pairing_binding.rs new file mode 100644 index 0000000..535923c --- /dev/null +++ b/src/pairing_binding.rs @@ -0,0 +1,502 @@ +//! The host trust generation and the pairing binding (`pairing.json`). +//! +//! Every pairing gets a binding: a random id, the Astation it pairs with and +//! the host trust generation it was created in. `atem pair` creates one in +//! the next generation; `atem unpair` commits the next generation without a +//! binding *before* it removes credentials and sessions, so an interrupted +//! unpair still invalidates the old binding. The generation never decreases +//! or repeats. +//! +//! Asynchronous Astation work (authentication, re-authentication, +//! verification, unlock, rotation, escrow, credential reports) captures the +//! state when it starts (`Captured`) and applies a reply only while the +//! state is still the captured one, under the file's lock (`Captured::apply`): +//! a delayed reply never refreshes a replacement session or adopts a later +//! binding. +//! +//! A profile paired before this file existed migrates to generation 1 with a +//! deterministic binding id (`legacy-`), computed in memory by +//! readers (`atem status` writes nothing) and recorded by the first +//! operation that captures it. Unpairing keeps memory, vault, credentials +//! data and keys: only the binding goes. +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::Arc; + +use crate::memory::key_agent::{KeyAgentApi, Reply, Request}; +use crate::memory::trust::{TrustLock, TrustStore}; +use crate::status::Sources; + +/// A reply arrived for a pairing that is no longer the current one. +pub const CHANGED: &str = "The Astation pairing changed while this was in progress (atem unpair or atem pair); nothing from it was applied"; +/// Work that needs a pairing binding on a profile without one. +pub const NO_BINDING: &str = + "This profile has no current pairing binding. Run `atem unpair`, then `atem pair`."; + +/// One pairing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Binding { + pub binding_id: String, + pub astation_id: String, + pub host_trust_gen: u64, + /// Unix seconds; 0 for a migrated pairing not recorded yet. + pub created_at: u64, +} + +/// The host trust generation and the current binding, if any. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustState { + /// 0 only for a profile that never had a pairing binding. + pub host_trust_gen: u64, + pub binding: Option, + /// Read from `pairing.json` (else computed: never paired, or migrating). + pub recorded: bool, +} + +impl TrustState { + /// The same generation and binding (identity only: not when it was + /// created or whether it is recorded yet). + pub fn same(&self, other: &TrustState) -> bool { + let id = |state: &TrustState| { + state + .binding + .as_ref() + .map(|b| (b.binding_id.clone(), b.astation_id.clone())) + }; + self.host_trust_gen == other.host_trust_gen && id(self) == id(other) + } +} + +/// `pairing.json`. +#[derive(Debug, Serialize, Deserialize)] +struct PairingFile { + version: u8, + host_trust_gen: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + binding_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + astation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + created_at: Option, +} + +/// Reads `pairing.json` without changing anything (no permission repair, +/// never through a symlink). `None` when there is no file. +fn read_file(path: &Path) -> Result> { + use std::io::Read; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(anyhow!(error).context(format!("could not read {}", path.display()))); + } + }; + let mut raw = Vec::new(); + file.read_to_end(&mut raw)?; + let file: PairingFile = serde_json::from_slice(&raw) + .with_context(|| format!("{} is unreadable", path.display()))?; + if file.version != 1 { + bail!("unsupported {} version {}", path.display(), file.version); + } + if file.host_trust_gen == 0 { + bail!("{} has no host trust generation", path.display()); + } + let binding = match (file.binding_id, file.astation_id) { + (None, None) => None, + (Some(binding_id), Some(astation_id)) + if !binding_id.is_empty() && !astation_id.is_empty() => + { + Some(Binding { + binding_id, + astation_id, + host_trust_gen: file.host_trust_gen, + created_at: file.created_at.unwrap_or(0), + }) + } + _ => bail!("{} has an incomplete binding", path.display()), + }; + Ok(Some(TrustState { + host_trust_gen: file.host_trust_gen, + binding, + recorded: true, + })) +} + +fn write_file(path: &Path, state: &TrustState) -> Result<()> { + let binding = state.binding.as_ref(); + let file = PairingFile { + version: 1, + host_trust_gen: state.host_trust_gen, + binding_id: binding.map(|b| b.binding_id.clone()), + astation_id: binding.map(|b| b.astation_id.clone()), + created_at: binding.map(|b| b.created_at), + }; + crate::memory::crypto::write_private(path, &serde_json::to_vec_pretty(&file)?) +} + +/// The binding a pairing from before `pairing.json` migrates to. +fn legacy(src: &Sources) -> Option { + let astation_id = crate::status::pairing(src)?.astation_id?; + Some(Binding { + binding_id: format!("legacy-{astation_id}"), + astation_id, + host_trust_gen: 1, + created_at: 0, + }) +} + +/// The current state, read-only: `pairing.json`, else an existing pairing +/// as it migrates (generation 1), else generation 0 without a binding. +pub fn load(src: &Sources) -> Result { + if let Some(state) = read_file(&src.pairing)? { + return Ok(state); + } + let binding = legacy(src); + Ok(TrustState { + host_trust_gen: u64::from(binding.is_some()), + binding, + recorded: false, + }) +} + +/// The lock every change of `pairing.json` and every bound apply holds +/// (`pairing.lock` beside it). +fn lock(src: &Sources) -> Result { + TrustStore::lock(&src.pairing) +} + +fn now_secs() -> u64 { + crate::credentials::CredentialEntry::now_secs() +} + +/// The state at the start of asynchronous work. Records a migrating +/// pairing's binding (best effort: the computed one is the same). +pub fn capture(src: &Sources) -> Result { + let _lock = lock(src)?; + let mut state = load(src)?; + if !state.recorded + && let Some(binding) = state.binding.as_mut() + { + binding.created_at = now_secs(); + if write_file(&src.pairing, &state).is_ok() { + state.recorded = true; + } + } + Ok(state) +} + +/// `atem unpair`'s first step: durably commits the next generation without +/// a binding, so every capture of the old one goes stale before anything is +/// removed. Returns the new generation. +pub fn commit_unpair(src: &Sources) -> Result { + let _lock = lock(src)?; + let current = load(src)?; + let next = current.host_trust_gen + 1; + write_file( + &src.pairing, + &TrustState { + host_trust_gen: next, + binding: None, + recorded: true, + }, + )?; + Ok(next) +} + +/// `atem pair`: a new binding with `astation_id` in the next generation, +/// unless the state changed since `expected` was captured (another pair or +/// unpair ran meanwhile). +pub fn bind_new(src: &Sources, expected: &TrustState, astation_id: &str) -> Result { + let _lock = lock(src)?; + let current = load(src)?; + if !current.same(expected) { + bail!(CHANGED); + } + let host_trust_gen = current.host_trust_gen + 1; + let state = TrustState { + host_trust_gen, + binding: Some(Binding { + binding_id: uuid::Uuid::new_v4().to_string(), + astation_id: astation_id.to_string(), + host_trust_gen, + created_at: now_secs(), + }), + recorded: true, + }; + write_file(&src.pairing, &state)?; + Ok(state) +} + +/// What asynchronous work started under: the state and the session its +/// authentication used (`(session_id, token)`; `None` for the local proof or +/// a pairing in progress). +#[derive(Clone)] +pub struct Captured { + src: Sources, + pub state: TrustState, + pub session: Option<(String, String)>, +} + +impl std::fmt::Debug for Captured { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Captured") + .field("state", &self.state) + .field("session_id", &self.session.as_ref().map(|(id, _)| id)) + .finish_non_exhaustive() + } +} + +impl Captured { + /// Captures `src`'s state now (`capture`). + pub fn now(src: &Sources) -> Result { + Ok(Self { + state: capture(src)?, + src: src.clone(), + session: None, + }) + } + + #[cfg(test)] + pub fn sources(&self) -> &Sources { + &self.src + } + + /// The captured binding (an error without one). + pub fn bound(&self) -> Result<&Binding> { + self.state + .binding + .as_ref() + .ok_or_else(|| anyhow!(NO_BINDING)) + } + + /// The captured binding, which must be with `astation_id`. + pub fn bound_to(&self, astation_id: &str) -> Result<&Binding> { + let binding = self.bound()?; + if binding.astation_id != astation_id { + bail!( + "This profile is paired with Astation {}, not {astation_id}. Run `atem unpair`, then `atem pair`.", + binding.astation_id + ); + } + Ok(binding) + } + + /// Whether the state is still the captured one (no lock held). + pub fn check(&self) -> Result<()> { + if load(&self.src)?.same(&self.state) { + Ok(()) + } else { + Err(anyhow!(CHANGED)) + } + } + + /// Runs `apply` under the lock, only while the state is still the + /// captured one. `apply` must not take the lock again (no `BoundAgent` + /// inside). + pub fn apply(&self, apply: impl FnOnce() -> Result) -> Result { + let _lock = lock(&self.src)?; + self.check()?; + apply() + } + + /// The pairing this work runs under created a binding (`bind_new`) from + /// `before`: go on under the new one. Nothing changes unless this capture + /// is of `before`. + pub fn adopt(&mut self, before: &TrustState, after: &TrustState) -> Result<()> { + if !self.state.same(before) { + bail!(CHANGED); + } + self.state = after.clone(); + Ok(()) + } +} + +/// A key agent whose every request runs only while the pairing it was +/// captured under is current (under the lock, so `atem unpair` waits for a +/// request in flight and every later one is refused). +pub struct BoundAgent { + captured: Captured, + inner: Arc, +} + +impl BoundAgent { + /// Needs a bound capture. + pub fn new(captured: Captured, inner: Arc) -> Result { + captured.bound()?; + Ok(Self { captured, inner }) + } +} + +impl KeyAgentApi for BoundAgent { + fn call(&self, request: Request) -> Result { + self.captured.apply(|| self.inner.call(request)) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + fn paired(dir: &Path, astation_id: &str) { + let path = dir.join("credentials.enc"); + let mut store = crate::credentials::CredentialStore::load_from(&path); + store.upsert(crate::credentials::CredentialEntry::new_paired( + "access".into(), + "refresh".into(), + crate::credentials::CredentialEntry::now_secs() + 3600, + None, + astation_id.into(), + true, + 1, + )); + store.save_to(&path).unwrap(); + } + + /// A profile in `dir` bound to `astation_id` (a fresh `atem pair`). + pub(crate) fn bound_profile(dir: &Path, astation_id: &str) -> Captured { + let src = Sources::in_dir(dir); + let before = capture(&src).unwrap(); + bind_new(&src, &before, astation_id).unwrap(); + paired(dir, astation_id); + Captured::now(&src).unwrap() + } + + #[test] + fn a_new_profile_has_generation_zero_and_no_binding() { + let dir = tempfile::tempdir().unwrap(); + let state = load(&Sources::in_dir(dir.path())).unwrap(); + assert_eq!(state.host_trust_gen, 0); + assert_eq!(state.binding, None); + assert!(!dir.path().join("pairing.json").exists()); + } + + #[test] + fn pair_unpair_cycles_never_repeat_a_generation() { + let dir = tempfile::tempdir().unwrap(); + let src = Sources::in_dir(dir.path()); + let mut seen = std::collections::BTreeSet::new(); + let mut ids = std::collections::BTreeSet::new(); + for _ in 0..3 { + let before = capture(&src).unwrap(); + let bound = bind_new(&src, &before, "astation-1").unwrap(); + let binding = bound.binding.clone().unwrap(); + assert!(seen.insert(binding.host_trust_gen)); + assert!(ids.insert(binding.binding_id.clone())); + assert_eq!(load(&src).unwrap().binding, Some(binding)); + assert!(seen.insert(commit_unpair(&src).unwrap())); + assert_eq!(load(&src).unwrap().binding, None); + } + assert_eq!(seen.into_iter().collect::>(), vec![1, 2, 3, 4, 5, 6]); + // Unpairing again (an interrupted unpair, retried) still moves on. + assert_eq!(commit_unpair(&src).unwrap(), 7); + } + + #[test] + fn the_file_is_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let src = Sources::in_dir(dir.path()); + commit_unpair(&src).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&src.pairing) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn an_existing_pairing_migrates_to_generation_one_when_first_captured() { + let dir = tempfile::tempdir().unwrap(); + paired(dir.path(), "astation-1"); + let src = Sources::in_dir(dir.path()); + let computed = load(&src).unwrap(); + assert!(!computed.recorded); + assert_eq!(computed.host_trust_gen, 1); + assert_eq!( + computed.binding.as_ref().unwrap().binding_id, + "legacy-astation-1" + ); + assert!(!src.pairing.exists(), "reading writes nothing"); + let captured = capture(&src).unwrap(); + assert!(captured.recorded); + assert!(captured.same(&computed)); + assert!(load(&src).unwrap().recorded); + // Unpairing it moves past generation 1. + assert_eq!(commit_unpair(&src).unwrap(), 2); + let before = capture(&src).unwrap(); + let next = bind_new(&src, &before, "astation-1").unwrap(); + assert_eq!(next.host_trust_gen, 3); + assert_ne!(next.binding.unwrap().binding_id, "legacy-astation-1"); + } + + #[test] + fn a_capture_goes_stale_after_unpair_and_re_pair() { + let dir = tempfile::tempdir().unwrap(); + let old = bound_profile(dir.path(), "astation-1"); + old.check().unwrap(); + commit_unpair(old.sources()).unwrap(); + let error = old + .apply(|| -> Result<()> { panic!("applied") }) + .unwrap_err(); + assert_eq!(error.to_string(), CHANGED); + // Re-paired with the same Astation: still not the captured binding. + let before = capture(old.sources()).unwrap(); + bind_new(old.sources(), &before, "astation-1").unwrap(); + assert!(old.check().is_err()); + // bind_new refuses a stale expectation (another pair ran meanwhile). + assert_eq!( + bind_new(old.sources(), &before, "astation-1") + .unwrap_err() + .to_string(), + CHANGED + ); + } + + #[test] + fn an_unreadable_file_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let src = Sources::in_dir(dir.path()); + std::fs::write(&src.pairing, b"not json").unwrap(); + assert!(load(&src).is_err()); + assert!(commit_unpair(&src).is_err()); + assert_eq!(std::fs::read(&src.pairing).unwrap(), b"not json"); + std::fs::write( + &src.pairing, + br#"{"version":1,"host_trust_gen":2,"binding_id":"x"}"#, + ) + .unwrap(); + assert!(load(&src).is_err()); + } + + /// Fails every request it gets. + struct NeverCalled; + + impl KeyAgentApi for NeverCalled { + fn call(&self, _: Request) -> Result { + panic!("the key agent was called for a stale pairing") + } + } + + #[test] + fn a_bound_agent_refuses_requests_after_unpair() { + let dir = tempfile::tempdir().unwrap(); + let captured = bound_profile(dir.path(), "astation-1"); + let agent = BoundAgent::new(captured.clone(), Arc::new(NeverCalled)).unwrap(); + commit_unpair(captured.sources()).unwrap(); + assert_eq!(agent.status().unwrap_err().to_string(), CHANGED); + // And an unbound capture can't make one. + let unbound = Captured::now(captured.sources()).unwrap(); + assert!(BoundAgent::new(unbound, Arc::new(NeverCalled)).is_err()); + } +} diff --git a/src/sso_auth.rs b/src/sso_auth.rs index 979a7d1..4f585e6 100644 --- a/src/sso_auth.rs +++ b/src/sso_auth.rs @@ -169,6 +169,9 @@ async fn fetch_from_astation(astation_id: String) -> Result { match client.recv_message_async().await { Some(AstationMessage::CredentialSync { access_token, refresh_token, expires_at, astation_id: from, .. }) => { if from == astation_id { + // Only tokens for the pairing this connection + // authenticated under (not one removed meanwhile). + client.apply_bound(&from, || Ok(()))?; return Ok(PairedTokens { access_token, refresh_token, expires_at }); } anyhow::bail!("connected to a different Astation ({})", from); diff --git a/src/status.rs b/src/status.rs index 69099f8..9da7420 100644 --- a/src/status.rs +++ b/src/status.rs @@ -92,6 +92,8 @@ pub struct Sources { pub config: AtemConfig, pub credentials: PathBuf, pub sessions: PathBuf, + /// `pairing.json`: the host trust generation and the pairing binding. + pub pairing: PathBuf, pub keys: KeyPaths, /// Ask an already running key agent whether the keys are unlocked /// (never starts one). Off in tests, so they never reach the user's agent. @@ -106,6 +108,7 @@ impl Sources { config: AtemConfig::load()?, credentials: CredentialStore::path(), sessions: SessionManager::sessions_path()?, + pairing: AtemConfig::config_dir().join("pairing.json"), keys: KeyPaths::default_paths(), query_agent: true, }) @@ -123,6 +126,7 @@ impl Sources { config, credentials: dir.join("credentials.enc"), sessions: dir.join("sessions.json"), + pairing: dir.join("pairing.json"), keys: KeyPaths::in_dir(dir), query_agent: false, } @@ -329,6 +333,7 @@ fn pairing_lines(src: &Sources, pairing: Option<&Pairing>) -> (Line, Line) { } else { "" }; + let trust = trust_note(src, astation_id); let verification = match verification(&src.keys, astation_id) { Verification::Verified { safety_code } => format!("verified (safety code {safety_code})"), Verification::NotVerified => "not verified".into(), @@ -336,11 +341,30 @@ fn pairing_lines(src: &Sources, pairing: Option<&Pairing>) -> (Line, Line) { Verification::Unknown(error) => format!("unknown ({error})"), }; ( - Line::fresh(format!("paired ({astation_id}{expired}){more}")), + Line::fresh(format!("paired ({astation_id}{trust}{expired}){more}")), Line::fresh(verification), ) } +/// The Pairing line's trust generation: `, trust gen N` for the binding with +/// `astation_id` (a pairing from before `pairing.json` shows the generation +/// it migrates to; nothing is written). Read-only. +fn trust_note(src: &Sources, astation_id: &str) -> String { + match crate::pairing_binding::load(src) { + Ok(state) => match state.binding { + Some(binding) if binding.astation_id == astation_id => { + format!(", trust gen {}", binding.host_trust_gen) + } + Some(binding) => format!( + ", trust gen {} is with {}; run `atem unpair`, then `atem pair`", + binding.host_trust_gen, binding.astation_id + ), + None => ", no pairing binding; run `atem unpair`, then `atem pair`".into(), + }, + Err(error) => format!(", trust gen unknown: {error:#}"), + } +} + /// The status from `src`, with `agent` the already running key agent (if any). pub fn gather_with(src: &Sources, agent: Option<&dyn KeyAgentApi>) -> StatusReport { let store = CredentialStore::load_from(&src.credentials); @@ -433,7 +457,7 @@ pub fn verify_start(src: &Sources) -> Result { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::auth::AuthSession; use crate::memory::key_agent::{Reply, Request}; @@ -596,7 +620,7 @@ mod tests { write_paired(dir.path(), ASTATION, later()); let report = report(dir.path()); assert_eq!(report.account.value, "signed in"); - assert_eq!(report.pairing.value, "paired (astation-1)"); + assert_eq!(report.pairing.value, "paired (astation-1, trust gen 1)"); assert_eq!(report.verification.value, "not verified"); assert_eq!(report.credentials.value, "not set up"); } @@ -678,7 +702,7 @@ mod tests { ); assert_eq!( report(dir.path()).pairing.value, - "paired (astation-2) (+1 more)" + "paired (astation-2, trust gen 1) (+1 more)" ); } @@ -689,8 +713,35 @@ mod tests { write_session(dir.path(), ASTATION); write_trust(dir.path(), Some(ASTATION), None); let before = snapshot(dir.path()); - let _ = text(&Sources::in_dir(dir.path())); + let text = text(&Sources::in_dir(dir.path())); assert_eq!(snapshot(dir.path()), before); + // A pairing from before pairing.json: its generation is computed, + // never recorded by status. + assert!(text.contains("paired (astation-1, trust gen 1)"), "{text}"); + assert!(!dir.path().join("pairing.json").exists()); + } + + #[test] + fn the_pairing_line_shows_the_trust_generation() { + let dir = tempfile::tempdir().unwrap(); + let src = Sources::in_dir(dir.path()); + // Paired, unpaired, paired again: generation 3. + let before = crate::pairing_binding::capture(&src).unwrap(); + crate::pairing_binding::bind_new(&src, &before, ASTATION).unwrap(); + crate::pairing_binding::commit_unpair(&src).unwrap(); + let before = crate::pairing_binding::capture(&src).unwrap(); + crate::pairing_binding::bind_new(&src, &before, ASTATION).unwrap(); + write_paired(dir.path(), ASTATION, later()); + assert_eq!( + report(dir.path()).pairing.value, + "paired (astation-1, trust gen 3)" + ); + // An unpair interrupted after its commit: the old binding is gone. + crate::pairing_binding::commit_unpair(&src).unwrap(); + assert_eq!( + report(dir.path()).pairing.value, + "paired (astation-1, no pairing binding; run `atem unpair`, then `atem pair`)" + ); } #[test] @@ -760,7 +811,7 @@ mod tests { let src = Sources::in_dir(dir.path()); assert_eq!( report(dir.path()).pairing.value, - "paired (astation-1, session expired)" + "paired (astation-1, trust gen 1, session expired)" ); assert!(profile_paired(&src)); assert_eq!( diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 8d4904d..8a7cee7 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -471,6 +471,11 @@ pub struct AstationClient { /// The profile whose pairing errors advise on (`with_profile`); `None` /// is this user's profile. profile: Option, + /// The pairing state and session this connection's authentication + /// started under, captured before the proof went out. Every reply with + /// effects (auth success, credentials, encryption, keys) is applied only + /// while it is still current (`pairing_binding::Captured::apply`). + capture: Option, } /// Why a connection couldn't authenticate with the saved session. @@ -531,9 +536,89 @@ impl AstationClient { answered_challenge: None, held_encryption: Vec::new(), profile: None, + capture: None, } } + /// The profile this client reads and writes (`with_profile`, else this + /// user's). Test clients must name one. + fn profile_sources(&self) -> Result { + match &self.profile { + Some(profile) => Ok(profile.clone()), + None if cfg!(test) => Err(anyhow!("test client without a profile")), + None => crate::status::Sources::current(), + } + } + + /// The pairing this connection authenticated under. + pub(crate) fn capture(&self) -> Option<&crate::pairing_binding::Captured> { + self.capture.as_ref() + } + + /// This run's `atem pair` created a binding (`bind_new`) from `before`: + /// the connection goes on under it. + pub(crate) fn adopt_binding( + &mut self, + before: &crate::pairing_binding::TrustState, + after: &crate::pairing_binding::TrustState, + ) -> Result<()> { + self.capture + .as_mut() + .ok_or_else(|| anyhow!(crate::pairing_binding::NO_BINDING))? + .adopt(before, after) + } + + /// Runs `apply` (blocking file work) only while this connection's + /// pairing and session are the ones its authentication captured, under + /// the pairing lock. + fn guarded(&self, apply: impl FnOnce() -> Result) -> Result { + let capture = self + .capture + .as_ref() + .ok_or_else(|| anyhow!("this connection has no captured pairing; nothing was applied"))?; + capture.apply(|| { + self.check_session(capture)?; + apply() + }) + } + + /// The saved session is still the one the proof used (when one was). + fn check_session(&self, capture: &crate::pairing_binding::Captured) -> Result<()> { + let (Some((session_id, token)), Some(astation_id)) = + (&capture.session, self.connected_astation_id.as_deref()) + else { + return Ok(()); + }; + let manager = self.load_session_manager()?; + let current = manager.get(astation_id); + if current.is_some_and(|s| &s.session_id == session_id && &s.token == token) { + Ok(()) + } else { + Err(anyhow!( + "The saved Astation session changed while this connection authenticated; nothing from it was applied" + )) + } + } + + /// Runs `apply` only while this connection is bound to `astation_id`'s + /// current pairing (credentials Astation sends, for example). + pub(crate) fn apply_bound(&self, astation_id: &str, apply: impl FnOnce() -> Result) -> Result { + let capture = self + .capture + .as_ref() + .ok_or_else(|| anyhow!(crate::pairing_binding::NO_BINDING))?; + capture.bound_to(astation_id)?; + if self.connected_astation_id.as_deref() != Some(astation_id) { + return Err(anyhow!("this connection authenticated another Astation than {astation_id}")); + } + self.guarded(apply) + } + + #[cfg(test)] + pub(crate) fn set_capture_for_test(&mut self, capture: crate::pairing_binding::Captured) { + self.capture = Some(capture); + } + /// Advise on pairing errors from `profile` instead of this user's profile. pub(crate) fn with_profile(mut self, profile: crate::status::Sources) -> Self { self.profile = Some(profile); @@ -577,8 +662,18 @@ impl AstationClient { /// Pretend the connect-time authentication pinned `astation_id`. #[cfg(test)] + /// Also captures the profile's pairing and saved session, as the + /// connect-time authentication does. pub(crate) fn pin_astation_for_test(&mut self, astation_id: &str) { self.connected_astation_id = Some(astation_id.to_string()); + let profile = self.profile_sources().expect("a test profile"); + let mut capture = crate::pairing_binding::Captured::now(&profile).unwrap(); + capture.session = self + .load_session_manager() + .unwrap() + .get(astation_id) + .map(|s| (s.session_id.clone(), s.token.clone())); + self.capture = Some(capture); } fn resolved_atem_id(&self, hostname: &str) -> String { @@ -626,6 +721,7 @@ impl AstationClient { } self.ws_closed = true; self.answered_challenge = None; + self.capture = None; } /// Alias for `connect_without_pairing`: the saved session is looked up @@ -768,14 +864,21 @@ impl AstationClient { } let challenge = AuthChallenge::parse(&auth_required)?; let astation_id = challenge.astation_id.clone(); - - if let Some(proof) = self.auth_proof(&challenge)? { + // What this authentication runs under, captured before any proof + // goes out: a reply that arrives after `atem unpair`/`atem pair` + // can't adopt the later binding or refresh its session. + self.capture = Some(crate::pairing_binding::Captured::now(&self.profile_sources()?)?); + + if let Some((proof, session)) = self.auth_proof(&challenge)? { + if let Some(capture) = self.capture.as_mut() { + capture.session = session; + } self.send_message(proof).await?; self.answered_challenge = Some((astation_id.clone(), challenge.challenge.clone())); let response = self.wait_for_auth_response(&astation_id).await?; if challenge.transport == "loopback" { return match response { - Some(AuthResponse::Authenticated) => Ok(()), + Some(AuthResponse::Authenticated) => self.guarded(|| Ok(())), Some(AuthResponse::Denied(message)) => { Err(anyhow!("Local authentication denied: {}", message)) } @@ -787,8 +890,10 @@ impl AstationClient { } match response { Some(AuthResponse::Authenticated) => { - self.refresh_saved_session(&astation_id); - return Ok(()); + return self.guarded(|| { + self.refresh_saved_session(&astation_id); + Ok(()) + }); } // Fall through to pairing. Some(AuthResponse::SessionExpired) | None => {} @@ -812,7 +917,19 @@ impl AstationClient { } // Session auth failed or no session - use explicit pairing. - self.authenticate_with_pairing(&astation_id, &atem_id).await + if let Some(capture) = self.capture.as_mut() { + capture.session = None; + } + self.authenticate_with_pairing(&astation_id, &atem_id).await?; + // Later checks compare against the session the grant saved. + let granted = self + .load_session_manager()? + .get(&astation_id) + .map(|s| (s.session_id.clone(), s.token.clone())); + if let Some(capture) = self.capture.as_mut() { + capture.session = granted; + } + Ok(()) } /// Authenticate using pairing code (fallback when session invalid/missing) @@ -867,11 +984,17 @@ impl AstationClient { /// bootstrap proof on loopback, else the saved session's proof. `None` when /// there is no saved session for that Astation. Shared by the connect path /// and re-authentication over an open socket so the two can't drift. - fn auth_proof(&self, challenge: &AuthChallenge) -> Result> { + /// Also returns the saved session the proof used (`None` on loopback). + #[allow(clippy::type_complexity)] + fn auth_proof( + &self, + challenge: &AuthChallenge, + ) -> Result)>> { let hostname = crate::auth::get_hostname(); let atem_id = self.resolved_atem_id(&hostname); let astation_id = &challenge.astation_id; let mut auth_data = std::collections::HashMap::new(); + let mut used = None; if challenge.transport == "loopback" { let token = self .local_bootstrap_token() @@ -888,6 +1011,7 @@ impl AstationClient { let Some(session) = session_mgr.get(astation_id) else { return Ok(None); }; + used = Some((session.session_id.clone(), session.token.clone())); auth_data.insert("session_id".to_string(), session.session_id.clone()); auth_data.insert("atem_id".to_string(), atem_id.clone()); auth_data.insert( @@ -901,10 +1025,11 @@ impl AstationClient { )?, ); } - Ok(Some(AstationMessage::StatusUpdate { + let proof = AstationMessage::StatusUpdate { status: "auth".to_string(), data: auth_data, - })) + }; + Ok(Some((proof, used))) } fn local_bootstrap_token(&self) -> Option { @@ -947,7 +1072,10 @@ impl AstationClient { challenge.astation_id )); } - let proof = self + // Only for the pairing and session this connection authenticated + // with: never answer with a replacement's session. + self.guarded(|| Ok(()))?; + let (proof, _) = self .auth_proof(&challenge)? .ok_or_else(|| { let paired = self.profile_paired(); @@ -973,11 +1101,16 @@ impl AstationClient { }) } - /// Re-authentication succeeded: keep the saved session fresh, as connect does. - pub(crate) fn reauthenticated(&self) { - if let Some(astation_id) = self.connected_astation_id.as_deref() { - self.refresh_saved_session(astation_id); - } + /// Re-authentication succeeded: keep the saved session fresh, as connect + /// does, only while the pairing and session are still the ones this + /// connection authenticated with (else nothing is touched). + pub(crate) fn reauthenticated(&self) -> Result<()> { + self.guarded(|| { + if let Some(astation_id) = self.connected_astation_id.as_deref() { + self.refresh_saved_session(astation_id); + } + Ok(()) + }) } /// Classify one message as an authentication response for `astation_id`. @@ -1003,8 +1136,11 @@ impl AstationClient { hostname, ); - self.persist_session(session)?; - self.remember_astation_relay_code(astation_id); + self.guarded(|| { + self.persist_session(session)?; + self.remember_astation_relay_code(astation_id); + Ok(()) + })?; Ok(Some(AuthResponse::Authenticated)) } Some("denied") => { @@ -1017,7 +1153,10 @@ impl AstationClient { }, "authenticated" => { // Alternative authenticated message format - self.remember_astation_relay_code(astation_id); + self.guarded(|| { + self.remember_astation_relay_code(astation_id); + Ok(()) + })?; Ok(Some(AuthResponse::Authenticated)) } "error" => Ok(data.get("message").map(|msg| { @@ -1036,6 +1175,16 @@ impl AstationClient { async fn wait_for_auth_response(&mut self, astation_id: &str) -> Result> { while let Some(msg) = self.recv_message_async().await { if let Some(response) = self.auth_response(&msg, astation_id, true)? { + // A grant saved a new session (under the captured pairing): + // later checks compare against that one. + if let AstationMessage::StatusUpdate { status, data } = &msg + && status == "auth" + && let (Some(id), Some(token), Some(capture)) = + (data.get("session_id"), data.get("token"), self.capture.as_mut()) + && data.get("status").map(String::as_str) == Some("granted") + { + capture.session = Some((id.clone(), token.clone())); + } return Ok(Some(response)); } // Ignore other messages during auth @@ -1093,12 +1242,16 @@ impl AstationClient { /// Best effort metadata; failure never prevents the user's command. pub async fn report_credential_projects(&self) -> Result<()> { - use crate::memory::key_agent::{blocking, running_agent}; + use crate::memory::key_agent::{blocking, running_agent, KeyAgentApi}; let astation_id = self.connected_astation_id.clone().ok_or_else(|| anyhow!("Astation identity is not known"))?; crate::auth::pairing_session() .map_err(|_| anyhow!("this machine isn't paired with an Astation"))?; + // Signed only under the pairing this connection authenticated with. + let capture = self.capture.clone().ok_or_else(|| anyhow!(crate::pairing_binding::NO_BINDING))?; + capture.bound()?; let report = blocking(move || { let agent = running_agent().ok_or_else(|| anyhow!("key agent is not running"))?; + let agent = crate::pairing_binding::BoundAgent::new(capture, std::sync::Arc::from(agent))?; if !agent.status()?.unlocked { return Err(anyhow!("key agent is locked")); } let project = std::env::current_dir().ok().and_then(|cwd| crate::memory::project::detect_repo(&cwd)).map(|repo| repo.label); agent.credential_projects(&astation_id, project) @@ -1129,13 +1282,24 @@ impl AstationClient { self.hold_encryption(message.clone()); return Ok(Some(HOLDING_ENCRYPTION.into())); }; + // Applied only under the pairing this connection authenticated with + // (never one removed or replaced since), under its lock. Before the + // pairing has a binding (`atem pair` waiting for credentials), held + // until it has one. + let Some(capture) = self.capture.clone().filter(|c| c.state.binding.is_some()) else { + self.hold_encryption(message.clone()); + return Ok(Some(HOLDING_ENCRYPTION.into())); + }; let paths = KeyPaths::default_paths(); let applied = match message { - AstationMessage::EncryptionMode { account_state } => apply_account_state(&paths, &astation_id, account_state.as_ref())?, + AstationMessage::EncryptionMode { account_state } => { + capture.apply(|| apply_account_state(&paths, &astation_id, account_state.as_ref()))? + } AstationMessage::KeyGrant { grant } => { // Grants are opened only inside the key agent (blocking socket I/O). let (paths, astation_id, grant) = (paths.clone(), astation_id.clone(), grant.clone()); - blocking(move || apply_grant(&paths, default_agent().as_ref(), &astation_id, grant.as_ref())).await? + let capture = capture.clone(); + blocking(move || capture.apply(|| apply_grant(&paths, default_agent().as_ref(), &astation_id, grant.as_ref()))).await? } _ => unreachable!("filtered above"), }; @@ -1192,6 +1356,12 @@ impl AstationClient { } } + /// The migration's signed completion goes out only under the captured + /// pairing. + fn check_capture(&self) -> Result<()> { + self.capture.as_ref().ok_or_else(|| anyhow!(crate::pairing_binding::NO_BINDING))?.check() + } + /// Keeps an encryption message to apply later (`apply_held_encryption`). /// Anything else is not held. pub fn hold_encryption(&mut self, message: AstationMessage) { @@ -1215,7 +1385,8 @@ impl AstationClient { /// client knows its Astation; returns their status lines. Without an /// Astation id they stay held. pub async fn apply_held_encryption(&mut self) -> Vec { - if self.connected_astation_id.is_none() { + let bound = self.capture.as_ref().is_some_and(|c| c.state.binding.is_some()); + if self.connected_astation_id.is_none() || !bound { return Vec::new(); } let mut statuses = Vec::new(); @@ -1232,6 +1403,7 @@ impl AstationClient { } async fn finish_encryption_migration(&self, astation_id: &str, kid: Option) -> Result> { + self.check_capture()?; let trust = crate::memory::trust::TrustStore::load_from(&crate::memory::verification::KeyPaths::default_paths().trust)?; let epoch = crate::memory::verification::effective_state(&trust, astation_id)? .and_then(|(_, state)| state).map(|state| state.epoch); @@ -1661,6 +1833,83 @@ fn relay_ws_url(relay_base: &str, identity_code: &str, atem_id: &str) -> String mod tests { use super::*; + /// A delayed success for a connection whose pairing was replaced (atem + /// unpair, then atem pair) while the proof was out is not adopted: no + /// session refresh, no relay code, no connection. + #[cfg(unix)] + #[tokio::test] + async fn delayed_authentication_cannot_adopt_a_later_binding() { + use std::os::unix::fs::PermissionsExt; + for transport in ["relay", "loopback"] { + let state = tempfile::tempdir().unwrap(); + let session_path = state.path().join("sessions.json"); + crate::pairing_binding::tests::bound_profile(state.path(), "mac-a"); + let mut sessions = crate::auth::SessionManager::default(); + sessions.insert_session(crate::auth::AuthSession::new( + "old-session".into(), + "old-token".into(), + "mac-a".into(), + "host".into(), + )); + sessions.save_to(&session_path).unwrap(); + let token_path = state.path().join("local-bootstrap-token"); + fs::write(&token_path, "bootstrap-secret\n").unwrap(); + fs::set_permissions(&token_path, fs::Permissions::from_mode(0o600)).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let dir = state.path().to_path_buf(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let challenge = reauth_challenge("mac-a", transport); + socket + .send(Message::Text(serde_json::to_string(&challenge).unwrap())) + .await + .unwrap(); + // The proof is out; meanwhile the profile is unpaired and + // paired again, with a new session. + socket.next().await.unwrap().unwrap(); + let src = crate::status::Sources::in_dir(&dir); + crate::pairing_binding::commit_unpair(&src).unwrap(); + let before = crate::pairing_binding::capture(&src).unwrap(); + crate::pairing_binding::bind_new(&src, &before, "mac-a").unwrap(); + let mut sessions = crate::auth::SessionManager::default(); + sessions.insert_session(crate::auth::AuthSession::new( + "new-session".into(), + "new-token".into(), + "mac-a".into(), + "host".into(), + )); + sessions.save_to(&dir.join("sessions.json")).unwrap(); + let reply = AstationMessage::StatusUpdate { + status: "authenticated".into(), + data: std::collections::HashMap::new(), + }; + socket + .send(Message::Text(serde_json::to_string(&reply).unwrap())) + .await + .unwrap(); + let _ = socket.next().await; + }); + let mut client = AstationClient::new_with_test_state("atem-test", session_path.clone()); + client.local_token_path_override = Some(token_path); + let error = client + .connect_without_pairing(&format!("ws://{address}")) + .await + .unwrap_err(); + assert!( + format!("{error:#}").contains(crate::pairing_binding::CHANGED), + "{transport}: {error:#}" + ); + assert!(client.capture().is_none()); + let after = fs::read(&session_path).unwrap(); + let sessions: crate::auth::SessionManager = serde_json::from_slice(&after).unwrap(); + assert_eq!(sessions.get("mac-a").unwrap().session_id, "new-session"); + assert!(!state.path().join("relay-code").exists(), "{transport}"); + server.await.unwrap(); + } + } + #[tokio::test] async fn closed_transport_is_detected_before_buffered_input_is_drained() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1727,9 +1976,13 @@ mod tests { // The authenticated connection takes them over and applies them, // through the same signature checks (unsigned here: ignored). + let profile = tempfile::tempdir().unwrap(); + let capture = crate::pairing_binding::tests::bound_profile(profile.path(), "astation-1"); let mut authenticated = AstationClient::new(); authenticated.connected_astation_id = Some("astation-1".into()); - for message in pairing.take_held_encryption() { + authenticated.set_capture_for_test(capture.clone()); + let held = pairing.take_held_encryption(); + for message in held.clone() { authenticated.hold_encryption(message); } assert_eq!( @@ -1737,6 +1990,28 @@ mod tests { vec!["Ignored an unsigned encryption mode from the relay".to_string()] ); assert!(authenticated.apply_held_encryption().await.is_empty()); + + // After `atem unpair`, nothing reaches the keys or the trust state. + crate::pairing_binding::commit_unpair(capture.sources()).unwrap(); + for message in held { + authenticated.hold_encryption(message); + } + assert_eq!( + authenticated.apply_held_encryption().await, + vec![format!("Ignored an encryption message: {}", crate::pairing_binding::CHANGED)] + ); + // Without a pairing binding yet (`atem pair` waiting for its + // credentials), they are held, not applied. + let mut unbound = AstationClient::new(); + unbound.connected_astation_id = Some("astation-1".into()); + let status = unbound + .handle_encryption_message(&AstationMessage::KeyGrant { grant: None }) + .await + .unwrap() + .unwrap(); + assert_eq!(status, HOLDING_ENCRYPTION); + assert!(unbound.apply_held_encryption().await.is_empty()); + assert_eq!(unbound.take_held_encryption().len(), 1); } #[test] @@ -2060,7 +2335,7 @@ mod tests { #[cfg(unix)] #[tokio::test] - async fn practical_websocket_reconnect_survives_session_refresh_failure() { + async fn practical_websocket_reconnect_fails_closed_when_the_session_cant_be_rechecked() { use std::os::unix::fs::symlink; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -2134,19 +2409,20 @@ mod tests { let mut client = AstationClient::new_with_test_state("atem-test-refresh-failure", session_path); - client + // The saved session the proof used can't be read back (a symlink is + // refused), so the success can't be tied to it: nothing is adopted. + let error = client .connect_without_pairing(&format!("ws://{address}")) .await - .expect("session metadata failure aborted an authenticated reconnect"); - assert!(client.sender.is_some()); + .expect_err("an unverifiable session authorized the connection"); + assert!(format!("{error:#}").contains("private file"), "{error:#}"); + assert!(client.sender.is_none()); + assert!(client.capture().is_none()); assert_eq!( fs::read_to_string(target_path).unwrap(), "must remain unchanged" ); - assert_eq!( - fs::read_to_string(directory.path().join("relay-code")).unwrap(), - "astation-refresh-failure" - ); + assert!(!directory.path().join("relay-code").exists()); server.await.unwrap(); } From 207b0e9a7852c1a8fa818465cb7fd9da76221053 Mon Sep 17 00:00:00 2001 From: Brent G Date: Thu, 15 Oct 2026 21:42:52 +0000 Subject: [PATCH 7/7] fix(pairing): no stale session after unpair; token refresh can't undo an unpair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A saved session answers an Astation challenge (connect and re-authentication) only when the captured pairing binding is with that Astation: after an atem unpair that stopped before removing the sessions, the leftover session is never used and the user is told to run atem unpair, then atem pair (atem pair on an unpaired profile). - Token refresh no longer saves the copy of the credential store it loaded before waiting: the paired refresh reloads the store and updates only an existing entry, inside apply_bound (pairing lock and binding check); the SSO refresh reloads right before saving. A concurrent unpair (or logout) is never undone. ๐Ÿค– Built with SMT --- src/sso_auth.rs | 144 +++++++++++++++++++++++++++++++++------- src/websocket_client.rs | 122 ++++++++++++++++++++++++++++++---- 2 files changed, 229 insertions(+), 37 deletions(-) diff --git a/src/sso_auth.rs b/src/sso_auth.rs index 4f585e6..c1ae324 100644 --- a/src/sso_auth.rs +++ b/src/sso_auth.rs @@ -67,6 +67,10 @@ pub async fn valid_token_from( valid_token_with(path, connected_astation_id, sso_url, fetch_from_astation).await } +/// Saves tokens Astation sent: called by the "ask Astation" step while its +/// connection is still bound to the pairing (`AstationClient::apply_bound`). +pub type SavePaired = Box Result<()> + Send>; + /// `valid_token_from` with the "ask Astation" step injectable for tests. /// /// An SSO entry (`atem login`) refreshes itself. A paired entry never does: @@ -81,11 +85,13 @@ async fn valid_token_with( fetch_paired: F, ) -> Result where - F: FnOnce(String) -> Fut, + F: FnOnce(String, SavePaired) -> Fut, Fut: std::future::Future>, { use crate::credentials::{CredentialEntry, CredentialSource, CredentialStore}; - let mut store = CredentialStore::load_from(path); + // Only for choosing the entry: every save below reloads the store, so + // a change made while atem waited (e.g. `atem unpair`) is never undone. + let store = CredentialStore::load_from(path); let now = CredentialEntry::now_secs(); let (source, astation_id, refresh, needs) = { @@ -109,28 +115,36 @@ where let refreshed = refresh_token(&refresh, sso_url) .await .map_err(|e| anyhow::anyhow!("Session expired. Run 'atem login' or re-pair. ({e})"))?; - if let Some(sso) = store.find_sso_mut() { - sso.access_token = refreshed.access_token.clone(); - sso.refresh_token = refreshed.refresh_token.clone(); - sso.expires_at = refreshed.expires_at; - } + let mut store = CredentialStore::load_from(path); + let Some(sso) = store.find_sso_mut() else { + anyhow::bail!("Logged out while the session was refreshed. Run 'atem login'."); + }; + sso.access_token = refreshed.access_token.clone(); + sso.refresh_token = refreshed.refresh_token.clone(); + sso.expires_at = refreshed.expires_at; store.save_to(path)?; Ok(refreshed.access_token) } CredentialSource::AstationPaired => { let aid = astation_id.ok_or_else(|| anyhow::anyhow!("Paired session has no Astation id. Run 'atem pair'."))?; - let fresh = fetch_paired(aid.clone()).await.map_err(|e| { + // Updates only the pairing still on disk; never re-adds one. + let (save_path, save_aid) = (path.to_path_buf(), aid.clone()); + let save: SavePaired = Box::new(move |fresh| { + let mut store = CredentialStore::load_from(&save_path); + let Some(p) = store.find_paired_mut(&save_aid) else { + anyhow::bail!("the pairing with Astation {save_aid} was removed meanwhile; nothing was saved"); + }; + p.access_token = fresh.access_token.clone(); + p.refresh_token = fresh.refresh_token.clone(); + p.expires_at = fresh.expires_at; + store.save_to(&save_path) + }); + let fresh = fetch_paired(aid.clone(), save).await.map_err(|e| { anyhow::anyhow!( "The session from Astation has expired, and Astation couldn't send a new one ({e}). \ Open Astation and try again, or run 'atem login'." ) })?; - if let Some(p) = store.find_paired_mut(&aid) { - p.access_token = fresh.access_token.clone(); - p.refresh_token = fresh.refresh_token; - p.expires_at = fresh.expires_at; - } - store.save_to(path)?; Ok(fresh.access_token) } } @@ -146,7 +160,7 @@ pub struct PairedTokens { /// Connects to Astation without pairing (local first, then the relay identity /// room, as the TUI does) and waits for the `credentialSync` Astation sends /// on connect. Only accepts tokens from `astation_id`. -async fn fetch_from_astation(astation_id: String) -> Result { +async fn fetch_from_astation(astation_id: String, save: SavePaired) -> Result { use crate::websocket_client::{AstationClient, AstationMessage}; use tokio::time::{timeout, Duration}; @@ -169,10 +183,12 @@ async fn fetch_from_astation(astation_id: String) -> Result { match client.recv_message_async().await { Some(AstationMessage::CredentialSync { access_token, refresh_token, expires_at, astation_id: from, .. }) => { if from == astation_id { - // Only tokens for the pairing this connection - // authenticated under (not one removed meanwhile). - client.apply_bound(&from, || Ok(()))?; - return Ok(PairedTokens { access_token, refresh_token, expires_at }); + // Saved only for the pairing this connection + // authenticated under (not one removed meanwhile), + // under the pairing lock. + let fresh = PairedTokens { access_token, refresh_token, expires_at }; + client.apply_bound(&from, || save(&fresh))?; + return Ok(fresh); } anyhow::bail!("connected to a different Astation ({})", from); } @@ -577,9 +593,11 @@ mod tests { paired_store(&path, now() + 10); let rt = tokio::runtime::Runtime::new().unwrap(); // An unreachable SSO URL: a refresh attempt would fail the test. - let token = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |aid| async move { + let token = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |aid, save| async move { assert_eq!(aid, "astation-A"); - Ok(PairedTokens { access_token: "new-access".into(), refresh_token: "new-refresh".into(), expires_at: now() + 7200 }) + let fresh = PairedTokens { access_token: "new-access".into(), refresh_token: "new-refresh".into(), expires_at: now() + 7200 }; + save(&fresh)?; + Ok(fresh) })).unwrap(); assert_eq!(token, "new-access"); let mut store = crate::credentials::CredentialStore::load_from(&path); @@ -593,7 +611,7 @@ mod tests { let path = dir.path().join("creds.enc"); paired_store(&path, now() + 3600); let rt = tokio::runtime::Runtime::new().unwrap(); - let token = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |_| async { + let token = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |_, _| async { panic!("must not contact Astation"); #[allow(unreachable_code)] Ok(PairedTokens { access_token: String::new(), refresh_token: String::new(), expires_at: 0 }) @@ -607,7 +625,7 @@ mod tests { let path = dir.path().join("creds.enc"); paired_store(&path, now() + 10); let rt = tokio::runtime::Runtime::new().unwrap(); - let err = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |_| async { + let err = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |_, _| async { Err(anyhow::anyhow!("timed out waiting for Astation")) })).unwrap_err().to_string(); assert!(err.contains("Open Astation") && err.contains("timed out"), "{}", err); @@ -616,6 +634,86 @@ mod tests { assert_eq!(store.find_paired_mut("astation-A").unwrap().access_token, "old-access"); } + /// Removes every paired entry from the store at `path` (`atem unpair`). + fn unpair_store(path: &std::path::Path) { + let mut store = crate::credentials::CredentialStore::load_from(path); + store + .entries + .retain(|e| e.source != crate::credentials::CredentialSource::AstationPaired); + store.save_to(path).unwrap(); + } + + fn has_paired(path: &std::path::Path) -> bool { + crate::credentials::CredentialStore::load_from(path) + .entries + .iter() + .any(|e| e.source == crate::credentials::CredentialSource::AstationPaired) + } + + #[test] + fn an_unpair_during_the_paired_refresh_is_not_undone() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.enc"); + paired_store(&path, now() + 10); + let rt = tokio::runtime::Runtime::new().unwrap(); + let unpaired = path.clone(); + let result = rt.block_on(valid_token_with(&path, None, "http://127.0.0.1:1", |_, save| async move { + // `atem unpair` runs while atem waits for Astation. + unpair_store(&unpaired); + let fresh = PairedTokens { access_token: "new-access".into(), refresh_token: "new-refresh".into(), expires_at: now() + 7200 }; + save(&fresh)?; + Ok(fresh) + })); + assert!(result.is_err(), "a removed pairing yields no token"); + assert!(!has_paired(&path), "the stale copy re-added the pairing"); + } + + #[test] + fn an_unpair_during_the_sso_refresh_is_not_undone() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("creds.enc"); + let mut store = crate::credentials::CredentialStore::load_from(&path); + store.upsert(crate::credentials::CredentialEntry::new_sso( + "sso-access".into(), "sso-refresh".into(), now() + 10, Some("me".into()), + )); + store.save_to(&path).unwrap(); + paired_store(&path, now() + 3600); + let rt = tokio::runtime::Runtime::new().unwrap(); + let token = rt.block_on(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let unpaired = path.clone(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 8192]; + let _ = socket.read(&mut buf).await.unwrap(); + // `atem unpair` runs while the SSO refresh is in flight. + unpair_store(&unpaired); + let body = r#"{"access_token":"fresh","refresh_token":"fresh-refresh","expires_in":3600}"#; + let reply = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(reply.as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); + }); + let token = valid_token_with(&path, None, &url, |_, _| async { + panic!("must not contact Astation"); + #[allow(unreachable_code)] + Ok(PairedTokens { access_token: String::new(), refresh_token: String::new(), expires_at: 0 }) + }) + .await + .unwrap(); + server.await.unwrap(); + token + }); + assert_eq!(token, "fresh"); + assert!(!has_paired(&path), "the stale copy re-added the pairing"); + let store = crate::credentials::CredentialStore::load_from(&path); + assert_eq!(store.find_sso().unwrap().access_token, "fresh"); + } + #[test] fn parse_callback_query_extracts_code_and_state() { let (code, state, login_id) = parse_callback_query("code=mycode123&state=mystate456"); diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 8a7cee7..705ade0 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -614,6 +614,34 @@ impl AstationClient { self.guarded(apply) } + /// A saved session may answer `astation_id` only when the captured + /// pairing binding is with it: after an `atem unpair` that stopped before + /// removing the sessions (or one for another Astation), it is never used. + fn session_usable(&self, astation_id: &str) -> bool { + self.capture + .as_ref() + .and_then(|capture| capture.state.binding.as_ref()) + .is_some_and(|binding| binding.astation_id == astation_id) + } + + /// No saved session may answer: what to do (`advised` when the message + /// says to unpair). `unpaired` is the message for a profile not paired. + fn no_session(&self, problem: SessionProblem, lead: &str, unpaired: &str) -> anyhow::Error { + let paired = self.profile_paired(); + let unbound = self + .capture + .as_ref() + .is_some_and(|capture| capture.state.binding.is_none()); + let message = if paired && unbound { + format!("{lead}. {}", crate::pairing_binding::NO_BINDING) + } else if paired { + format!("{lead}. {}", crate::status::SESSION_GONE) + } else { + unpaired.to_string() + }; + SessionError::new(problem, paired, message).into() + } + #[cfg(test)] pub(crate) fn set_capture_for_test(&mut self, capture: crate::pairing_binding::Captured) { self.capture = Some(capture); @@ -907,13 +935,11 @@ impl AstationClient { let hostname = crate::auth::get_hostname(); let atem_id = self.resolved_atem_id(&hostname); if !allow_pairing { - let paired = self.profile_paired(); - let message = if paired { - format!("Pairing required. {}", crate::status::SESSION_GONE) - } else { - "Pairing required; run 'atem pair'".to_string() - }; - return Err(SessionError::new(SessionProblem::PairingRequired, paired, message).into()); + return Err(self.no_session( + SessionProblem::PairingRequired, + "Pairing required", + "Pairing required; run 'atem pair'", + )); } // Session auth failed or no session - use explicit pairing. @@ -1007,6 +1033,9 @@ impl AstationClient { device_auth_proof(&token, &challenge.challenge, astation_id, &atem_id, "local")?, ); } else { + if !self.session_usable(astation_id) { + return Ok(None); + } let session_mgr = self.load_session_manager()?; let Some(session) = session_mgr.get(astation_id) else { return Ok(None); @@ -1078,13 +1107,11 @@ impl AstationClient { let (proof, _) = self .auth_proof(&challenge)? .ok_or_else(|| { - let paired = self.profile_paired(); - let message = if paired { - format!("no saved session for this Astation. {}", crate::status::SESSION_GONE) - } else { - "no saved session for this Astation; run `atem pair`".to_string() - }; - anyhow::Error::from(SessionError::new(SessionProblem::Missing, paired, message)) + self.no_session( + SessionProblem::Missing, + "no saved session for this Astation", + "no saved session for this Astation; run `atem pair`", + ) })?; self.send_message(proof).await?; self.answered_challenge = Some((challenge.astation_id, challenge.challenge)); @@ -2103,6 +2130,73 @@ mod tests { assert!(server.await.unwrap().is_none(), "sent something without a session"); } + /// `dir`'s profile after an `atem unpair` that crashed after its commit: + /// the paired credentials and the saved session for `astation_id` are + /// still on disk, but the binding is gone. + fn interrupted_unpair(dir: &std::path::Path, astation_id: &str) { + crate::pairing_binding::tests::bound_profile(dir, astation_id); + let mut sessions = crate::auth::SessionManager::default(); + sessions.insert_session(crate::auth::AuthSession::new( + "leftover-session".into(), + "leftover-token".into(), + astation_id.into(), + "host".into(), + )); + sessions.save_to(&dir.join("sessions.json")).unwrap(); + crate::pairing_binding::commit_unpair(&crate::status::Sources::in_dir(dir)).unwrap(); + } + + #[tokio::test] + async fn an_interrupted_unpair_never_authenticates_with_the_leftover_session() { + let (state, mut client) = isolated_client("atem-test-leftover"); + interrupted_unpair(state.path(), "astation-a"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let challenge = reauth_challenge("astation-a", "relay"); + socket + .send(Message::Text(serde_json::to_string(&challenge).unwrap())) + .await + .unwrap(); + // Whatever Atem sends before it gives up (nothing, it should be). + let mut frames = Vec::new(); + while let Ok(Some(Ok(Message::Text(text)))) = + tokio::time::timeout(Duration::from_secs(2), socket.next()).await + { + frames.push(text); + } + frames + }); + let error = client + .connect_without_pairing(&format!("ws://{address}")) + .await + .expect_err("authenticated with a leftover session"); + assert!(format!("{error:#}").contains("atem unpair"), "{error:#}"); + let frames = server.await.unwrap(); + assert!( + !frames.iter().any(|frame| frame.contains("leftover-session")), + "{frames:?}" + ); + } + + #[tokio::test] + async fn reauth_after_an_interrupted_unpair_never_uses_the_leftover_session() { + let (state, mut client) = isolated_client("atem-test-leftover-reauth"); + interrupted_unpair(state.path(), "astation-a"); + let (url, server) = recording_peer().await; + client.connect_raw(&url).await.unwrap(); + client.pin_astation_for_test("astation-a"); + let error = client + .answer_auth_required(&reauth_challenge("astation-a", "relay")) + .await + .expect_err("answered with a leftover session"); + assert!(error.to_string().contains("atem unpair"), "{error}"); + drop(client); + assert!(server.await.unwrap().is_none(), "sent the leftover session"); + } + #[tokio::test] async fn reauth_refuses_a_different_astation() { let (_state, mut client) = isolated_client("atem-test-pinned");