diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b07ddcb..a63b0af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,25 @@ jobs: name: ${{ matrix.artifact }}-${{ env.VERSION }} path: ${{ matrix.artifact }}-${{ env.VERSION }}.tar.gz + # macOS also ships the menu-bar VT.app (ad-hoc signed) alongside the + # bare `vt` binary. `just app` is the single source of the bundle + # layout; VT_APP_BIN reuses the binary just built (no second compile). + # tar.gz to match the binary artifacts; CLI extraction (`tar xzf`) + # avoids the Gatekeeper quarantine that Finder/zip would propagate. + - name: Package VT.app (macOS) + if: matrix.os == 'macos-latest' + run: | + brew install just + VT_APP_BIN="target/${{ matrix.target }}/release/vt" just app + COPYFILE_DISABLE=1 tar czf VT-app-darwin-arm64-${{ env.VERSION }}.tar.gz -C build VT.app + + - name: Upload VT.app artifact (macOS) + if: matrix.os == 'macos-latest' + uses: actions/upload-artifact@v4 + with: + name: VT-app-darwin-arm64-${{ env.VERSION }} + path: VT-app-darwin-arm64-${{ env.VERSION }}.tar.gz + release: needs: build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index a9d6003..3cb0752 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /.vscode /target +# VT.app bundle output (just app / install-app) +/build GEMINI.md .claude/ .codex diff --git a/CLAUDE.md b/CLAUDE.md index d0e1053..94b1916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,11 @@ pin the transport. See [`config.example.toml`](config.example.toml) and | CLI and command routing | `src/main.rs` | | Transport selection and client protocol | `src/config.rs`, `src/client.rs`, `src/cf.rs` | | `vt://` format and cryptography | `src/core.rs`, `src/core/crypto.rs`, `src/core/wire.rs` | +| Agent authorization scopes, grants, and revocation | `src/core/authorization.rs`, `src/server_macos/authorization.rs`, `src/server_macos/ssh_agent.rs` | | SSH identity and `vt ssh connect` | `src/ssh_sign.rs` | | macOS Keychain, Touch ID, and SSH agent | `src/server_macos/` | | AI-agent command hook | `src/hook.rs`, [`docs/hook.md`](docs/hook.md) | +| VT.app menu-bar shell and bundle packaging | `app/VTShell.swift`, `app/Info.plist`, `justfile` (`app`, `install-app`), [`docs/app-bundle.md`](docs/app-bundle.md) | | Worker routes and admin pages | `cf-worker/src/index.ts`, `cf-worker/pwa/admin/` | | Worker ceremony/cache lifecycle | `cf-worker/src/do_account.ts` | | Worker notification channels | `cf-worker/src/notify.ts`, `pushover.ts`, `slack.ts`, `slack_app.ts`, `feishu.ts` | @@ -48,6 +50,62 @@ pin the transport. See [`config.example.toml`](config.example.toml) and behavior and IP binding. - `auth@vt` and `run@vt` always require a fresh approval. Do not add them to auth caches. +- `auth@vt`, `run@vt`, SSH signing, and `decrypt@vt` all use the unified + authorization engine. Reusable grants are operation- and subject-scoped, + and a non-cloneable permit is committed only after the protected operation + and, for extensions, response encryption succeed. A live permit holds the + global prompt slot and blocks revocation; handlers must not perform + unbounded-latency work while one is live. +- Grants are activity-scoped + ([`docs/authorization-scopes-v2.md`](docs/authorization-scopes-v2.md)): + raw SSH signs bind to the session-bind-verified destination host key + (forwarding-capable or tainted connections are never cached); local vt + callers bind to their kernel-derived `.git` workspace root, falling back + to the exact cwd directory, then — for broad shared cwds (`$HOME`, its + ancestors, temp roots) — to the kernel-derived parent application. Each + fallback is a distinct grant family with its own digest domain; relay + traffic is confined per connection. `session-bind@openssh.com` is plaintext and + must be handled before the VT_AUTH cipher path, and must not reset the + idle clock. The Touch ID prompt must state the reuse scope whenever an + approval can create a grant, and agent-derived truth lines (relay marker, + `caller:`, raw-sign `dest:`/taint warning, reuse) must precede every + client-reported line + ([`docs/approval-transparency.md`](docs/approval-transparency.md)). Cache + duration `0` (the default) maps to the engine's first-class `Fresh` + policy, never `StrictTtl(0)`. +- A live locked/non-interactive check failure revokes all grants. Agent lock, + idle timeout, an observed screen lock, and a detected wake advance the + authorization epoch even when the grant store is empty, so an in-flight + prompt cannot recreate a revoked grant. Screen lock and idle timeout also + wipe decrypted SSH keys from memory (not just grants); `ensure_keys_loaded` + must refuse to reload keys while the screen is non-interactive — checked + both before and after the keychain read — so a request during lock cannot + repopulate RAM ([`docs/app-bundle.md`](docs/app-bundle.md) §10). Idle + timeout is floored at 60s (idle `0` is not a `Fresh`-style special case; + it would busy-loop the sweeper). +- `ui-status@vt` is the ONE deliberate whole-store visibility channel + ([`docs/app-bundle.md`](docs/app-bundle.md) §5): plaintext, dispatched with + `session-bind@openssh.com` before the lock check and the VT_AUTH cipher + path, gated by the 32-byte spawn token the VT.app shell pipes to + `--ui-token-fd` (never env/argv/file; constant-time compare; no token ⇒ + every request fails unstructured). It never resets the idle clock, is never + audit-pushed, and its only actions are `status` and the authority-reducing + `revoke_all` — no action may grant, extend, or approve. The relay filter + must keep refusing it. Grant `display` labels are memory-only. +- The master-key wrap is versioned (`KeychainStore.wrap_v`): new stores are + always wrap v2 (path-independent `vt-wrap-v2` label); v1 stores upgrade + transparently at agent startup via the flock-guarded minimal mutator that + touches ONLY `encrypted_passphrase` + `wrap_v`. Never route a rewrap + through `create_and_save_passcode_passphrase` — it mints a fresh + passcode/auth_token and would silently rotate every client's VT_AUTH. + `vt secret rebind` is the manual migration/rollback path. +- Notifications must never block or fail a protected operation: `notify_macos` + is fire-and-forget (reaper thread), prefers the bundled `VTApp notify` + helper (bundle identity) with `osascript` fallback, and both paths share + one sanitize. Cache-hit notifications fire only AFTER `permit.commit()` + returns — a live permit blocks revocation, so no unbounded-latency work + (e.g. the first-use notification permission dialog) may run while one is + held. - Plaintext secrets and private key seeds must not be written to disk or logs. Do not add credentials to examples, test output, or command-line arguments unless the existing design explicitly requires it. @@ -68,7 +126,9 @@ pin the transport. See [`config.example.toml`](config.example.toml) and between the remote client and the upstream agent. - `diag@vt` is read-only and requires no Touch ID; it must never reset the agent's idle-activity clock (a polling loop must not keep grants alive), and - its `live_entries` stays scoped to the caller's own cache context. + its `live_entries` counts only grants the caller's own scope classification + could reuse — a caller whose basis never caches reports 0, never a + whole-store count. - `vt inject -r` decrypts a file briefly and starts a self-exec'd restore supervisor. Keep the supervisor path before Tokio/clap initialization and keep `--recover` limited to restoring ciphertext backups. diff --git a/README.md b/README.md index 19c058c..95b6fd1 100644 --- a/README.md +++ b/README.md @@ -29,15 +29,38 @@ common paths are: ## Installation -Download prebuilt binaries from [GitHub Releases](https://github.com/timqi/vt/releases) (macOS arm64, Linux amd64). +Download prebuilt artifacts from [GitHub Releases](https://github.com/timqi/vt/releases): +the bare `vt` binary (macOS arm64, Linux amd64) and, for macOS, **VT.app** — a +menu-bar app bundling the same `vt` CLI plus native VT-branded notifications +(including cache-hit transparency), live grant status with one-click +revoke-all, and agent supervision. Or build from source (recipes live in the `justfile`, run `just` to list them): ```bash -# Builds (musl-static on Linux, native on macOS) and installs to ~/.local/bin +# CLI only: builds (musl-static on Linux, native on macOS) and installs to ~/.local/bin just install + +# macOS menu-bar app: assembles VT.app, installs to /Applications, symlinks ~/.local/bin/vt +just install-app +``` + +Installing the **downloaded** VT.app tarball (vs `just install-app`): + +```bash +tar xzf VT-app-darwin-arm64-*.tar.gz # CLI extract avoids Gatekeeper quarantine +mv VT.app /Applications/ +ln -sf /Applications/VT.app/Contents/MacOS/vt ~/.local/bin/vt # put the CLI on PATH +# If Gatekeeper still blocks it (e.g. extracted via Finder): +# xattr -dr com.apple.quarantine /Applications/VT.app ``` +The release build is **ad-hoc signed**, so each release re-triggers the +one-time Keychain authorization prompt on first launch. See +[docs/app-bundle.md](docs/app-bundle.md) — including the one-time +`vt secret rebind` migration if your keychain store was created by a `vt` +binary at another path. + ## Quick Start > **Platform note.** Vault bootstrap and key storage (`init`, `secret *`, @@ -168,11 +191,9 @@ vt ssh show SHA256:... # Start the SSH agent (it listens on ~/.ssh/vt.sock): eval $(vt ssh agent) -# Start with auth caching (skip repeated Touch ID within a time window): -# per-session: cache by terminal session (TTY) -eval $(vt ssh agent --ssh-auth-cache-mode per-session --ssh-auth-cache-duration 300) -# per-app: cache by application (e.g., Terminal.app, iTerm2) -eval $(vt ssh agent --ssh-auth-cache-mode per-app --ssh-auth-cache-duration 300) +# Start with approval reuse (skip repeated Touch ID within a time window; +# grants are activity-scoped — see "Auth Caching" below): +eval $(vt ssh agent --ssh-auth-cache-duration 28800 --decrypt-auth-cache-duration 3600) # Set SSH_AUTH_SOCK to use the agent (add to your shell profile) export SSH_AUTH_SOCK=~/.ssh/vt.sock @@ -193,25 +214,34 @@ Keys are stored as a single encrypted JSON blob inside `rusty.vault.store` (unde #### Auth Caching -By default, Touch ID is required for every sign/decrypt request. You can enable auth caching to skip repeated prompts within a time window: - -| Mode | `--ssh-auth-cache-mode` | Scope | -|------|-------------------------|-------| -| None (default) | `none` | Touch ID every time | -| Per-session | `per-session` | Shared within same terminal/TTY | -| Per-app | `per-app` | Shared within same application (e.g., Terminal.app) | -| Global | `global` | Shared by orchestrated callers; still partitioned by reported working directory | - -`--ssh-auth-cache-duration ` controls the sign cache TTL (default: -120s). `--decrypt-auth-cache-mode` and -`--decrypt-auth-cache-duration` configure a separate cache for v2 envelope -decrypts (default: disabled, 30s when a duration is supplied). Legacy URLs are -never eligible for the decrypt cache. Both caches are cleared when the agent -locks, the screen locks, the Mac wakes from sleep, or the idle timeout clears -keys. `per-session` and `per-app` require a controlling TTY; `global` is the -mode intended for TTY-less orchestrators. Forwarded-agent contexts are narrowed -to the connection; keep caching disabled (`none`) when forwarding to hosts you -do not trust. +By default (`--ssh-auth-cache-duration 0`), Touch ID is required for every +sign/decrypt request. Setting a duration enables **activity-scoped** approval +reuse — the Touch ID prompt always states exactly what is being granted and +for how long: + +| Caller | Grant scope | +|--------|-------------| +| `ssh` / `git fetch` / `git push` (OpenSSH ≥ 8.9) | The **destination server** (verified via `session-bind@openssh.com`): one approval covers repeated one-shot connections to the same host with the same key, from any local caller | +| `ssh-keygen -Y sign` (git commit signing), local `vt ssh connect` | The caller's **git workspace** (kernel-derived `.git` root): one approval covers the project, including multi-host fan-outs and TTY-less AI agents / CI working in the same checkout | +| Local caller outside any git repository | The caller's **exact working directory** (kernel-derived, a separate grant family from git workspaces) | +| Local caller from a broad shared directory (`$HOME`, `/`, temp roots) | The **calling application** (kernel-derived parent process): repeated requests from the same app instance — e.g. a daemon probing `gh` through the hook — share one approval; grants die when the app exits | +| Forwarded / relay traffic (`ssh -A`, `--forward-real-agent`) | vt extensions (`decrypt@vt`, `sign@vt`) are confined **per connection**: a remote host can reuse only its own approvals and never rides local grants. Raw SSH signs arriving through a forwarding-capable connection are never cached at all | +| OpenSSH < 8.9, `auth@vt`, `run@vt`, legacy URLs | Never cached — always prompts | + +`--ssh-auth-cache-duration ` and `--decrypt-auth-cache-duration ` +are separate knobs (a cached decrypt grant releases per-record DEK material, +so you may want it shorter or disabled). TTLs are strict (no sliding +refresh), and every grant is revoked immediately when the agent locks, the +screen locks, the Mac sleeps/wakes, or the idle timeout fires — the duration +is effectively "within this presence session, at most N hours", so generous +values (8h sign / 1h decrypt) are reasonable. + +For **unattended periodic jobs** (editor auto-fetch, cron), caching is the +wrong tool — any TTL eventually prompts while you are away. Give fetch a +read-only credential instead (a GitHub read-only deploy key via a `Host` +alias with `IdentitiesOnly yes`, or HTTPS with a `contents:read` token), or +keep an ssh `ControlMaster`/`ControlPersist` window longer than the fetch +interval so the connection never re-authenticates. ### Portable SSH identity for git (`vt://`) diff --git a/app/Info.plist b/app/Info.plist new file mode 100644 index 0000000..85701c1 --- /dev/null +++ b/app/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleIdentifier + dev.rustyvault.vt + CFBundleName + VT + CFBundleDisplayName + VT + + CFBundleExecutable + VTApp + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + CFBundleShortVersionString + VT_BUNDLE_VERSION + CFBundleVersion + VT_BUNDLE_VERSION + LSMinimumSystemVersion + 12.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + VT + + diff --git a/app/VTShell.swift b/app/VTShell.swift new file mode 100644 index 0000000..4456f0c --- /dev/null +++ b/app/VTShell.swift @@ -0,0 +1,784 @@ +// VT.app menu-bar shell (docs/app-bundle.md §6). +// +// Roles: +// - menu-bar UI: agent status, live grants (via the token-gated +// `ui-status@vt` channel), revoke-all, start/stop of the managed agent; +// - agent supervisor: spawns `vt ssh agent --ui-token-fd 0` with a random +// 32-byte token written to the child's stdin pipe (never env/argv/file); +// - `VT notify --title T --body B` helper mode: posts a native +// UNUserNotificationCenter notification carrying the bundle identity, +// used by the Rust agent's notify path, then exits. +// +// Security stance: this UI holds no VT_AUTH and can only read status or +// reduce authority (revoke). It never approves anything — Touch ID sheets +// remain the only approval surface. A CLI-started agent (no token) yields a +// degraded read-only view. + +import AppKit +import Foundation +import ServiceManagement +import UserNotifications + +// MARK: - Wire types (mirror src/core.rs UiStatusReq/UiStatusRes) + +struct UiStatusReq: Codable { + var token: String + var action: String +} + +struct UiGrant: Codable { + var operation: String + var family: String + var display: String + var remaining_secs: UInt64 + var ttl_secs: UInt64 +} + +struct UiStatusRes: Codable { + var agent_version: String + var locked: Bool + var sign_ttl_secs: UInt64 + var decrypt_ttl_secs: UInt64 + var idle_timeout_secs: UInt64 + var run_allow_len: Int + var audit_push: Bool + var revoked: Int? + var grants: [UiGrant] +} + +// MARK: - base64url (no padding), matching Rust BASE64_URL_SAFE_NO_PAD + +func base64UrlNoPad(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} + +// MARK: - ssh-agent wire client for ui-status@vt + +/// Minimal ssh-agent protocol client: one connect per request (the agent is +/// connection-oriented and cheap on localhost). Framing per +/// draft-miller-ssh-agent: u32 BE length, then SSH_AGENTC_EXTENSION (27), +/// string(name), raw contents. Success reply is SSH_AGENT_EXTENSION_RESPONSE +/// (29) + string(name) + raw JSON; failure is SSH_AGENT_FAILURE (5). +enum AgentClient { + static var socketPath: String { + (NSHomeDirectory() as NSString).appendingPathComponent(".ssh/vt.sock") + } + + enum Failure: Error { + case noSocket // connect refused / missing — agent not running + case refused // SSH_AGENT_FAILURE — no/wrong token, locked path, etc. + case proto(String) // framing/decoding surprise + } + + static func uiStatus(token: String, action: String) throws -> UiStatusRes { + let req = UiStatusReq(token: token, action: action) + let payload = try JSONEncoder().encode(req) + let reply = try request(name: "ui-status@vt", payload: payload) + return try JSONDecoder().decode(UiStatusRes.self, from: reply) + } + + private static func request(name: String, payload: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw Failure.noSocket } + defer { close(fd) } + + var tv = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + let capacity = MemoryLayout.size(ofValue: addr.sun_path) + guard pathBytes.count <= capacity else { throw Failure.proto("socket path too long") } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + pathBytes.withUnsafeBufferPointer { raw.update(from: $0.baseAddress!, count: $0.count) } + } + let len = socklen_t(MemoryLayout.size) + let connected = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, len) } + } + guard connected == 0 else { throw Failure.noSocket } + + // Frame: u32 BE length | 27 | u32 name-len | name | payload + var body = Data([27]) + var nameLen = UInt32(name.utf8.count).bigEndian + body.append(Data(bytes: &nameLen, count: 4)) + body.append(name.data(using: .utf8)!) + body.append(payload) + var frameLen = UInt32(body.count).bigEndian + var frame = Data(bytes: &frameLen, count: 4) + frame.append(body) + try writeAll(fd, frame) + + let lenBytes = try readExactly(fd, 4) + let replyLen = lenBytes.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } + guard replyLen >= 1, replyLen < 16 * 1024 * 1024 else { throw Failure.proto("bad frame") } + let reply = try readExactly(fd, Int(replyLen)) + + switch reply[reply.startIndex] { + case 29: // SSH_AGENT_EXTENSION_RESPONSE: string(name) + raw payload + var offset = reply.startIndex + 1 + guard reply.count >= 5 else { throw Failure.proto("short reply") } + let n = reply.subdata(in: offset..<(offset + 4)).withUnsafeBytes { + Int($0.load(as: UInt32.self).bigEndian) + } + offset += 4 + n + guard offset <= reply.endIndex else { throw Failure.proto("bad name len") } + return reply.subdata(in: offset.. 0 else { throw Failure.proto("write failed") } + sent += n + } + } + } + + private static func readExactly(_ fd: Int32, _ count: Int) throws -> Data { + var buf = Data(count: count) + var got = 0 + try buf.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in + while got < count { + let n = read(fd, raw.baseAddress! + got, count - got) + guard n > 0 else { throw Failure.proto("short read") } + got += n + } + } + return buf + } +} + +// MARK: - notify helper mode (`VT notify --title T --body B`) + +func runNotifyMode(_ args: [String]) -> Never { + var title = "VT" + var body = "" + var i = 0 + while i < args.count { + switch args[i] { + case "--title": if i + 1 < args.count { title = args[i + 1]; i += 1 } + case "--body": if i + 1 < args.count { body = args[i + 1]; i += 1 } + default: break + } + i += 1 + } + + let center = UNUserNotificationCenter.current() + let done = DispatchSemaphore(value: 0) + var ok = false + center.requestAuthorization(options: [.alert, .sound]) { granted, _ in + guard granted else { done.signal(); return } + let content = UNMutableNotificationContent() + content.title = title + content.body = body + let request = UNNotificationRequest( + identifier: UUID().uuidString, content: content, trigger: nil) + center.add(request) { error in + ok = (error == nil) + done.signal() + } + } + // First use can show the system permission dialog; cap the wait so the + // agent's reaper thread never hangs on us. + _ = done.wait(timeout: .now() + 30) + exit(ok ? 0 : 1) +} + +// MARK: - managed agent supervisor + +final class AgentSupervisor { + /// UserDefaults keys for the menu-chosen cache durations. Absent = pass + /// no flag (the agent's `[agent]` config / built-in default governs); + /// present = pass an explicit spawn flag that overrides both. + static let signCacheKey = "vt.signCacheSecs" + static let decryptCacheKey = "vt.decryptCacheSecs" + static let idleTimeoutKey = "vt.idleTimeoutSecs" + + /// Why the managed agent exited — drives the terminationHandler. + private enum ExitIntent { case crash, stop, restart } + + /// base64url token for ui-status requests; nil = degraded (external agent + /// or none). + private(set) var tokenB64: String? + /// Last start-failure hint (first stderr line of a fast-failing agent), + /// surfaced in the menu. Cleared on a healthy start. + private(set) var lastError: String? + private var process: Process? + private var intent: ExitIntent = .crash + private var startedAt: TimeInterval = 0 + private var restartCount = 0 + var onStateChange: (() -> Void)? + + var isManaged: Bool { process?.isRunning == true } + + func startIfNeeded() { + guard process?.isRunning != true else { return } + // Never fight an externally-started agent for the socket. + if agentListening() { return } + start() + } + + /// Deliberate stop then fresh start — used for a version-skew upgrade + /// (`just install-app` replaced the binary; the running agent still runs + /// the old code). Bypasses the external-agent guard: we own this one. + func restart() { + guard let proc = process, proc.isRunning else { start(); return } + intent = .restart + tokenB64 = nil + proc.terminate() + onStateChange?() + } + + private func agentListening() -> Bool { + // A refused ui-status still proves something is listening. + do { + _ = try AgentClient.uiStatus(token: "", action: "status") + return true + } catch AgentClient.Failure.refused { + return true + } catch { + return false + } + } + + func start() { + guard let vt = Bundle.main.path(forAuxiliaryExecutable: "vt") else { return } + var tokenBytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, 32, &tokenBytes) == errSecSuccess else { return } + let token = Data(tokenBytes) + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: vt) + // Token over the stdin pipe (fd 0): inherited descriptor, never an + // env var (`ps e`) or a file. The agent reads exactly 32 bytes and + // closes it (docs/app-bundle.md §5). + var arguments = ["ssh", "agent", "--ui-token-fd", "0"] + // Menu-chosen durations (if any) as explicit spawn flags — + // flag > config.toml [agent] > default. Absent key ⇒ no flag. + let defaults = UserDefaults.standard + if defaults.object(forKey: Self.signCacheKey) != nil { + arguments += ["--ssh-auth-cache-duration", String(defaults.integer(forKey: Self.signCacheKey))] + } + if defaults.object(forKey: Self.decryptCacheKey) != nil { + arguments += ["--decrypt-auth-cache-duration", String(defaults.integer(forKey: Self.decryptCacheKey))] + } + if defaults.object(forKey: Self.idleTimeoutKey) != nil { + arguments += ["--timeout", String(defaults.integer(forKey: Self.idleTimeoutKey))] + } + proc.arguments = arguments + let stdinPipe = Pipe() + let errPipe = Pipe() + proc.standardInput = stdinPipe + proc.standardOutput = FileHandle.nullDevice + // Capture stderr so a fast-failing agent (e.g. keychain wrap bound to + // the old binary path — needs `vt secret rebind`) surfaces a reason + // instead of a silent "not running". + proc.standardError = errPipe + // The wrap derivation needs USER; launchd contexts may lack it. + var env = ProcessInfo.processInfo.environment + if env["USER"] == nil { env["USER"] = NSUserName() } + proc.environment = env + + let startTime = ProcessInfo.processInfo.systemUptime + proc.terminationHandler = { [weak self] p in + let stderr = String( + data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + DispatchQueue.main.async { + guard let self else { return } + self.tokenB64 = nil + let intent = self.intent + self.intent = .crash + switch intent { + case .stop: + self.process = nil + case .restart: + self.process = nil + // Let the OS release the old socket; the new agent also + // self-heals a stale socket file on bind. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.start() } + case .crash: + // Fast exit (< 3s) = failed startup, not a long-run crash. + // Keep the reason and stop hammering restart. + let fast = ProcessInfo.processInfo.systemUptime - startTime < 3 + if fast || p.terminationStatus != 0 { + self.lastError = stderr + .split(separator: "\n") + .last(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) + .map(String.init) + } + self.process = nil + if self.restartCount < 5 { + self.restartCount += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + Double(self.restartCount) * 2) { + self.startIfNeeded() + } + } + } + self.onStateChange?() + } + } + do { + try proc.run() + } catch { + lastError = "could not launch vt: \(error.localizedDescription)" + onStateChange?() + return + } + stdinPipe.fileHandleForWriting.write(token) + stdinPipe.fileHandleForWriting.closeFile() + process = proc + startedAt = startTime + intent = .crash + tokenB64 = base64UrlNoPad(token) + // Clear the stale-error hint once the agent survives past the + // fast-fail window. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self, self.process === proc, proc.isRunning else { return } + self.restartCount = 0 + self.lastError = nil + self.onStateChange?() + } + onStateChange?() + } + + func stop() { + guard let proc = process, proc.isRunning else { return } + intent = .stop + tokenB64 = nil + proc.terminate() // SIGTERM — the agent removes its socket on exit + onStateChange?() + } +} + +// MARK: - menu bar icon (hex-nut key, template, code-drawn from icon.svg geometry) + +func hexKeyTemplateImage(height: CGFloat) -> NSImage { + // Geometry from cf-worker/pwa/icon.svg (512 viewBox). Content bounding + // box in that space: x 152..360, y 90..412. We fit that box, aspect + // preserved, into a padded status-bar cell so the glyph matches the + // visual weight of SF Symbol menu-bar icons (which carry internal + // padding) instead of filling the whole bar height. + let contentMinX: CGFloat = 152, contentMinY: CGFloat = 90 + let contentW: CGFloat = 360 - 152, contentH: CGFloat = 412 - 90 + let pad: CGFloat = 0.5 // minimal inset — fill the cell + let avail = height - 2 * pad + let scale = avail / contentH // height-dominant (tall key) + let drawW = contentW * scale + let canvasW = drawW + 2 * pad + let ox = pad, oy = pad + + let image = NSImage(size: NSSize(width: canvasW, height: height), flipped: true) { _ in + func p(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + NSPoint(x: ox + (x - contentMinX) * scale, y: oy + (y - contentMinY) * scale) + } + func r(_ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat) -> NSRect { + NSRect(x: ox + (x - contentMinX) * scale, y: oy + (y - contentMinY) * scale, + width: w * scale, height: h * scale) + } + NSColor.black.setFill() + // hex nut bow + let hex = NSBezierPath() + hex.move(to: p(360, 180)) + for (x, y) in [(308, 270), (204, 270), (152, 180), (204, 90), (308, 90)] { + hex.line(to: p(CGFloat(x), CGFloat(y))) + } + hex.close() + // keyhole punched out of the nut + let hole = NSBezierPath() + hole.appendOval(in: r(226, 136, 60, 60)) + hole.move(to: p(240, 182)) + hole.line(to: p(272, 182)) + hole.line(to: p(282, 246)) + hole.line(to: p(230, 246)) + hole.close() + hex.append(hole.reversed) + hex.windingRule = .evenOdd + hex.fill() + // shaft + teeth + NSBezierPath(roundedRect: r(235, 250, 42, 162), xRadius: 18 * scale, yRadius: 18 * scale).fill() + NSBezierPath(roundedRect: r(276, 330, 40, 24), xRadius: 6 * scale, yRadius: 6 * scale).fill() + NSBezierPath(roundedRect: r(276, 372, 26, 24), xRadius: 6 * scale, yRadius: 6 * scale).fill() + return true + } + image.isTemplate = true + return image +} + +// MARK: - app delegate / menu + +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + private var statusItem: NSStatusItem! + private let supervisor = AgentSupervisor() + private var pollTimer: Timer? + private var lastStatus: UiStatusRes? + private var agentReachable = false + private var bundledVersion: String = "" + + func applicationDidFinishLaunching(_ notification: Notification) { + bundledVersion = readBundledVtVersion() + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + // Fill the status-bar cell like neighboring icons (~18pt). + statusItem.button?.image = hexKeyTemplateImage(height: 18) + statusItem.button?.imagePosition = .imageLeft + let menu = NSMenu() + menu.delegate = self + statusItem.menu = menu + + supervisor.onStateChange = { [weak self] in self?.poll() } + supervisor.startIfNeeded() + // Ask for notification permission at first launch so the agent's + // notify helper calls never block on the permission dialog. + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } + + poll() + pollTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in + self?.poll() + } + } + + private func readBundledVtVersion() -> String { + guard let vt = Bundle.main.path(forAuxiliaryExecutable: "vt") else { return "" } + let proc = Process() + proc.executableURL = URL(fileURLWithPath: vt) + proc.arguments = ["version"] + let out = Pipe() + proc.standardOutput = out + proc.standardError = FileHandle.nullDevice + guard (try? proc.run()) != nil else { return "" } + proc.waitUntilExit() + let data = out.fileHandleForReading.readDataToEndOfFile() + // `vt version` prints two lines: + // vt <- field 2 of the FIRST line only + // commit () + // The version already carries its own leading "v"; do not add one. + let text = String(data: data, encoding: .utf8) ?? "" + guard let firstLine = text.split(separator: "\n").first else { return "" } + let parts = firstLine.split(separator: " ") + return parts.count >= 2 ? String(parts[1]) : "" + } + + /// Refresh ui-status for icon/menu state. The socket I/O (blocking + /// `connect`/`read`, up to a 5s timeout on a wedged agent) runs on a + /// background queue — never the main thread — so a slow/mid-restart agent + /// can't freeze the menu bar; only the state write + icon update hop back + /// to main. Safe by construction: ui-status never resets the agent's idle + /// clock (docs/app-bundle.md §5). Callers on main read the last result; + /// the 3s timer keeps it fresh (≤3s stale on menu open). + private func poll() { + let token = supervisor.tokenB64 // read on main; supervisor mutates it on main + DispatchQueue.global(qos: .utility).async { [weak self] in + var status: UiStatusRes? + var reachable = false + if let token = token { + status = try? AgentClient.uiStatus(token: token, action: "status") + reachable = status != nil + } + if status == nil { + // Degraded probe: refused = an agent listens but we hold no token. + do { + _ = try AgentClient.uiStatus(token: "", action: "status") + } catch AgentClient.Failure.refused { + reachable = true + } catch {} + } + DispatchQueue.main.async { + guard let self else { return } + self.lastStatus = status + self.agentReachable = reachable + self.updateIcon() + } + } + } + + private func updateIcon() { + guard let button = statusItem.button else { return } + button.appearsDisabled = !agentReachable + if let s = lastStatus { + if s.locked { + button.title = " ⊘" + } else if !s.grants.isEmpty { + button.title = " \(s.grants.count)" + } else { + button.title = "" + } + } else { + button.title = "" + } + } + + func menuWillOpen(_ menu: NSMenu) { + poll() + rebuildMenu(menu) + } + + private func fmtRemaining(_ secs: UInt64) -> String { + String(format: "%d:%02d", secs / 60, secs % 60) + } + + private func rebuildMenu(_ menu: NSMenu) { + menu.removeAllItems() + + // Status line (agent_version already carries its own leading "v") + let statusText: String + if let s = lastStatus { + statusText = s.locked + ? "Agent locked · \(s.agent_version)" + : "Agent running · \(s.agent_version) · \(s.grants.count) grant\(s.grants.count == 1 ? "" : "s")" + } else if agentReachable { + statusText = "Agent running (started outside VT.app — limited view)" + } else { + statusText = "Agent not running" + } + menu.addItem(disabled(statusText)) + + // Version skew: if we own the agent, offer a one-click upgrade + // restart; otherwise it's an external agent we can only warn about. + if let s = lastStatus, !bundledVersion.isEmpty, s.agent_version != bundledVersion { + if supervisor.isManaged { + addItem(menu, "⚠ Restart Agent to update (\(s.agent_version) → \(bundledVersion))", + #selector(restartAgent)) + } else { + menu.addItem(disabled("⚠ agent \(s.agent_version) ≠ app \(bundledVersion) — restart agent")) + } + } + + // Managed agent failed to start (common cause: keychain wrap still + // bound to the old binary path — run `vt secret rebind`). + if !supervisor.isManaged, !agentReachable, let err = supervisor.lastError, !err.isEmpty { + menu.addItem(disabled("⚠ agent failed to start:")) + menu.addItem(disabled(" \(String(err.prefix(120)))")) + addItem(menu, "Run Doctor…", #selector(runDoctor)) + } + + menu.addItem(.separator()) + + // Grants + if let s = lastStatus { + if s.grants.isEmpty { + menu.addItem(disabled(s.sign_ttl_secs == 0 && s.decrypt_ttl_secs == 0 + ? "No grants (caching disabled — every request prompts)" + : "No live grants")) + } else { + let grantsItem = NSMenuItem(title: "Grants (\(s.grants.count))", action: nil, keyEquivalent: "") + let sub = NSMenu() + for g in s.grants { + sub.addItem(disabled("\(g.operation) · \(g.display.isEmpty ? g.family : g.display) · \(fmtRemaining(g.remaining_secs))")) + } + grantsItem.submenu = sub + menu.addItem(grantsItem) + } + let revoke = NSMenuItem(title: "Revoke All Grants", action: #selector(revokeAll), keyEquivalent: "l") + revoke.target = self + menu.addItem(revoke) + } + + // Cache duration (managed agent only — applying restarts the agent, + // which reads the TTL at startup). Choices persist in UserDefaults + // and are passed as spawn flags (§4/§6); the checkmark reflects the + // agent's live TTL from ui-status, whatever its source. + if let s = lastStatus, supervisor.isManaged { + let cacheItem = NSMenuItem(title: "Cache Duration", action: nil, keyEquivalent: "") + let sub = NSMenu() + let sign = NSMenuItem(title: "Signing — \(AppDelegate.fmtTTL(s.sign_ttl_secs))", action: nil, keyEquivalent: "") + sign.submenu = presetChoices(key: AgentSupervisor.signCacheKey, presets: Self.cachePresets, selector: #selector(setSignCache)) + sub.addItem(sign) + let decrypt = NSMenuItem(title: "Decrypt — \(AppDelegate.fmtTTL(s.decrypt_ttl_secs))", action: nil, keyEquivalent: "") + decrypt.submenu = presetChoices(key: AgentSupervisor.decryptCacheKey, presets: Self.cachePresets, selector: #selector(setDecryptCache)) + sub.addItem(decrypt) + cacheItem.submenu = sub + menu.addItem(cacheItem) + + // Idle timeout — VISIBLE so cache expiry is debuggable, not + // "mysterious" (docs/app-bundle.md §10). Line shows the live + // value; submenu configures it. + let idleItem = NSMenuItem( + title: "Idle timeout — \(AppDelegate.fmtTTL(s.idle_timeout_secs)) (clears cache when unused)", + action: nil, keyEquivalent: "") + idleItem.submenu = presetChoices(key: AgentSupervisor.idleTimeoutKey, presets: Self.idlePresets, selector: #selector(setIdleTimeout)) + menu.addItem(idleItem) + } + + menu.addItem(.separator()) + + // Agent control (managed only) + if supervisor.isManaged { + addItem(menu, "Stop Agent", #selector(stopAgent)) + } else if !agentReachable { + addItem(menu, "Start Agent", #selector(startAgent)) + } + + // Login item + if #available(macOS 13.0, *) { + let login = NSMenuItem(title: "Start at Login", action: #selector(toggleLogin), keyEquivalent: "") + login.target = self + login.state = SMAppService.mainApp.status == .enabled ? .on : .off + menu.addItem(login) + } + + addItem(menu, "Run Doctor…", #selector(runDoctor)) + + menu.addItem(.separator()) + addItem(menu, "Quit (agent keeps running)", #selector(quitKeepAgent)) + if supervisor.isManaged { + addItem(menu, "Stop Agent and Quit", #selector(quitStopAgent)) + } + } + + private func disabled(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } + + private func addItem(_ menu: NSMenu, _ title: String, _ sel: Selector) { + let item = NSMenuItem(title: title, action: sel, keyEquivalent: "") + item.target = self + menu.addItem(item) + } + + @objc private func revokeAll() { + guard let token = supervisor.tokenB64 else { return } + DispatchQueue.global().async { + // May wait while a live permit holds the security gate — that is + // the engine's linearized revoker semantics, not a hang. + _ = try? AgentClient.uiStatus(token: token, action: "revoke_all") + DispatchQueue.main.async { self.poll() } + } + } + + @objc private func startAgent() { supervisor.startIfNeeded() } + @objc private func stopAgent() { supervisor.stop() } + @objc private func restartAgent() { supervisor.restart() } + + private static let cachePresets: [(String, Int)] = [ + ("Off (always prompt)", 0), ("5 minutes", 300), ("15 minutes", 900), + ("1 hour", 3600), ("2 hours", 7200), ("8 hours", 28800), + ] + /// Tag sentinel for "remove the shell's override, let config.toml / + /// default govern again". Distinct from any real second-count. + private static let followConfigTag = -1 + + static func fmtTTL(_ secs: UInt64) -> String { + if secs == 0 { return "off" } + if secs % 3600 == 0 { return "\(secs / 3600)h" } + return "\(secs / 60)m" + } + + // Idle-timeout presets. No "Off"/0 — idle 0 would busy-loop the agent + // sweeper (agent also floors it at 60s). Cache presets are `cachePresets` + // above (includes Off). + private static let idlePresets: [(String, Int)] = [ + ("15 minutes", 900), ("30 minutes", 1800), + ("1 hour", 3600), ("2 hours", 7200), ("8 hours", 28800), + ] + + /// Build a preset submenu for one agent duration knob. `key` is the + /// UserDefaults override key; the checkmark shows what the SHELL is doing + /// (a specific override vs "Follow config file"), while the parent menu + /// line carries the live effective value. Shared by the cache and + /// idle-timeout submenus. + private func presetChoices(key: String, presets: [(String, Int)], selector: Selector) -> NSMenu { + let overridden = UserDefaults.standard.object(forKey: key) != nil + let overrideVal = UserDefaults.standard.integer(forKey: key) + let m = NSMenu() + let follow = NSMenuItem(title: "Follow config file", action: selector, keyEquivalent: "") + follow.target = self + follow.tag = Self.followConfigTag + follow.state = overridden ? .off : .on + m.addItem(follow) + m.addItem(.separator()) + for (label, secs) in presets { + let it = NSMenuItem(title: label, action: selector, keyEquivalent: "") + it.target = self + it.tag = secs + it.state = (overridden && secs == overrideVal) ? .on : .off + m.addItem(it) + } + return m + } + + // Persist the choice (spawn flag on next start) — or clear it to follow + // config — then restart the managed agent so it takes effect. Dropping + // current grants on restart is acceptable for a policy change. + private func applyPreset(_ sender: NSMenuItem, key: String) { + if sender.tag == AppDelegate.followConfigTag { + UserDefaults.standard.removeObject(forKey: key) + } else { + UserDefaults.standard.set(sender.tag, forKey: key) + } + supervisor.restart() + } + + // Thin @objc shims: the UserDefaults key can't ride an @objc selector, so + // each knob needs its own one-line entry point delegating to applyPreset. + @objc private func setSignCache(_ sender: NSMenuItem) { + applyPreset(sender, key: AgentSupervisor.signCacheKey) + } + + @objc private func setDecryptCache(_ sender: NSMenuItem) { + applyPreset(sender, key: AgentSupervisor.decryptCacheKey) + } + + @objc private func setIdleTimeout(_ sender: NSMenuItem) { + applyPreset(sender, key: AgentSupervisor.idleTimeoutKey) + } + + @objc private func toggleLogin() { + if #available(macOS 13.0, *) { + let service = SMAppService.mainApp + do { + if service.status == .enabled { try service.unregister() } + else { try service.register() } + } catch { + NSLog("login item toggle failed: \(error)") + } + } + } + + @objc private func runDoctor() { + guard let vt = Bundle.main.path(forAuxiliaryExecutable: "vt") else { return } + let script = "tell application \"Terminal\"\nactivate\ndo script \"\(vt) doctor\"\nend tell" + if let osa = NSAppleScript(source: script) { + osa.executeAndReturnError(nil) + } + } + + @objc private func quitKeepAgent() { + // Deliberately do NOT stop the managed agent: it survives the UI and + // keeps serving; the token dies with us so ui-status degrades until + // the next shell start restarts it. + NSApp.terminate(nil) + } + + @objc private func quitStopAgent() { + supervisor.stop() + NSApp.terminate(nil) + } +} + +// MARK: - entry point + +let args = Array(CommandLine.arguments.dropFirst()) +if args.first == "notify" { + runNotifyMode(Array(args.dropFirst())) +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/cf-worker/pwa/admin/audit.js b/cf-worker/pwa/admin/audit.js index 7359402..13d740e 100644 --- a/cf-worker/pwa/admin/audit.js +++ b/cf-worker/pwa/admin/audit.js @@ -323,6 +323,15 @@ addRow(dl, '终端', r.tty); addRow(dl, '父进程', r.ppid_cmd); if (r.ppid != null) addRow(dl, '父进程PID', r.ppid); + // Agent-authoritative fields (source='agent' rows; addRow skips ''/null, + // so pre-migration and non-agent rows render unchanged). + addRow(dl, '调用进程', r.peer_exe); + addRow(dl, '密钥', r.key_fp); + addRow(dl, '目的主机', r.dest); + addRow(dl, '复用范围', r.scope_label); + addRow(dl, '范围类型', r.scope_family); + if (typeof r.grant_ttl_s === 'number' && r.grant_ttl_s > 0) addRow(dl, '授权时长', cacheTtlLabel(r.grant_ttl_s)); + if (r.relayed === 1) addRow(dl, '经中继', '是'); addRow(dl, 'SSH 来源', r.ssh_client); addRow(dl, 'IP', r.ip); addRow(dl, 'DEK 数', r.salts); diff --git a/cf-worker/pwa/approve.js b/cf-worker/pwa/approve.js index fe292ac..5588b7a 100644 --- a/cf-worker/pwa/approve.js +++ b/cf-worker/pwa/approve.js @@ -58,9 +58,11 @@ cacheSec.appendChild(el('h2', null, '缓存解密授权')); var warn = el('p', 'hint cache-warn'); // Cache ctx binds source IP (hard) + working directory (advisory) — see - // docs/dek-cache.md. Built as nodes to keep the emphasis under CSP. + // docs/dek-cache.md. The copy states each half's trust level so the + // promised boundary matches the implemented one. Built as nodes to keep + // the emphasis under CSP. warn.appendChild(document.createTextNode('选择后,在该时长内、')); - warn.appendChild(el('strong', null, '同一来源 IP 且同一工作目录')); + warn.appendChild(el('strong', null, '同一来源 IP(已验证)且同一工作目录(客户端自报)')); warn.appendChild(document.createTextNode('对这些记录的解密将')); warn.appendChild(el('strong', null, '免手机审批')); warn.appendChild(document.createTextNode('。默认不缓存。')); @@ -112,6 +114,10 @@ var meta = data.metadata; refs.meta.innerHTML = ''; if (meta) { + // Only `ip` is worker-verified (CF-Connecting-IP); every other + // field is client-reported display data — labeled so an + // approver weighs them accordingly (the footnote below states + // the rule once). var fields = [ ['op_kind', '类型'], ['host', '主机'], @@ -121,7 +127,7 @@ ['tty', '终端'], ['ppid_cmd', '父进程'], ['ssh_client', 'SSH 来源'], - ['ip', 'IP'], + ['ip', 'IP(已验证)'], ['reason', '原因'] ]; for (var i = 0; i < fields.length; i++) { @@ -132,6 +138,19 @@ row.appendChild(el('dd', null, String(meta[key]))); refs.meta.appendChild(row); } + // Decrypt batch size — worker-derived (the DEKs this approval + // would mint), not client-claimed meta. An anomalous batch is + // exactly what an approver should see before tapping 同意. + var salts = Array.isArray(data.salts_b64u) ? data.salts_b64u.length : 0; + if (salts > 0) { + var srow = document.createElement('div'); + srow.appendChild(el('dt', null, '记录数')); + srow.appendChild(el('dd', null, String(salts) + ' 条')); + refs.meta.appendChild(srow); + } + var note = el('p', 'hint vt-ap-meta-note', + '除 IP 外均为客户端自报信息,仅供参考。'); + refs.meta.parentNode.appendChild(note); } } diff --git a/cf-worker/src/do_account.ts b/cf-worker/src/do_account.ts index 0b63f60..9f018e0 100644 --- a/cf-worker/src/do_account.ts +++ b/cf-worker/src/do_account.ts @@ -55,10 +55,17 @@ const MAX_ADMIN_SOCKETS = 8; // Column projection shared by opAuditQuery and the real-time broadcast, so a // pushed row is byte-for-byte the same shape the REST query returns (no field // can leak into the stream that the query itself does not already expose). +// Decrypt batch size of a ceremony — worker-derived (from the salts array the +// DEKs are minted for), not client-claimed meta. Joins the notification head +// line as `user@host · N 条`. +const chSalts = (ch: Challenge): number => + Array.isArray(ch.salts_b64u) ? ch.salts_b64u.length : 0; + const AUDIT_SELECT_COLS = `id, token_id, created_ms, finalized_ms, status, op_kind, command, reason, host, user, pwd, tty, ppid_cmd, ssh_client, ip, salts, latency_ms, - verify_failures, cache_ttl_s, ppid, source, seq`; + verify_failures, cache_ttl_s, ppid, source, seq, + peer_exe, key_fp, dest, scope_family, scope_label, grant_ttl_s, relayed`; // DEK cache entry key. ctx binds the entry to (a) the requester's worker-derived // IP (CF-Connecting-IP — unspoofable by the client, the hard boundary) AND (b) @@ -180,7 +187,14 @@ export class AccountDO extends DurableObject { cache_ttl_s INTEGER, ppid INTEGER, source TEXT NOT NULL DEFAULT 'ceremony', - seq INTEGER + seq INTEGER, + peer_exe TEXT, + key_fp TEXT, + dest TEXT, + scope_family TEXT, + scope_label TEXT, + grant_ttl_s INTEGER, + relayed INTEGER )`, ); // Additive migrations for older audit tables (token_id present but newer @@ -221,6 +235,21 @@ export class AccountDO extends DurableObject { this.ctx.storage.sql.exec(`ALTER TABLE audit ADD COLUMN seq INTEGER`); this.ctx.storage.sql.exec(`UPDATE audit SET seq = id WHERE seq IS NULL`); } + // Agent-authoritative audit context (docs/approval-transparency.md §B). + // Re-snapshot table_info — the `source`/`seq` ALTERs above invalidated + // the earlier snapshots (per the note on the `source` migration). All + // seven are plain nullable adds: NULL on pre-migration rows means "the + // agent never sent the field", which is exactly the ingest convention. + const colsForAgentCtx = this.ctx.storage.sql + .exec<{ name: string }>(`PRAGMA table_info(audit)`) + .toArray(); + if (colsForAgentCtx.length > 0 && !colsForAgentCtx.some(c => c.name === 'peer_exe')) { + for (const col of ['peer_exe TEXT', 'key_fp TEXT', 'dest TEXT', + 'scope_family TEXT', 'scope_label TEXT', + 'grant_ttl_s INTEGER', 'relayed INTEGER']) { + this.ctx.storage.sql.exec(`ALTER TABLE audit ADD COLUMN ${col}`); + } + } // Drop the short-lived standalone cache_audit table from an earlier build // of this branch — its events now live in the unified audit table. this.ctx.storage.sql.exec(`DROP TABLE IF EXISTS cache_audit`); @@ -415,8 +444,9 @@ export class AccountDO extends DurableObject { try { const cursor = this.ctx.storage.sql.exec( `INSERT INTO audit - (token_id, created_ms, finalized_ms, status, op_kind, command, reason, host, user, pwd, tty, ppid_cmd, ssh_client, ip, salts, latency_ms, ppid, source, seq) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'agent', ?) + (token_id, created_ms, finalized_ms, status, op_kind, command, reason, host, user, pwd, tty, ppid_cmd, ssh_client, ip, salts, latency_ms, ppid, source, seq, + peer_exe, key_fp, dest, scope_family, scope_label, grant_ttl_s, relayed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'agent', ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(token_id) DO NOTHING`, op.token_id, op.ts_ms, op.ts_ms, op.outcome, m.op_kind ?? null, m.command ?? null, m.reason ?? null, m.host ?? null, @@ -424,6 +454,11 @@ export class AccountDO extends DurableObject { m.ssh_client ?? null, m.ip ?? null, op.salts, op.latency_ms, typeof m.ppid === 'number' ? m.ppid : null, this.nextSeq(), + // Agent-authoritative context: the ingest already normalized these to + // string/number/null — `?? null` only guards a malformed internal op. + op.peer_exe ?? null, op.key_fp ?? null, op.dest ?? null, + op.scope_family ?? null, op.scope_label ?? null, + op.grant_ttl_s ?? null, op.relayed ?? null, ); // Skip the broadcast on an idempotent-retry no-op (agent's 1-retry). if (cursor.rowsWritten > 0) this.broadcastRow(op.token_id, 'insert'); @@ -664,7 +699,8 @@ export class AccountDO extends DurableObject { // failure mode of the race is a card that never leaves "⏳", which this closes. private async feishuSendAndStore(cfg: FeishuConfig, ch: Challenge, approveUrl: string): Promise { try { - const id = await sendApprovalCard(cfg, this.feishuKv(), Date.now(), ch.meta.op_kind, ch.meta, approveUrl); + const id = await sendApprovalCard( + cfg, this.feishuKv(), Date.now(), ch.meta.op_kind, ch.meta, approveUrl, chSalts(ch)); if (!id) { logErr('feishu.send_failed', 'no message_id'); return; } const cur = await this.ctx.storage.get(`ch:${ch.approve_token}`); if (!cur) return; // expired + swept before the send returned @@ -675,7 +711,9 @@ export class AccountDO extends DurableObject { // id. Approver label is unavailable on this path (opApprove already ran // without an id) — degrade to latency-only; this race is rare + cosmetic. const latencyMs = cur.finalized_ms != null ? cur.finalized_ms - cur.created_ms : undefined; - const w = await editCard(cfg, this.feishuKv(), Date.now(), id, cur.status as FeishuState, cur.meta.op_kind, cur.meta, { latencyMs }); + const w = await editCard( + cfg, this.feishuKv(), Date.now(), id, cur.status as FeishuState, + cur.meta.op_kind, cur.meta, { latencyMs }, chSalts(cur)); if (w) logErr('feishu.edit_failed', w); } } catch (e) { logErr('feishu.send_failed', e); } @@ -697,7 +735,7 @@ export class AccountDO extends DurableObject { const mid = ch.feishu_message_id; const meta = ch.meta; this.ctx.waitUntil( - editCard(cfg, this.feishuKv(), Date.now(), mid, state, meta.op_kind, meta, extra) + editCard(cfg, this.feishuKv(), Date.now(), mid, state, meta.op_kind, meta, extra, chSalts(ch)) .then((w) => { if (w) logErr('feishu.edit_failed', w); }) .catch((e) => logErr('feishu.edit_failed', e)), ); @@ -720,7 +758,7 @@ export class AccountDO extends DurableObject { // race: if the decision landed first, edit straight to the terminal state. private async slackAppSendAndStore(cfg: SlackAppConfig, ch: Challenge, approveUrl: string): Promise { try { - const ref = await sendSlackAppCard(cfg, ch.meta.op_kind, ch.meta, approveUrl); + const ref = await sendSlackAppCard(cfg, ch.meta.op_kind, ch.meta, approveUrl, chSalts(ch)); if (!ref) { logErr('slackapp.send_failed', 'no ts'); return; } const cur = await this.ctx.storage.get(`ch:${ch.approve_token}`); if (!cur) return; // expired + swept before the send returned @@ -731,7 +769,9 @@ export class AccountDO extends DurableObject { // ref. Approver label is unavailable on this path (opApprove already ran // without a ref) — degrade to latency-only; this race is rare + cosmetic. const latencyMs = cur.finalized_ms != null ? cur.finalized_ms - cur.created_ms : undefined; - const w = await editSlackAppCard(cfg, ref, cur.status as SlackAppState, cur.meta.op_kind, cur.meta, { latencyMs }); + const w = await editSlackAppCard( + cfg, ref, cur.status as SlackAppState, cur.meta.op_kind, cur.meta, + { latencyMs }, chSalts(cur)); if (w) logErr('slackapp.edit_failed', w); } } catch (e) { logErr('slackapp.send_failed', e); } @@ -751,7 +791,7 @@ export class AccountDO extends DurableObject { const ref: SlackAppMsgRef = ch.slackapp; const meta = ch.meta; this.ctx.waitUntil( - editSlackAppCard(cfg, ref, state, meta.op_kind, meta, extra) + editSlackAppCard(cfg, ref, state, meta.op_kind, meta, extra, chSalts(ch)) .then((w) => { if (w) logErr('slackapp.edit_failed', w); }) .catch((e) => logErr('slackapp.edit_failed', e)), ); diff --git a/cf-worker/src/feishu.ts b/cf-worker/src/feishu.ts index 88ed632..17c8611 100644 --- a/cf-worker/src/feishu.ts +++ b/cf-worker/src/feishu.ts @@ -227,13 +227,13 @@ export interface EditExtra { function buildCard( state: FeishuState, opKind: string, - meta: Pick, - opts: { approveUrl?: string; mention?: string[]; extra?: EditExtra }, + meta: Pick, + opts: { approveUrl?: string; mention?: string[]; extra?: EditExtra; salts?: number }, ): unknown { const h = HEADER[state]; const title = `${h.emoji} VT 审批${opKind ? `: ${opKind}` : ''} — ${h.label}`; - const context = metaLines(meta); + const context = metaLines(meta, opts.salts ?? 0); if (state === 'approved') { const bits: string[] = []; if (opts.extra?.approverLabel) bits.push(`批准人(Passkey): ${opts.extra.approverLabel}`); @@ -285,8 +285,9 @@ export async function sendApprovalCard( opKind: string, meta: ChallengeMeta, approveUrl: string, + salts = 0, ): Promise { - const card = buildCard('pending', opKind, meta, { approveUrl, mention: cfg.mention }); + const card = buildCard('pending', opKind, meta, { approveUrl, mention: cfg.mention, salts }); const url = `${apiBase(cfg)}/open-apis/im/v1/messages?receive_id_type=${encodeURIComponent(cfg.receiveIdType)}`; const res = await withToken(cfg, kv, now, (token) => apiCall(url, 'POST', { receive_id: cfg.receiveId, @@ -309,8 +310,11 @@ export async function editCard( opKind: string, meta: ChallengeMeta, extra: EditExtra = {}, + salts = 0, ): Promise { - const card = buildCard(state, opKind, meta, { extra }); + // Terminal edits keep the batch-size segment: the context block must stay + // identical to the pending card apart from the state line (no drift). + const card = buildCard(state, opKind, meta, { extra, salts }); const url = `${apiBase(cfg)}/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`; const res = await withToken(cfg, kv, now, (token) => apiCall(url, 'PATCH', { content: JSON.stringify(card), diff --git a/cf-worker/src/index.ts b/cf-worker/src/index.ts index 5adb093..8e165f6 100644 --- a/cf-worker/src/index.ts +++ b/cf-worker/src/index.ts @@ -320,7 +320,10 @@ app.post('/api/challenge', async (c) => { const origin = c.env.WORKER_ORIGIN; const approveUrl = `${origin}/a/${approveToken}`; - const pushWarning = await notifyApproval(c.env, ch.meta.op_kind, ch.meta, approveUrl); + const pushWarning = await notifyApproval( + c.env, ch.meta.op_kind, ch.meta, approveUrl, + Array.isArray(ch.salts_b64u) ? ch.salts_b64u.length : 0, + ); if (pushWarning) logErr('notify.failed', pushWarning, { at: tokenPrefix(approveToken) }); log('challenge.created', { @@ -451,6 +454,16 @@ app.post('/api/audit-ingest', async (c) => { const clampInt = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v >= 0) ? Math.floor(v) : 0; + // Null-preserving variants for the agent-authoritative fields: an old agent + // that never sent the field must store SQL NULL, distinguishable from a new + // agent's explicit ''/0/false ("not applicable"). capMeta/clampInt would + // coerce absent to ''/0 and erase that distinction. + const capOrNull = (v: unknown, max: number): string | null => + v === undefined || v === null ? null : capMeta(v, max); + const clampIntOrNull = (v: unknown): number | null => + v === undefined || v === null ? null : clampInt(v); + const boolOrNull = (v: unknown): number | null => + typeof v === 'boolean' ? (v ? 1 : 0) : null; // ts_ms must itself be within the replay window — otherwise an HMAC-verified // (but buggy/compromised) agent could write a far-future ts_ms that escapes // the 90-day retention sweep, or a ts_ms=0 row that's swept immediately. @@ -465,6 +478,13 @@ app.post('/api/audit-ingest', async (c) => { latency_ms: clampInt(entry.latency_ms), ts_ms: tsMs, meta: capChallengeMeta(entry.meta, c.req.header('CF-Connecting-IP')), + peer_exe: capOrNull(entry.peer_exe, 160), + key_fp: capOrNull(entry.key_fp, 160), + dest: capOrNull(entry.dest, 160), + scope_family: capOrNull(entry.scope_family, 32), + scope_label: capOrNull(entry.scope_label, 160), + grant_ttl_s: clampIntOrNull(entry.grant_ttl_s), + relayed: boolOrNull(entry.relayed), }; const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromName('account')); diff --git a/cf-worker/src/notify.ts b/cf-worker/src/notify.ts index 5293143..17f6e11 100644 --- a/cf-worker/src/notify.ts +++ b/cf-worker/src/notify.ts @@ -11,17 +11,23 @@ function truncate(s: string, max = 512): string { return s.length <= max ? s : s.slice(0, max - 1) + '…'; } -// The shared context lines (who / pwd / cmd / ssh / ip / reason) that every -// notification body carries. Approval and cache-hit notices differ only in their -// title and trailing line, so they build on this common block — keeping the two -// from silently drifting if a field is added later. Exported so feishu.ts renders -// the SAME context block (as plain_text) rather than keeping a third copy. +// The shared context lines (who / pwd / cmd / via / ssh / ip / reason) that +// every notification body carries. Approval and cache-hit notices differ only in +// their title and trailing line, so they build on this common block — keeping the +// two from silently drifting if a field is added later. Exported so feishu.ts +// renders the SAME context block (as plain_text) rather than keeping a third copy. +// `salts` (the decrypt batch size, from the ceremony's salts_b64u — worker-derived, +// not client-claimed meta) joins the head line as `user@host · N 条`, mirroring the +// cache-hit head: an anomalous batch is exactly what an approver should see before +// tapping the link. 0 (auth/encrypt, or callers without a ceremony) drops the segment. export function metaLines( - meta: Pick, + meta: Pick, + salts = 0, ): string[] { const who = [meta.user, meta.host].filter(Boolean).join('@'); const lines: string[] = []; - if (who) lines.push(who); + const head = [who, salts > 0 ? `${salts} 条` : ''].filter(Boolean).join(' · '); + if (head) lines.push(head); if (meta.pwd) lines.push(`pwd: ${meta.pwd}`); if (meta.command) { // The CLI sends `command` as a self-labelled multi-line body @@ -29,6 +35,11 @@ export function metaLines( // would duplicate the labels. Inline single-line legacy commands. lines.push(meta.command.includes('\n') ? meta.command : `cmd: ${meta.command}`); } + // The parent-process line mirrors the approval page's 父进程 row: users often + // decide from the notification alone, and "which program asked" is a + // higher-signal field than tty (still omitted here). Client-claimed, like + // every meta field except ip. + if (meta.ppid_cmd) lines.push(`via: ${meta.ppid_cmd}`); if (meta.ssh_client) lines.push(`ssh: ${meta.ssh_client}`); if (meta.ip) lines.push(`ip: ${meta.ip}`); if (meta.reason) lines.push(`reason: ${meta.reason}`); @@ -38,11 +49,12 @@ export function metaLines( // Compose the human-facing approval message once; every channel reuses it. export function buildApprovalMessage( opKind: string, - meta: Pick, + meta: Pick, approveUrl: string, + salts = 0, ): { title: string; body: string } { const title = opKind ? `VT 审批: ${opKind}` : 'VT 审批请求'; - const lines = metaLines(meta); + const lines = metaLines(meta, salts); lines.push(approveUrl); return { title, body: lines.join('\n') }; } @@ -144,8 +156,9 @@ export async function notifyApproval( opKind: string, meta: ChallengeMeta, approveUrl: string, + salts = 0, ): Promise { - const { title, body } = buildApprovalMessage(opKind, meta, approveUrl); + const { title, body } = buildApprovalMessage(opKind, meta, approveUrl, salts); return fanOut(env, title, body); } diff --git a/cf-worker/src/slack_app.ts b/cf-worker/src/slack_app.ts index a106b91..15305c4 100644 --- a/cf-worker/src/slack_app.ts +++ b/cf-worker/src/slack_app.ts @@ -181,13 +181,13 @@ export interface EditExtra { function buildMessage( state: SlackAppState, opKind: string, - meta: Pick, - opts: { approveUrl?: string; mention?: string[]; extra?: EditExtra }, + meta: Pick, + opts: { approveUrl?: string; mention?: string[]; extra?: EditExtra; salts?: number }, ): { text: string; color: string; blocks: unknown[] } { const h = HEADER[state]; const title = `${h.emoji} VT 审批${opKind ? `: ${opKind}` : ''} — ${h.label}`; - const context = metaLines(meta); + const context = metaLines(meta, opts.salts ?? 0); if (state === 'approved') { const bits: string[] = []; if (opts.extra?.approverLabel) bits.push(`批准人(Passkey): ${opts.extra.approverLabel}`); @@ -239,8 +239,10 @@ export async function sendApprovalCard( opKind: string, meta: ChallengeMeta, approveUrl: string, + salts = 0, ): Promise { - const { text, color, blocks } = buildMessage('pending', opKind, meta, { approveUrl, mention: cfg.mention }); + const { text, color, blocks } = + buildMessage('pending', opKind, meta, { approveUrl, mention: cfg.mention, salts }); const res = await apiCall('chat.postMessage', { channel: cfg.channel, text, // fallback (notifications / a11y) @@ -263,8 +265,11 @@ export async function editCard( opKind: string, meta: ChallengeMeta, extra: EditExtra = {}, + salts = 0, ): Promise { - const { text, color, blocks } = buildMessage(state, opKind, meta, { extra }); + // Terminal edits keep the batch-size segment: the context block must stay + // identical to the pending card apart from the state line (no drift). + const { text, color, blocks } = buildMessage(state, opKind, meta, { extra, salts }); const res = await apiCall('chat.update', { channel: ref.channel, ts: ref.ts, diff --git a/cf-worker/src/types.ts b/cf-worker/src/types.ts index d5e038b..28f1055 100644 --- a/cf-worker/src/types.ts +++ b/cf-worker/src/types.ts @@ -66,6 +66,23 @@ export interface AuditRow { * or 'agent' (SSH-agent Touch ID decision pushed by the Mac). The column is * NOT NULL DEFAULT 'ceremony', so a read always has a value. */ source: string; + // Agent-authoritative context, source='agent' rows only + // (docs/approval-transparency.md §B). NULL = pre-field agent or non-agent + // row; '' / 0 = a new agent said "not applicable" (fresh scope, non-sign op). + /** Kernel-verified peer executable basename. */ + peer_exe: string | null; + /** Sign operations: `SHA256:…` of the signing key. */ + key_fp: string | null; + /** Verified non-forwarding session-bind destination label. */ + dest: string | null; + /** connection | destination | workspace | cwd-fallback | parent-app. */ + scope_family: string | null; + /** The exact label the Touch ID reuse line displayed. */ + scope_label: string | null; + /** Effective TTL of the reusable scope (seconds); 0 = fresh. */ + grant_ttl_s: number | null; + /** Peer is the vt relay: 0 | 1. */ + relayed: number | null; /** Monotonic change counter, bumped on EVERY write to the row (create + * each in-place lifecycle update). Unlike `id` (assigned once at INSERT), a * later approve/reject/expire/verify-fail UPDATE advances `seq`, so the @@ -311,7 +328,7 @@ export interface CacheEntry { * Worker always overwrites it from CF-Connecting-IP. `meta.op_kind` duplicates * the sibling `op_kind`. */ export interface AgentAuditEntry { - /** encrypt | decrypt | auth | run | sign */ + /** encrypt | decrypt | auth | run | ssh-sign | sign */ op_kind: string; /** approved | rejected | unavailable | cache_hit | spawn_failed */ outcome: string; @@ -324,6 +341,24 @@ export interface AgentAuditEntry { /** `a__<8 random bytes b64u>` — UNIQUE retry-dedup key. */ token_id: string; meta?: Partial; + // Agent-authoritative context (docs/approval-transparency.md §B). Unlike + // `meta` these are kernel/agent-derived, never client-claimed. All ABSENT + // from an old agent — the ingest preserves absence as SQL NULL, while a + // new agent sends ''/0/false for "not applicable". + /** Kernel-verified peer executable basename; '' unknown. */ + peer_exe?: string; + /** Sign operations: `SHA256:…` of the signing key; '' otherwise. */ + key_fp?: string; + /** Verified non-forwarding session-bind destination label; '' otherwise. */ + dest?: string; + /** connection | destination | workspace | cwd-fallback | parent-app; '' = fresh. */ + scope_family?: string; + /** The exact label the Touch ID reuse line displayed; '' = fresh. */ + scope_label?: string; + /** Effective TTL of the reusable scope (seconds); 0 = fresh. */ + grant_ttl_s?: number; + /** Peer is the `vt ssh connect --forward-real-agent` relay. */ + relayed?: boolean; } /** Inbound to POST /api/audit-ingest. Signed with `VT-HMAC` over the raw body @@ -346,6 +381,18 @@ export interface DoAuditIngestOp { latency_ms: number; ts_ms: number; meta: ChallengeMeta; + // Agent-authoritative context. `null` = the (old) agent never sent the + // field; ''/0/false = a new agent said "not applicable". The DO stores the + // null as SQL NULL so the two stay distinguishable (see the ingest caps in + // index.ts, which must NOT coerce absent to ''/0). + peer_exe: string | null; + key_fp: string | null; + dest: string | null; + scope_family: string | null; + scope_label: string | null; + grant_ttl_s: number | null; + /** SQLite has no bool: 0 | 1 | null. */ + relayed: number | null; } // ── Internal DO op bodies ────────────────────────────────────────────────── diff --git a/cf-worker/test/notify.test.ts b/cf-worker/test/notify.test.ts new file mode 100644 index 0000000..439ff3f --- /dev/null +++ b/cf-worker/test/notify.test.ts @@ -0,0 +1,66 @@ +// Unit tests for the shared notification builders — the context block every +// channel (Pushover / Slack webhook / Slack App / Feishu) renders. These are +// pure string builders, so they run under plain vitest with no workerd. + +import { describe, it, expect } from 'vitest'; +import { metaLines, buildApprovalMessage, buildCacheHitLines } from '../src/notify'; + +const meta = { + op_kind: 'decrypt', + command: 'op: inject\nfile: .env', + host: 'devbox', + user: 'qiqi', + pwd: '/repo', + ppid_cmd: 'zsh -c deploy.sh', + ssh_client: '10.0.0.2 51000 22', + ip: '203.0.113.9', + reason: 'release', +}; + +describe('metaLines', () => { + it('renders who · N 条 head, then pwd/cmd/via/ssh/ip/reason in order', () => { + const lines = metaLines(meta, 3); + expect(lines[0]).toBe('qiqi@devbox · 3 条'); + expect(lines.slice(1)).toEqual([ + 'pwd: /repo', + 'op: inject\nfile: .env', // self-labelled multi-line command, no cmd: prefix + 'via: zsh -c deploy.sh', + 'ssh: 10.0.0.2 51000 22', + 'ip: 203.0.113.9', + 'reason: release', + ]); + }); + + it('drops the batch segment at salts=0 and keeps a bare count when who is empty', () => { + expect(metaLines(meta)[0]).toBe('qiqi@devbox'); + const anon = { ...meta, user: '', host: '' }; + expect(metaLines(anon, 2)[0]).toBe('2 条'); + // No head line at all when both are absent. + expect(metaLines(anon)[0]).toBe('pwd: /repo'); + }); + + it('prefixes single-line commands and skips empty fields', () => { + const lines = metaLines({ ...meta, command: 'vt read foo', ppid_cmd: '', reason: '' }); + expect(lines).toContain('cmd: vt read foo'); + expect(lines.some((l) => l.startsWith('via:'))).toBe(false); + expect(lines.some((l) => l.startsWith('reason:'))).toBe(false); + }); +}); + +describe('buildApprovalMessage', () => { + it('carries the batch size and ends with the approve URL', () => { + const { title, body } = buildApprovalMessage('decrypt', meta, 'https://w/a/tok', 5); + expect(title).toBe('VT 审批: decrypt'); + expect(body.startsWith('qiqi@devbox · 5 条\n')).toBe(true); + expect(body.endsWith('\nhttps://w/a/tok')).toBe(true); + }); +}); + +describe('buildCacheHitLines', () => { + it('stays compact: who · N 条 · note, pwd, cmd — no via/ssh/ip/reason', () => { + const { title, lines } = buildCacheHitLines(meta, 2); + expect(title).toBe('VT 缓存命中(免审批): decrypt'); + expect(lines[0]).toBe('qiqi@devbox · 2 条 · 缓存命中,无手机审批'); + expect(lines.join('\n')).not.toMatch(/via:|ssh:|ip:|reason:/); + }); +}); diff --git a/config.example.toml b/config.example.toml index 0f04f62..b3e0ddf 100644 --- a/config.example.toml +++ b/config.example.toml @@ -55,3 +55,15 @@ # ~/.config/vt/agent.toml (override with $VT_AGENT_CONFIG) # # See agent.example.toml for the template and docs/hook.md for the design. + +# --- SSH agent defaults ([agent] section, optional) -------------------------- +# Startup defaults for `vt ssh agent`, so a supervisor (the VT.app menu-bar +# shell) can spawn the agent without hardcoding your flags. Explicit CLI flags +# always override these; none of these keys is an environment variable. +# See docs/app-bundle.md §4. +# [agent] +# timeout = 300 # idle seconds before keys are cleared +# ssh_auth_cache_duration = 0 # sign approval reuse seconds; 0 = always prompt +# decrypt_auth_cache_duration = 0 # decrypt approval reuse seconds; 0 = always prompt +# cache_hit_notify = true # notify when a cached grant skips Touch ID +# run_allow = "/opt/homebrew/bin/zed" # run@vt allowlist (comma-separated); unset disables run@vt diff --git a/docs/README.md b/docs/README.md index b99b530..0721657 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,12 @@ and update the relevant document in the same change. | Understand DEK caching | [`dek-cache.md`](dek-cache.md) | `cf-worker/src/do_account.ts`, `src/cf.rs` | | Use SSH identities | [`README.md` — portable identity](../README.md#portable-ssh-identity-for-git-vt) | `src/ssh_sign.rs`, `src/client.rs` | | Understand extension errors | [`structured-errors.md`](structured-errors.md) | `src/core/wire.rs`, `src/client.rs` | +| Understand SSH-agent authorization and caching | [`unified-authorization-engine.md`](unified-authorization-engine.md) | `src/core/authorization.rs`, `src/server_macos/authorization.rs`, `src/server_macos/ssh_agent.rs` | +| Understand grant scopes (destination / workspace / relay) | [`authorization-scopes-v2.md`](authorization-scopes-v2.md) | `src/core/authorization.rs` (`GrantScope`), `src/server_macos/ssh_agent.rs` (`BindState`, `resolve_workspace`) | | Enable agent audit push | [`agent-audit.md`](agent-audit.md) | `src/audit.rs`, `src/server_macos/audit.rs` | +| Understand prompt/notification fields and audit context | [`approval-transparency.md`](approval-transparency.md) | `src/server_macos/ssh_agent.rs` (prompt lines), `cf-worker/src/notify.ts`, `cf-worker/pwa/approve.js` | | Diagnose config/routing/caching (`vt doctor`) | [`diag-design.md`](diag-design.md) | `src/client.rs` (`doctor`), `src/server_macos/ssh_agent.rs` (`handle_diag`) | +| Build/install VT.app, menu bar UI, native notifications, key-wrap rebind | [`app-bundle.md`](app-bundle.md) | `app/VTShell.swift`, `src/server_macos/security.rs` (`notify_macos`), `src/core/crypto.rs` (`derive_passphrase_secret`) | | Configure Slack App notifications | [`slack-app.md`](slack-app.md) | `cf-worker/src/slack_app.ts` | | Configure Feishu/Lark notifications | [`feishu.md`](feishu.md) | `cf-worker/src/feishu.ts` | diff --git a/docs/agent-audit.md b/docs/agent-audit.md index d8d6772..3d0f775 100644 --- a/docs/agent-audit.md +++ b/docs/agent-audit.md @@ -36,7 +36,7 @@ unavailable, cache_hit, spawn_failed}`: | `decrypt` | `handle_decrypt` | emits `cache_hit` on a decrypt-cache hit; `salts` = batch size | | `auth` | `handle_auth` (auth@vt) | always prompts | | `run` | `handle_run` (run@vt) | `approved` at the human tap, **plus** a second `spawn_failed` row if the launch fails (two events, two rows) | -| `sign` | `Session::sign` (standard SSH auth) | distinct from `auth@vt`; carries no vt ClientMeta, so the prompt label is the audit `command` | +| `sign` | `Session::sign` (standard SSH auth) | distinct from `auth@vt`; carries no vt ClientMeta, so the prompt label is the audit `command` — the structured `key_fp` / `dest` / `peer_exe` fields carry the key, verified destination, and caller | | `ssh-sign`| `handle_sign_vt` (`sign@vt`) | context-carrying git signing; carries `ClientMeta` and shares the sign auth cache | `latency_ms` measures prompt-shown → decision; cache hits are `0`. `ppid` is the @@ -105,12 +105,21 @@ never crowds out the random suffix (which would collapse dedup). POST {audit_url}/api/audit-ingest Authorization: VT-HMAC b64u(HMAC-SHA256(agent_audit_key, rawBody)) body = { timestamp_ms, agent_id, hostname, entry } - entry = { op_kind, outcome, salts, latency_ms, ts_ms, token_id, meta } + entry = { op_kind, outcome, salts, latency_ms, ts_ms, token_id, meta, + peer_exe, key_fp, dest, scope_family, scope_label, grant_ttl_s, + relayed } meta = ChallengeMeta wire shape (op_kind, command, host, user, pwd, tty, ppid_cmd, ppid, ssh_client, reason) — NO `ip` (the Worker forces it from CF-Connecting-IP). ``` +The seven trailing fields are agent-authoritative context +(docs/approval-transparency.md §B): kernel-verified peer executable, sign key +fingerprint, verified session-bind destination, and the reuse-scope +family/label/TTL the Touch ID prompt displayed, plus the relay flag. The agent +always sends them (`''`/`0`/`false` = not applicable); the Worker stores an +*absent* field (old agent) as SQL NULL — the two stay distinguishable. + Worker `/api/audit-ingest`: 1. reject body > 64 KB (Content-Length + arrayBuffer length) → 413 2. parse `agent_id` (unverified — only selects the key) diff --git a/docs/app-bundle.md b/docs/app-bundle.md new file mode 100644 index 0000000..1dcfe25 --- /dev/null +++ b/docs/app-bundle.md @@ -0,0 +1,446 @@ +# VT.app — bundle, menu bar UI, native notifications, key-wrap rebind + +Design R2 (post codex-expert review; R1 findings folded in). Owner: this +document. Implements the migration of the +`vt` binary into a signed `.app` bundle with a Swift menu-bar shell, native +`UNUserNotificationCenter` notifications (replacing `osascript`), and the +master-key wrap-derivation migration that the path move requires. + +## Goals + +- VT-branded system notifications (own icon and name in System Settings → + Notifications) instead of Script Editor via `osascript`. +- A persistent menu-bar presence: agent status at a glance, live grants with + scope and remaining TTL, recent activity, and a panic "revoke everything" + action. +- Cache-hit transparency: a grant reuse that skips Touch ID now fires a system + notification (op + scope + remaining TTL), so silent reuse is observable in + real time, complementing [`approval-transparency.md`](approval-transparency.md). +- Survive the binary path change: the master-key wrap currently derives from + `current_exe()`; moving into the bundle must not lock the store. + +## Non-goals (v1) + +- No approval from the UI, ever. Touch ID sheets remain the only approval + surface. The menu never gains a "grant"/"approve"/"extend" affordance. +- No `run@vt` surface in the UI beyond the existing allowlist count. +- No Worker/phone-approval integration. +- No per-grant revocation and no cache-TTL editing from the menu (v1.5). +- No Sparkle/auto-update (distribution remains `just install-app`). + +## 1. Bundle layout and packaging + +``` +VT.app/Contents/ + Info.plist CFBundleIdentifier dev.rustyvault.vt, LSUIElement=true + MacOS/VTApp Swift menu-bar shell (also handles `VTApp notify ...`) + MacOS/vt the Rust binary, unmodified role; CLI symlink target + Resources/AppIcon.icns generated from cf-worker/pwa/icon.svg / icon-512.png +``` + +The shell executable is `VTApp`, not `VT`: the default APFS volume is +case-insensitive, so `MacOS/VT` and `MacOS/vt` would be one file. Menu bar +and notifications show `CFBundleDisplayName` (`VT`) regardless. + +- Executables under `Contents/MacOS/` share the bundle identity, so the Rust + agent process itself counts as "VT" for notification and TCC purposes. +- Packaging is a `just app` recipe: `cargo build --release`, `swiftc` the + shell, generate `AppIcon.icns` (`sips` scale-down set from `icon-512.png`; + 1024 upscaled once for `512@2x` — acceptable softness), assemble, then + `codesign --force --deep -s "${VT_CODESIGN_ID:--}"` (ad-hoc by default, + Developer ID when the env var is set). +- `just install-app` copies to `/Applications/VT.app` and symlinks + `~/.local/bin/vt → /Applications/VT.app/Contents/MacOS/vt` (same target dir + the existing `just install` uses). +- **Release** (`.github/workflows/release.yml`): the macOS job builds VT.app + via `just app` (reusing the release binary through `VT_APP_BIN`, so no + second compile) and ships it as `VT-app-darwin-arm64-.tar.gz` + alongside the bare `vt` tarball. It is **ad-hoc signed** (no Developer ID + set up), so: each release re-triggers the one-time Keychain ACL prompt on + first launch (ad-hoc signatures differ per build), and Gatekeeper would + quarantine it — but `tar xzf` from the CLI does not propagate the download + quarantine the way Finder/zip does, so a terminal install usually opens + cleanly; `xattr -dr com.apple.quarantine` is the fallback. Moving to + Developer ID + notarization later removes both frictions and is a drop-in + `VT_CODESIGN_ID` + a notarize step. +- Menu-bar icon: the hex-nut key shape from the SVG redrawn as an `NSImage` + template (code-drawn paths in Swift; monochrome, so it adapts to + light/dark and lets us render state variants). + +## 2. Master-key wrap v2 and `vt secret rebind` + +Verified breakage: `derive_passphrase_secret` (`src/core/crypto.rs`) mixes +`current_exe()` into the wrap key; moving the binary makes the AES-GCM unwrap +fail. The path term is a soft binding with ~zero security value (the passcode +sits in the same keychain item; path and `$USER` are attacker-knowable), and +it also breaks Homebrew prefix moves. + +- **Wrap v2**: derivation string becomes + `base64(passcode):$USER:vt-wrap-v2` — the binary-path term is replaced by a + fixed domain label. `$USER` stays (no added brittleness from the bundle + move; dropping it would widen review scope for no gain). +- **Store marker**: `KeychainStore` gains `#[serde(default = "...")] wrap_v: + u32` (serde default fn returning 1). `STORE_SCHEMA_VERSION` stays 1 so + pre-migration binaries can still parse the store (they will fail the + unwrap, not the parse — see rollback). +- **Transparent upgrade**: wherever the store is loaded and the unwrap + *succeeds* with a v1 key (agent startup preflight, CLI ops), rewrap to v2. + Review-mandated shape: a **new, minimal mutator** run as a closure inside + `KeychainStore::modify` (the real `vt-keychain.lock` flock path) that + re-reads the store under the lock, re-verifies `wrap_v == 1`, decrypts and + re-encrypts **only** `encrypted_passphrase`, and sets `wrap_v = 2`. It must + never touch `passcode_and_auth_token` (else VT_AUTH silently rotates), + `encrypted_ssh_keys`, or `encrypted_fido2`, and must not reuse + `create_and_save_passcode_passphrase` (which mints fresh secrets by design + and saves without the flock). After the first successful run of the new + binary at the *old path*, the store is path-independent before the move. +- **Old/new binary coexistence hazard**: an old binary still on `$PATH` that + runs `init`/`import`/`rotate-passcode` performs a full store rewrite with + the *old* derivation and drops the `wrap_v` field it does not know — + silently downgrading a v2 store to "bound to the old binary's path". + Operator doc must say: remove old `vt` binaries once migrated. The new + binary's own `rotate-passcode`/`import` always write wrap v2. +- **`vt secret rebind [--old-bin-path ]`**: for stores that jumped + straight to the new location. Gated by `local_authentication` (same as + `vt secret export`). Tries, in order: v2 label, `current_exe()`, the + explicit `--old-bin-path` string (the old binary need not exist — only the + string enters the derivation). On first success, rewraps to v2 and saves. + Passcode, auth_token (VT_AUTH), encrypted SSH keys, and FIDO2 blobs are + preserved byte-for-byte; only `encrypted_passphrase` and `wrap_v` change. +- **Rollback**: a v2 store is unreadable by old binaries. Escape hatch: + `vt secret rebind --to-v1` (rewraps back using `current_exe()`), or the + existing `vt secret export` / `import` pair. +- Naming: the top-level `vt rewrap` (re-encrypts `vt://` URLs in files) is + unrelated; `rebind` avoids the collision. + +## 3. Native notifications + +- `notify_macos` (`src/server_macos/security.rs`) gains a first path: if + `current_exe()` lives under `*.app/Contents/MacOS/`, spawn + `/Contents/MacOS/VTApp notify --title --body ` (argv, no + AppleScript interpolation; control-character filtering and length caps + stay). The Swift binary handles `notify` before `NSApplicationMain`: post + via `UNUserNotificationCenter`, wait for the add-request callback, exit. + First use triggers the one-time system authorization prompt. +- Fallback is the existing `osascript` path (binary outside a bundle, helper + missing, or spawn failure). Notification failure must never affect the + protected operation (`let _ =` semantics preserved). +- **Cache-hit notifications (new requirement)**: in the two places the server + observes `Decision::CacheHit` (raw/`sign@vt` sign, `handle_decrypt`), fire + `notify_cache_hit(op, scope_display, remaining_ttl)` → e.g. + “sign · github.com · 4m12s left (no Touch ID — cached grant)”. + - **Call-site constraint (review-mandated)**: the notification fires only + **after `permit.commit()` returns** — a `CacheHit` permit holds the + security read gate, and `run_revoker` needs the write side; a blocking + notify (worst case: the first-use notification-permission dialog) while + the permit is live would stall revocation, violating the live-permit + invariant. The call is fire-and-forget (spawn, never await the helper), + so it also adds no latency to the sign/decrypt response. + - The scope display string comes from the **request-side** `GrantScope` + (the server builds it for the lookup anyway), not from the store — so P1 + needs no store changes. + - New `NotifyKind::CacheHit`, reusing the 30 s per-kind throttle + (`notify_throttle_should_fire`) so a burst (multi-sign `git push`) + notifies once; coalesced counts are a later refinement. + - Default on; opt-out via `--no-cache-hit-notify` on `vt ssh agent` and the + matching `[agent]` config key (§4). + - Residual visibility note: banners persist in Notification Center after + the event (and on the lock screen if the user's preview settings allow), + longer than the Touch ID sheet showed the same strings. Users who treat + hostnames/workspace paths as sensitive can opt out. + - Both the helper path and the `osascript` fallback route through the + **same** sanitize call (control-char filter + length caps) before the + text leaves the agent. + +## 4. Agent defaults from config (`[agent]` section) + +The shell supervises the agent (§6) and must not hardcode the operator's +flags. `~/.config/vt/config.toml` gains an optional section read by +`vt ssh agent` at startup as *defaults*; explicit CLI flags override: + +```toml +[agent] +timeout = 1800 # --timeout +ssh_auth_cache_duration = 900 # --ssh-auth-cache-duration +decrypt_auth_cache_duration = 0 # --decrypt-auth-cache-duration +cache_hit_notify = true # --no-cache-hit-notify inverts +run_allow = "/opt/homebrew/bin/zed" # --run-allow (agent policy, not a secret) +``` + +`run_allow` lives here (not just the flag) so a supervisor that spawns the +agent without flags — the VT.app shell — keeps `run@vt` working. It is agent +policy, contains no secret, and follows the same `flag > config > default` +precedence. + +Env-over-file precedence is unchanged (no new env vars; `hydrate_env_from_file` +already skips structured TOML sections). Duration `0` still maps to +first-class `Fresh`. + +**Menu-driven cache policy.** The menu-bar shell does NOT rewrite this +(mode-600, secret-bearing) file. Because the shell is the agent's sole +lifecycle owner (§6), a cache choice from the menu is persisted in the +shell's own `UserDefaults` and applied as an explicit spawn flag on the next +(re)start — precedence stays `flag > [agent] config > default`, so a user who +prefers the file can set `[agent]` and never touch the menu. The menu +checkmark reflects the agent's *live* TTL from `ui-status@vt`, whatever its +source. Applying a change restarts the managed agent (TTL is read at +startup; there is no hot reload), which drops current grants — acceptable for +a policy change and consistent with every other revoke path. + +Implementation note (review finding): the `vt ssh agent` clap args currently +use eager `default_value_t`, which cannot distinguish "flag passed" from +"compiled default". They move to `Option` (and a tri-state for the new +notify boolean) so the merge is `flag.or(config).unwrap_or(default)`. + +## 5. `ui-status@vt` — token-gated read/revoke channel for the shell + +`diag@vt` cannot serve the UI: it rides the VT_AUTH cipher path (the shell +holds no VT_AUTH) and is deliberately caller-scoped (`live_entries` is never +a whole-store count — that stays true). Whole-store visibility needs a +deliberately gated channel: + +- **Spawn token**: the shell generates 32 random bytes, writes them into a + pipe, and spawns `vt ssh agent --ui-token-fd ` with the read end + inherited. The agent reads the token at startup and drops the fd. No env + var (visible to same-user `ps e`), no file. A CLI-started agent has no + token → the extension always fails → the UI degrades to "agent running, + restart from VT.app for details". +- **Dispatch position**: handled with the plaintext pre-cipher extensions — + after `session-bind@openssh.com`, *before* the lock check (a locked agent + must still report `locked: true`) and before keychain/auth-cipher work. + Constant-time token compare; on mismatch, plain `AgentError::Failure` + (indistinguishable from unknown-extension). +- **Never resets the idle clock** — added to the same `touch_activity` + exclusion as `EXT_DIAG`. Not audit-pushed, never cached, no Touch ID. +- **Request** (JSON, like diag): `{ token_b64, action: "status" | "revoke_all" }`. + - `status` → response: + `{ agent_version, locked, sign_ttl_secs, decrypt_ttl_secs, run_allow_len, + audit_push, grants: [ { operation, family, display, expires_in_secs, + ttl_secs } ] }`. + - `revoke_all` → `engine.invalidate_all()` (the existing linearized + revoker; epoch advances even when the store is empty). Authority-reducing + only, hence token-but-not-Touch-ID is acceptable. If a live permit holds + the prompt slot, revocation queues exactly as it does today; the UI shows + "waiting for the in-flight approval". +- **Grant display labels**: `GrantKey` digests are one-way, so the store + cannot reconstruct "github.com" or a workspace path. `GrantScope` gains a + `display: String` set by each constructor (destination host + key type, + workspace root, cwd, parent app name — the same phrasing the Touch ID + prompt already shows), stored alongside `CacheExpiry` in a new + `GrantEntry { expiry, display }` value. A new `GrantStore::snapshot()` / + `AuthorizationEngine::snapshot()` enumerates live entries for this handler + only. Memory-only; labels never hit disk or audit push. +- Information-disclosure stance: the full grant list (hosts, workspace paths) + is exposed *only* to a holder of the spawn token, i.e. the shell that + parented the agent. Same-user processes without the token get nothing, + preserving today's diag@vt posture. Two consequences made explicit + (review): (a) `ui-status@vt` is a deliberate, token-gated exception to the + "never a whole-store count" diag@vt invariant — CLAUDE.md gets a companion + line naming it as the one legitimate whole-store channel; (b) scope + display strings, today shown only transiently in Touch ID prompts, are now + retained in agent memory for the grant's TTL and readable by the token + holder throughout that window. Memory-only, never disk/audit. +- Token compare reuses the `subtle::ConstantTimeEq` idiom already used by + the agent's `unlock()` path (`None => false` when no token configured). + +## 6. Swift menu-bar shell (`VTApp`) + +Single-file AppKit app (`app/VTShell.swift`), no third-party deps. + +- **Role — sole agent lifecycle owner.** The shell is the intended way the + agent runs (replacing a hand-rolled launchd/supervisor): it registers as a + login item (`SMAppService.mainApp`), spawns `vt ssh agent --ui-token-fd 0` + (token over the stdin pipe), supervises it, and restarts it. Also serves + `VTApp notify` helper mode (§3). + - **Exit intent** drives the termination handler: `stop` (leave down), + `restart` (respawn after the OS releases the socket — the new agent also + self-heals a stale socket file on bind), or `crash` (backoff restart, up + to 5 attempts). + - **Never fights an external agent**: `startIfNeeded` defers if something is + already listening on the socket; it only supervises an agent it spawned. + An externally-started agent yields a degraded, read-only menu (no token). + - **Version-skew restart**: after `just install-app` replaces the binary, + the still-running agent runs old code. The shell compares the running + `agent_version` to the bundled `vt version`; when they differ *and the + agent is shell-managed*, the menu offers a one-click "Restart Agent to + update". A restart is the trigger for the transparent wrap-v2 upgrade + (§2) to run. + - **Start-failure surfacing**: the agent's stderr is captured; a fast + (< 3 s) or non-zero exit keeps the last line and shows it in the menu + with a Doctor shortcut. This is the visible path for the common + "keychain wrap still bound to the old binary path — run `vt secret + rebind`" failure (§2), instead of a silent "not running". +- **Data flow**: speaks the ssh-agent wire protocol over `~/.ssh/vt.sock` + (u32-BE length framing, `SSH_AGENTC_EXTENSION` 27 → `ui-status@vt`, reply + `SSH_AGENT_EXTENSION_RESPONSE` 29). Poll every 3 s for icon state; refresh + on `menuWillOpen`. Polling is safe by construction (no idle-clock reset). + The fixed `~/.ssh/vt.sock` path is what lets a login-item-started agent be + found by the vt client's built-in fallback with no `SSH_AUTH_SOCK` export. +- **Icon**: the hex-nut key from `icon.svg`, code-drawn as a monochrome + template `NSImage`. Its content bounding box (512-space x 152..360, + y 90..412) is aspect-fit into a padded ~16 pt cell so it carries the same + internal padding as SF Symbol menu-bar glyphs rather than filling the bar + height. The badge text (grant count / lock glyph) sits to its right. +- **Menu (v1)**: + - status line: `Agent running · · N grants` (disabled item; + `` already carries its own leading `v`) + - version-skew: one-click restart (managed) or warning line (external) + - agent start-failure hint + reason (when the managed agent won't start) + - `Grants ▸` one item per grant: `sign · github.com · 4m12s` + (display-only) + - `Revoke All Grants` (⌘L accelerator while menu open) → `revoke_all` + - `Cache Duration ▸ Signing / Decrypt ▸ Off / 5 min / 15 min / 1 h / 2 h / + 8 h / Follow config file` (managed agent only; applying restarts the + agent) + - `Idle timeout — (clears cache when unused)` line + submenu + (`15 min / 30 min / 1 h / 2 h / 8 h / Follow config file`) — surfaced so + cache expiry is discoverable/debuggable (§10) + - `Start Agent` / `Stop Agent` / `Restart Agent` (managed agent only) + - `Start at Login` toggle (`SMAppService.mainApp`) + - `Run Doctor…` (opens Terminal with `vt doctor`) + - `Quit (agent keeps running)` / `Stop Agent and Quit` +- "Recent activity" (audit-log tail) is **v1.5** — cache-hit notifications + cover the transparency need in v1 without the shell parsing audit files. + +## 7. Migration from an existing (non-bundle) install + +A store created by a pre-bundle `vt` is wrap v1, bound to *that* binary's +resolved path. `install-app` makes `~/.local/bin/vt` a symlink into the +bundle, so `current_exe()` now resolves to the bundle path — the transparent +upgrade (§2) cannot unwrap, and a shell-started agent would fail to boot. +Rebind once, in this order: + +``` +1. Stop the current agent (whatever supervises it today). +2. just install-app +3. vt secret rebind --old-bin-path + # e.g. /Users/you/.local/bin/vt (the real file, symlinks resolved) +4. Launch VT.app — the shell starts the agent; wrap is now v2 (path-independent). +``` + +If step 3 is skipped, the shell surfaces the failure and the `vt secret +rebind` hint in its menu (§6) rather than failing silently. Once on wrap v2, +future bundle moves need no rebind. + +### Keychain ACL note (operational, not code) + +Moving the binary re-triggers the legacy keychain ACL prompt once +(`rusty.vault.store` is a single generic-password item). The first +`vt secret rebind` run from the bundle doubles as that prompt. With a stable +`VT_CODESIGN_ID`, it is the last such prompt; with ad-hoc signing the +per-rebuild prompt behavior is unchanged from today. + +## 8. Invariants preserved + +- `session-bind@openssh.com` stays first in dispatch; `ui-status@vt` slots + after it and never touches the VT_AUTH cipher path. +- `diag@vt` semantics untouched (caller-scoped counts, no idle reset). +- `auth@vt`/`run@vt` fresh-only; `run@vt` unreachable from the UI. +- Cache duration `0` → `Fresh`, never `StrictTtl(0)`. +- Revocation/epoch semantics unchanged; `revoke_all` reuses the existing + linearized revoker. +- No secret or private-key material crosses `ui-status@vt`; grant labels are + the same strings already shown in Touch ID prompts. +- Notification failure never blocks or fails a protected operation. + +## 9. Phasing and tests + +- **P1 (Rust)**: wrap v2 + `vt secret rebind` (+ `--to-v1`), `[agent]` config + defaults, cache-hit notify (request-side scope display, post-commit + fire-and-forget), bundle-aware `notify_macos`. + Tests: derivation vectors old/new, rebind round-trip (v1→v2→v1), the + transparent-upgrade mutator preserves passcode/auth_token/ssh/fido2 + byte-for-byte, config precedence (flag > file > default), throttle + behavior for `CacheHit`. +- **P2 (agent)**: spawn token fd, `ui-status@vt` handler, `GrantEntry` + display labels, `snapshot()`, revoke action. + Tests: token gate (no token/wrong token/right token), locked-agent status, + idle-clock non-reset, snapshot label/expiry correctness, revoke-all epoch + advance. +- **P3 (app)**: Swift shell, icns, `just app` / `install-app`, docs. + Manual verification: notification identity, menu states, agent + supervision, ACL prompt flow. +- Gates: `cargo test`, `just check`, `just check-worker` (untouched but run). + +## 10. Key-wipe on lock + menu-configurable/visible idle timeout (R3) + +**Motivation.** The idle timeout couples two concerns onto one timer: wiping +decrypted SSH keys from RAM (memory hygiene, wants *short*) and invalidating +cached grants (walked-away backstop, wants *long* for cache convenience). +Raising the default to 2 h (§4) for cache convenience therefore also +lengthened key memory-residency to 2 h — and the screen-lock watcher today +only `invalidate_all()`s grants, it does **not** clear the key map. So a +locked screen currently leaves decrypted private keys resident until the +2 h idle sweep. A same-machine memory-scraping attacker (root/debugger +`task_for_pid`) is not stopped by screen lock. Fix: **lock/sleep also wipes +keys** (C), and keep idle as a long backstop that is now **surfaced and +configurable in the menu** so cache expiry is debuggable rather than +"mysterious". + +### C — lock/sleep clears keys, not just grants + +- The cache watcher, on a `watcher_should_clear` trigger (lock transition or + detected sleep), in addition to `invalidate_all()` (grants), clears the + key map and sets `idle_cleared = true` so the next use silently reloads + (no Touch ID). The watcher gains `Arc` clones of `keys` and `idle_cleared` + (mirroring the idle sweeper). +- **Race that must be closed (review Finding 1, blocker):** a sign request + arriving *while the screen is locked* would today hit `ensure_keys_loaded` + (which sees `idle_cleared` and reloads) — repopulating RAM with the very + keys we just wiped. The interactive guard must be checked in **two** places + (mirroring the existing post-I/O `self.locked` re-check): (a) before + `load_all_keys()` to skip the pointless read, and **(b) again immediately + before `*keys = loaded`** — because the screen can lock *during* the + blocking (possibly ACL-prompting) keychain read, and an entry-only check + would then install keys into RAM during the lock. Both `self.locked` and + the screen-interactive check gate the install; either failing → no-op + reload, `idle_cleared` left set so a later interactive call reloads. A sign + during lock is independently rejected at `authorize` (validator + `NotInteractive`); the guard's sole job is keeping decrypted keys out of + RAM (memory-scraping hygiene), since *use* is already blocked. Scope: covers + the automatic reload path (`sign`, `sign@vt`, and `request_identities`, all + sharing `ensure_keys_loaded`); does **not** cover the explicit `ssh-add -X` + `unlock()` path, which reloads on a caller-supplied passphrase by design and + is outside the memory-scraping threat model. +- Agent lock (`ssh-add -x`) already wipes keys via the lock finalizer; + unchanged. Menu "Revoke All" stays grants-only (it is not a lock). +- Reload cost after unlock is one silent keychain read on the first + post-unlock use; no Touch ID. + +### Menu-configurable + visible idle timeout + +- `UiStatusRes` gains `idle_timeout_secs` so the shell can display and + reconcile it. +- The menu shows the active idle timeout (e.g. a disabled "Idle timeout: 2h" + line) — this is the "make timeout perceivable" requirement: a user seeing + the value understands *why* grants clear after a quiet spell, so cache + disappearance is debuggable instead of mysterious. The grant list already + shows each grant's remaining TTL; the idle line explains the orthogonal + expiry axis. +- An "Idle Timeout ▸ 15m / 30m / 1h / 2h / 8h / Follow config file" submenu + (managed agent only) persists the choice to `UserDefaults` + (`vt.idleTimeoutSecs`), passed as `--timeout` on the next spawn; applying + restarts the managed agent (same mechanism/among-caveats as the cache + submenu, §4/§6). Precedence stays `flag > [agent] config > default`. +- **Floor clamp (review Finding 5):** `timeout` is clamped to ≥ 60 s where it + becomes a `Duration` in `run_ssh_agent`. Unlike the cache durations, idle + `0` is **not** a "Fresh"-style special case — `0` would make the sweeper + `sleep(0)` busy-loop (`check_interval = min(60, timeout)`). The menu presets + never offer 0, but the clamp protects the config/CLI/UserDefaults paths. +- Idle is **not removed** — it remains a long backstop for the + unlocked-but-unattended window that screen lock does not cover (e.g. + display sleep without password-on-wake, or auto-lock disabled). Removing + the only automatic non-lock guard would make screen lock a single point of + failure; keeping it long costs nothing (silent reload, rarely fires before + lock does). + +### Invariants preserved + +- Lock, sleep/wake, and idle still advance the authorization epoch even with + an empty store; key reload is always silent (no Touch ID). +- `ui-status@vt` stays token-gated read + authority-reducing revoke; setting + the idle timeout happens via UserDefaults + agent restart (the shell's + lifecycle ownership), not a new mutating agent channel — no approval, grant, + or extend action is ever added. +- No secret or key material crosses any new surface; `idle_timeout_secs` is + policy, not a secret. diff --git a/docs/approval-transparency.md b/docs/approval-transparency.md new file mode 100644 index 0000000..8ff1545 --- /dev/null +++ b/docs/approval-transparency.md @@ -0,0 +1,242 @@ +# Approval transparency — prompt truth lines, audit fields, worker display + +Status: implemented (codex-expert review round folded in; verify current +behavior against the anchors named per section). Follow-up to the +activity-scopes V2 review: +a field-level comparison of the Touch ID prompt, the Worker approval +surfaces, and the agent audit push found gaps in what each shows/records. +This document is the decision record for closing them. + +## 1. Problems + +1. **Raw `SIGN_REQUEST` hides the verified destination by default.** The + agent verifies the destination host key via `session-bind@openssh.com`, + but the prompt (`Session::sign`, `src/server_macos/ssh_agent.rs`) only + surfaces it inside the `reuse:` line — which exists only when + `--ssh-auth-cache-duration > 0` and the scope is destination-reusable. + With the default (`0`), the user approves `sign: (ssh)` with no + destination shown, even though the agent knows it. Forwarding-capable + and tainted connections show no marker at all, yet these are exactly + the connections where a signature may serve a request originating + beyond hop one. +2. **Kernel truth vs client claims are indistinguishable in vt-extension + prompts.** decrypt@vt / sign@vt / auth@vt / run@vt render the + client-reported `via:` (`meta.ppid_cmd`) but never the kernel-verified + peer executable (`self.peer_exe`), which only the raw-sign prompt shows. +3. **The agent audit push is under-specified.** `AgentAuditEntry` + (`src/server_macos/audit.rs`) records outcome + `ChallengeMeta` only: + - raw sign rows carry `ClientMeta::default()` — the key, the peer + process, and the verified destination survive only as unstructured + prompt text stuffed into `command`; + - no row records whether the approval minted a reusable grant, its + scope family/label, or the TTL; `cache_hit` rows do not say which + scope was hit; + - the kernel-verified peer executable is not reported anywhere + (`meta.ppid` does carry the kernel peer pid); + - relay provenance (`peer_is_vt_relay`) is not reported. +4. **Worker approval surfaces have small field gaps.** + - The approve page renders ten meta fields with equal visual weight, + though only `ip` is worker-verified (`CF-Connecting-IP`); the rest + are client-claimed and unbound (`src/cf.rs:109-114`, accepted). + - The DEK-cache consent copy claims "同一来源 IP 且同一工作目录" as if + both halves were verified; `pwd` is client-reported advisory + (`types.ts` CacheEntry v3 notes). + - An approval request never shows the decrypt batch size, although the + cache-hit FYI does (`N 条`) and the Touch ID prompt does + (`decrypt N secrets`). A 50-record batch is exactly the anomaly an + approver should see. + - Notifications (`metaLines`, `cf-worker/src/notify.ts`) omit + `ppid_cmd`, though users often decide from the notification alone. + +## 2. Changes + +### A. Agent prompt truth lines (`src/server_macos/ssh_agent.rs`) + +Rendering order invariant (already implicit, now stated): **agent-derived +truth lines precede every client-reported line**, so a hostile caller can +pad only its own region off-screen. + +- **A1 — raw sign destination.** After the `sign:` header: + - `BindState::Bound { forwarding: false }` and the scope is *not* + destination-reusable (Fresh): add `dest: ` — the + same precomputed label the reuse line would have used. (When the + reuse line exists it already names the destination; no duplicate + line.) + - `BindState::Bound { forwarding: true }`: add + `dest: (forwarding — may serve a relayed request)`. + - `BindState::Tainted`: add `warning: session-bind verification failed`. + - `Unbound`: unchanged (an unbound ssh peer has nothing verified to + show; modern OpenSSH always binds). +- **A2 — caller line.** New helper `append_caller_line`: appends + `caller: ` when `self.peer_exe` is known, placed + with the truth lines (right after the relay line). Applied to + decrypt@vt, sign@vt, auth@vt, run@vt. Raw sign keeps its inline + `({proc})` form. Although `peer_exe` is kernel-derived, it is a + filesystem basename an attacker with local code execution controls, so + the line goes through `sanitize_for_display` like every other prompt + field (the raw-sign header gets the same treatment while we are + there). +- **A3 — run@vt relay marker.** Add the missing `append_relay_origin` + call for consistency. Dead path today (the relay filter refuses + run@vt) — cheap insurance, keeps the four extension prompts uniform. + +Descoped: showing the resolved workspace on Fresh prompts (would blur the +"reuse line appears iff a grant can be created" invariant); tty in the +prompt; localizing prompt language; enriching the static CLI-local +prompts (`export master secret` …) — separate, lower-value change. + +### B. Structured audit fields (agent → worker ingest) + +New **top-level** fields on `AgentAuditEntry` / `DoAuditIngestOp` — not +on `ChallengeMeta`, which stays the client-claimed display struct; these +are agent-authoritative: + +| field | type | content | +|---|---|---| +| `peer_exe` | String | kernel-verified peer executable basename ("" unknown) | +| `key_fp` | String | sign ops: `SHA256:…` of the signing key ("" otherwise) | +| `dest` | String | verified non-forwarding session-bind destination label ("" otherwise) | +| `scope_family` | String | `connection` / `destination` / `workspace` / `cwd-fallback` / `parent-app`; "" = fresh | +| `scope_label` | String | the exact label the reuse line displayed ("" = fresh) | +| `grant_ttl_s` | u64 | effective TTL for the reusable scope; 0 = fresh | +| `relayed` | bool | `peer_is_vt_relay` | + +- `scope_family`/`scope_label`/`grant_ttl_s` are set on **both** + `approved` (grant minted) and `cache_hit` rows (digest match implies + the hit's recomputed label equals the grant's), answering "which prior + approval class did this silent hit ride" without engine surgery. +- `GrantScope` gains `pub fn family(&self) -> Option` and + `ScopeFamily::as_wire(&self) -> &'static str` in + `src/core/authorization.rs`. Unlike `ContextBasis`, `ScopeFamily` + needs no `from_wire`: this is one-way telemetry, never parsed back. +- `emit_audit`'s argument list is refactored into a small + `AuditContext<'_>` struct (it is at 8 arguments already). +- Raw sign keeps `meta = ClientMeta::default()` (honest: nothing + client-reported exists on that path) — the new structured fields now + carry key/peer/destination. + +**Deliberately deferred — grant→hit token linkage.** Storing the minting +approval's `token_id` inside each grant and echoing it on `cache_hit` +(mirroring the Worker cache's `origin_token_id`) requires threading an +audit token through `AuthorizationRequest`, the grant store, and +`Decision`. `scope_label` + timestamps give the correlation for +forensics; revisit only if that proves insufficient in practice. Known +ambiguity, accepted: distinct approvals can share an identical +`scope_label` over time (`commit_at`'s TTL-tightening rule re-mints the +same scope), so label+timestamp correlation blurs when approvals cluster +closely — a forensic imprecision, not a security gap. + +Worker side (`cf-worker`): + +- `DoAuditIngestOp` (`types.ts`) gains the seven fields (all optional). +- Additive `ALTER TABLE audit ADD COLUMN` migrations: `peer_exe TEXT`, + `key_fp TEXT`, `dest TEXT`, `scope_family TEXT`, `scope_label TEXT`, + `grant_ttl_s INTEGER`, `relayed INTEGER` — same pattern as the existing + `cache_ttl_s`/`ppid` migrations in `do_account.ts` (re-snapshot + `PRAGMA table_info` per the stale-snapshot note there). +- **NULL vs "" convention:** the agent always sends every field, using + `""` / `0` / `false` for "not applicable" (fresh, unknown peer, non-sign + op). Ingest must preserve *absence* as SQL `NULL` — a null-preserving + cap helper for the strings (NOT the existing `capMeta`, which coerces + absent to `''`) and a null-preserving numeric/bool parse for + `grant_ttl_s`/`relayed` (NOT `clampInt`, which coerces to `0`). Thus + `NULL` = "old agent, field never sent" and `''`/`0` = "new agent, + explicitly fresh/unknown" stay distinguishable in SQL. +- `auditAgent` INSERT includes them; defensive caps + control-char strip + at ingest (strings capped at 160). +- `AUDIT_SELECT_COLS` + the admin audit page detail view render them + (labels: 调用进程 `peer_exe` / 密钥 `key_fp` / 目的主机 `dest` / + 复用范围 `scope_label` / 范围类型 `scope_family` / 授权时长 + `grant_ttl_s` / 经中继 `relayed`; the page already skips empty/NULL + values, so pre-migration rows render unchanged). +- Compatibility: old agent → new worker ⇒ columns NULL; new agent → old + worker ⇒ unknown JSON fields ignored. The ingest HMAC covers the body + as-is; no protocol version needed. + +### C. Worker approval surfaces (`cf-worker/pwa`, `notify.ts`) + +- **C1 — trust labeling on the approve page** (`pwa/approve.js`): the + `ip` row is labeled `IP(已验证)`; a footnote under the field list + states `除 IP 外均为客户端自报信息,仅供参考`. New row `记录数: N` + (from `salts_b64u.length`, worker-derived) when N > 0. +- **C2 — cache consent copy**: 「同一来源 IP(已验证)且同一工作目录 + (客户端自报)」 so the stated boundary matches the implemented one + (ctx = verified IP + advisory pwd). +- **C3 — batch size in approval notifications**: `metaLines` gains a + `salts` parameter; when > 0 the head line becomes `user@host · N 条` + (mirrors the cache-hit head). Ripple is wider than one call site: + `buildApprovalMessage` (notify.ts) plus `buildCard`/`buildMessage` and + the public `sendApprovalCard`/`editCard` in feishu.ts and slack_app.ts, + and their do_account.ts callers, all thread `ch.salts_b64u.length`. + Terminal edit cards (approved/rejected/expired) keep the count too — + same shared block, no drift. +- **C4 — notifications show the parent process**: `metaLines` adds + `via: ` between `cmd` and `ssh`. Cache-hit lines stay + compact (unchanged). + +### D. Docs + +- `docs/authorization-scopes-v2.md` §6: add the truth-line ordering and + the new `dest:`/`caller:` lines. +- `CLAUDE.md` invariants: extend the prompt invariant with "agent-derived + truth lines precede client-reported lines". +- This file joins `docs/README.md`. + +## 2a. R2 follow-up — signal-per-line simplifications (operator feedback) + +A first round of live use found the opposite failure mode: fields that are +always the same carry no signal, and long unshortened strings drown the +signal they do carry. Changes, all display-only (grants, digests, and audit +fields are unaffected unless noted): + +- **`caller:` is exception-display.** `caller: vt` (the CLI itself, the + overwhelmingly common case) is suppressed; the line appears only for + other basenames (`ssh -A` traffic, `ssh-keygen`, renamed binaries). + Suppressing on the name hides nothing a rename could not already hide — + the basename is attacker-chosen either way. Audit `peer_exe` stays + unconditional. +- **Workspace reuse labels are unmarked.** `reuse: ~/code/vt · 8h` — bare, + home-contracted path = whole repository; `directory` / `app` keep their + prefixes (§6 of authorization-scopes-v2.md). `contract_home` is display + only. This changes audit `scope_label` too (it mirrors the displayed + line by definition). +- **`op: inject` line dropped at the source** (`src/client.rs`): the + `cmd:`/`file:` lines themselves mean inject, and every surface already + names the operation (prompt header / approval-page 类型). `vt read` + keeps its explicit `op: read` (sole distinguishing line on that path). +- **Raw-sign prompt unified with sign@vt**: header `ssh-sign`, then + `key:` / `caller:` / `dest:` / `reuse:` truth lines — replaces the old + `sign: key (proc)` one-liner, so the two sign paths read as one UI. +- **`cmd:` shortened at the source**: basename(argv[0]) + args, cap 160 + (was full path, cap 1024). Applies to the Touch ID prompt, the approval + page, and notifications alike; the executable path was client-claimed + display data, never a verified field. +- **`ppid_cmd` shortened at collection** (`src/cf.rs parent_cmd`): + basename(argv[0]) + args. Affects the `via:` prompt line, the approval + page 父进程 row, notifications, and audit rows uniformly. + +## 3. Tests + +- Rust (`ssh_agent.rs` unit tests): dest line on Fresh bound; no + duplicate dest when reuse line names it; forwarding/tainted variants; + caller line presence/absence; run@vt relay line; `AuditContext` + population for raw sign (key_fp/dest/peer_exe) and cache_hit scope + fields. `audit.rs`: serde of new fields; ip-absent test unchanged. +- Worker (Vitest): `metaLines` with salts/via; `auditAgent` writes new + columns; ingest tolerates missing fields (old agent); approve-page + data path for 记录数. +- Gates: `cargo test`, `just check`, `just check-worker`. + +## 4. Risks + +- Touch ID dialog height grows by 1–2 lines on sign/decrypt; caps keep + each line bounded and truth lines are at the top. +- Notification format change (`· N 条`, `via:`) may surprise downstream + parsers of Pushover/Slack text — none are known; the audit row is the + stable machine surface. +- Audit schema growth is additive-only; no migration risk beyond the + established ALTER pattern. +- `scope_label` for workspace/cwd grants contains a local filesystem + path (`workspace /Users/…`). Accepted: the same class of data already + leaves the host via `ChallengeMeta.pwd` on every ceremony, and the + audit push is opt-in (`--audit-url`). diff --git a/docs/authorization-scopes-v2.md b/docs/authorization-scopes-v2.md new file mode 100644 index 0000000..87d52eb --- /dev/null +++ b/docs/authorization-scopes-v2.md @@ -0,0 +1,476 @@ +# Authorization scopes V2 — activity-scoped grants + +Status: **implemented; revised after expert review R1, then extended with +the cwd fallback (§4a)** + +V2 replaces the caller-topology cache modes (`none` / `per-session` / +`per-app` / `global`) with per-operation activity scopes. The unified +authorization engine (`src/core/authorization.rs`) is unchanged; V2 only +changes how handlers construct `GrantScope` values, how connections classify +their peer, and what the operator can configure. + +## 1. Decision + +A grant names **the activity the human approved**, in the dimension where +that operation's risk actually lives: + +| Operation | Grant identity | Rationale | +|---|---|---| +| raw SSH sign, session-bound | key fingerprint × **destination host key** | The risk of a signature is "authenticate as me to some server". Which directory or process asked is incidental. | +| raw SSH sign, unbound non-ssh peer | key fingerprint × **workspace** | `ssh-keygen -Y sign` (git commit signing) never binds; the local kernel-verified caller's project is the activity. | +| `sign@vt` from local vt | key fingerprint × **workspace** | The local vt client is kernel-verified; a multi-host fan-out from one project is one human activity. | +| local caller with no `.git` root | key fingerprint / secret × **cwd directory** | Outside any repository the kernel-derived cwd itself is the activity — a distinct grant family from workspaces (§4a). | +| local caller from a broad shared cwd ($HOME, `/`, temp roots) | key fingerprint / secret × **parent application** | Daemons and GUI helpers launch from shared directories; the kernel-derived parent process is the activity (§4a parent-app arm). | +| `sign@vt` via relay **or plain-ssh** connection | key fingerprint × claimed pwd, bounded **per connection** | Unchanged from V1: a connection that can carry forwarded remote traffic reuses only its own approvals and never reaches the workspace arm. | +| `decrypt@vt` local, pure v2 | secret `(type, salt)` × **workspace** | Secrets belong to projects. "I am working in this project" is the approval unit. | +| `decrypt@vt` via relay or plain-ssh connection, pure v2 | secret `(type, salt)` × claimed host/pwd, bounded per connection | Unchanged from V1. | +| legacy-containing `decrypt@vt` | — | Fresh, unchanged. | +| `auth@vt` | — | Fresh by definition: it attests "a human is present now". | +| `run@vt` | — | Fresh. A future reusable policy needs exe identity + argv digest + `max_uses`; out of scope. | + +The four cache modes are deleted. They existed to describe caller topology +(terminal session, app bundle, global escape hatch for TTY-less +orchestrators); activity scopes do not care who the caller is, so the knob — +and the TTY gate that made AI agents and CI uncacheable — disappears. + +## 2. Threat-model stance (what a cache key is for) + +Key components ranked by who can forge them: + +1. **Cryptographic** — session-bind destination: the server host key's + signature over the KEX session identifier. Unforgeable by a **remote** + host. A same-UID local process that captured a genuine + `(session_id, signature)` pair (e.g. a trojaned ssh) can replay it on its + own connection — which grants it nothing it could not get by driving the + real ssh, so this stays within the concession below. +2. **Kernel-derived** — peer pid, start time, executable path, **cwd** + (`proc_pidinfo(PROC_PIDVNODEPATHINFO)`). Trustworthy against remote and + cross-context confusion; forgeable by a same-UID local process (it can + `chdir` anywhere and exec real tools). +3. **Client-claimed** — pwd/host strings in vt request metadata. Partitioning + for honest callers only; never a security boundary. + +Stated plainly: **against same-UID local malware no cache key is a boundary** +— such a process can drive the real ssh/vt binaries inside any scope. The +boundaries against that adversary are the revocation epoch (lock, wake, +idle), the strict TTL, and audit push. The job of the scope is (a) matching +honest intent so prompts are rare and meaningful, and (b) containing +**remote** peers (forwarded sockets, relays) and accidental cross-context +reuse. V1's per-session/TTY machinery paid usability for no additional +protection within this model; V2 spends the same budget on intent-matching. + +## 3. Sign scopes + +### 3.1 session-bind is a plaintext extension — dispatcher restructure + +OpenSSH ≥ 8.9 clients send `session-bind@openssh.com` on every agent +connection before requesting signatures: `(host_key, session_id, signature, +is_forwarding)`, plain SSH-wire encoded. **It is not a vt extension and is +never VT_AUTH-encrypted.** `Session::extension()` currently early-returns +for non-vt names and only decrypts `details` with the auth cipher for vt +names; session-bind must be intercepted **before** the keychain load / +auth-cipher path: + +```text +extension(): + locked check (unchanged) + name == "session-bind@openssh.com" + -> parse_message::() on the raw details + -> update per-connection bind state (§3.2) + -> Ok(None) on success (plain SSH_AGENT_SUCCESS), + Err(AgentError::Failure) on any invalid bind + other non-vt names -> Ok(None) (unchanged) + vt names -> keychain, auth cipher, envelope dispatch (unchanged) +``` + +`ssh-agent-lib` 0.5 ships the `SessionBind` message type and +`verify_signature()` (host key over session id). A failed or absent bind is +non-fatal to the ssh client; it only means the connection never becomes +destination-cacheable. `session-bind` must not touch the idle-activity +clock (it precedes any human-gated operation). + +### 3.2 Per-connection bind state machine + +Each `VtSshSession` (one per socket connection) tracks: + +```text +BindState = Unbound + | Bound { destination: KeyData-wire-bytes digest, forwarding: bool, + session_ids: Vec } + | Tainted +``` + +Rules (the shape follows OpenSSH's `process_ext_session_bind`; the numeric +cap is a **local DoS-defense choice**, not a claimed protocol contract): + +- An **unverifiable** bind — bad signature, or a host key this build cannot + decode or verify (host certificates, curves without an enabled crypto + feature such as nistp521) — is refused with an extension failure WITHOUT + changing recorded state, mirroring OpenSSH. Benign cert/p521 + infrastructure therefore simply gets no destination caching (the ssh peer + stays `Unbound` ⇒ Fresh) instead of an attack-flavored diagnosis. +- A **consistency violation** — duplicate `session_id` under a different + host key, or a forwarding→non-forwarding downgrade for the same session + id — ⇒ `Tainted` + failure reply: the peer is playing games with state + the agent must remember. +- More than 16 recorded ids ⇒ refuse the excess bind, keep recorded state + (OpenSSH behavior). +- First valid bind ⇒ `Bound` with that host key and flag. +- A further valid bind for a **different** host key, or any bind with + `is_forwarding = true`, marks the connection as forwarding-capable: + requests can originate beyond the first hop, so the connection is no + longer destination-cacheable (state stays `Bound` with + `forwarding = true`). +- `Tainted` never recovers for the connection lifetime. + +The bind intercept sits before the agent-lock check (binding grants nothing +by itself and must work on connections opened while locked) and never +touches the idle clock. + +### 3.3 Scope derivation for raw `SIGN_REQUEST` + +```text +Bound { forwarding: false, destination } + ⇒ subject = (0, 0) // destination grants are user-wide + digest = H("vt-authz-sign-dest-v1", + wire-encoded KeyData bytes of the destination host key, + key_fp) +Bound { forwarding: true } | Tainted + ⇒ Fresh +Unbound, peer executable is an OpenSSH client (basename "ssh") + ⇒ Fresh // cannot distinguish auth from + // forwarding without a bind +Unbound, peer is any other local process (ssh-keygen, custom tools) + ⇒ workspace scope (§4): + subject = workspace identity + digest = H("vt-authz-sign-ws-v1", key_fp) +``` + +The digest hashes the **exact wire-encoded `KeyData` bytes** from +`SessionBind.host_key`; string fingerprints are display-only. + +Destination grants are deliberately shared across all local callers: any +same-UID process could drive the real ssh anyway (§2), and the human intent +"talk to github.com with this key" is caller-independent. Repeated one-shot +`git fetch` therefore hits after one approval; forwarded (`ssh -A`) traffic +never reads or writes these grants — strictly stronger than V1's +per-ssh-process narrowing, which could not distinguish the two cases at all. + +The unbound-non-ssh workspace arm exists for SSH-based git commit signing +(`ssh-keygen -Y sign` sends no bind). A renamed ssh binary would take this +arm and could expose workspace grants to traffic it forwards; renaming ssh +is a deliberate same-UID local action and stays within the §2 concession +(documented, accepted — V1's `is_ssh_client_path` basename match had the +same evasion property). + +Prompt display: the agent knows a bound destination only as a host key. The +prompt shows its SHA256 fingerprint and, best-effort, a hostname resolved by +scanning unhashed `~/.ssh/known_hosts` entries for that key. The display +name is cosmetic; the grant is keyed on the host key bytes only. + +### 3.4 `sign@vt` and `decrypt@vt` connection confinement + +- Local peer (kernel-verified vt process — neither the relay nor an ssh + binary): workspace scope (§4) — `digest = H("vt-authz-sign-ws-v1", + key_fp)`, subject = workspace identity. One approval covers a same-project + multi-host fan-out (`tssh h1 h2 …`), preserving the V1 fan-out behavior + with a kernel-verified boundary instead of a claimed `pwd`. Raw + commit-signing grants (§3.3) and `sign@vt` grants from the same workspace + and key intentionally share this scope family. +- **Confined connections** — the relay (`vt ssh connect + --forward-real-agent`, detected via kernel argv as today) AND any peer + whose executable is the OpenSSH client: per-connection subject + `(pid, start_tvsec)` with the V1 digests. An ssh peer can be `ssh -A` + relaying a remote host's vt extensions; classifying it as a local caller + would hand the remote the local workspace scope, so both peer kinds are + confined identically and never reach the workspace arm. + +## 4. Workspace identity + +For local vt peers (decrypt and sign@vt) and unbound non-ssh raw signers: + +1. Read the peer's kernel cwd: `proc_pidinfo(pid, PROC_PIDVNODEPATHINFO)` + (`pvi_cdir.vip_path`). Failure ⇒ Fresh. +2. Ascend from cwd to the nearest ancestor containing a `.git` entry (dir + **or** file — worktrees use a file), depth-capped as a syscall bound. + **None found ⇒ the cwd itself becomes the scope** (§4a) unless it is a + broad shared directory. A root that IS `$HOME` (a dotfiles repository — + `git init ~`, yadm) and a root found above `$HOME` for a cwd inside + `$HOME` are rejected the same way: accepting them would make every + caller under the home directory share one grant bucket, so they degrade + to the narrower cwd fallback instead. +3. Capture the workspace identity through **one file descriptor**: + `open(root, O_RDONLY | O_DIRECTORY)` → `fstat(fd)` for `(st_dev, st_ino)` + → `fcntl(fd, F_GETPATH)` for the canonical path → close. The dev/ino pair + is the engine `SubjectId`; the digest additionally binds the canonical + path, so an inode recycled for a different path cannot match: + `H("vt-authz-decrypt-ws-v1", root_path, secret_type, salt)`. Deriving + path and identity from the same fd removes the rename race between two + separate lookups. + +Client-reported `pwd` remains display metadata. If it is present and does +not lie inside the workspace root, the request degrades to Fresh +(consistency check, not a boundary). Resolution happens once per connection +(vt CLI connections are per-command; a process cwd does not change +mid-connection in practice), and **lazily on first use** rather than at +accept time: the resolver does filesystem I/O on a peer-controlled cwd, and +a hung network mount must stall only that connection, not the accept loop. + +Subject spaces overlap structurally (`(dev, ino)` vs relay `(pid, tvsec)` +vs `(0, 0)`), but every scope family uses a distinct digest domain label, +so a subject collision alone can never merge grants. + +## 4a. Cwd fallback (no `.git` root) + +When step 2 finds no usable `.git` root, the kernel-derived cwd directory +itself becomes the scope, captured through the same one-fd identity as a +workspace root, with two deliberate differences: + +- **Distinct grant family.** The digests use `vt-authz-sign-cwd-v1` / + `vt-authz-decrypt-cwd-v1`, and the engine tags grants with a + `ScopeFamily` so diag counting can also tell the families apart. The same + directory before and after `git init` shares its `(dev, ino)` subject and + canonical path; without domain separation, adding or removing a `.git` + entry would silently continue the other family's grants with no new + approval event. +- **Broad shared directories are never a directory scope** + (`cwd_fallback_acceptable`, checked on the fd-derived canonical path so + `/tmp` aliases cannot dodge it): `$HOME`, any ancestor of `$HOME` (`/`, + `/Users`), the shared temp roots (`/tmp`, `/private/tmp`, `/var/tmp`, + `/private/var/tmp`), `/Volumes`, and the per-user Darwin temp root + (`confstr(_CS_DARWIN_USER_TEMP_DIR)`, queried from the OS, never from a + peer's `$TMPDIR`). These are the launch cwd of many unrelated activities; + scoping them would pool that work into one bucket. They fall through to + the parent-app arm below. Subdirectories (`/tmp/scratch`, `~/notes`) name + one activity and are acceptable. + +### Parent-app arm (broad cwd) + +A caller whose cwd is one of the excluded broad directories is typically an +application-spawned helper (a daemon probing `gh` through the hook from +cwd `/`, a GUI app's git integration from `$HOME`). Its activity identity is +the **kernel-derived parent process**: + +- `subject` = parent `(pid, start_tvsec)` — grants die when the app exits; +- digest binds the parent's kernel-verified executable path + (`proc_pidpath(ppid)`, never the client-claimed `ppid_cmd` string): + `H("vt-authz-{sign,decrypt}-app-v1", parent_exe, …)`; +- parent `pid <= 1` (launchd — daemon orphans) or a failed lookup ⇒ Fresh + (diag basis `no-workspace-root`): scoping to launchd would pool every + daemon on the box; +- the claimed `pwd` is display-only in this arm (there is no root to check + it against), like the connection arms; +- prompt label: `reuse: app `; diag basis `parent-app`. + +The fallback matches the honest-intent reading of §2: repeated operations +from one non-repository directory (a deploy dir, a scratch checkout-free +project) are one human activity. Remote containment is unaffected — confined +connections (relay / ssh peers) never reach this arm, exactly as they never +reach the workspace arm. The prompt states the scope as +`reuse: directory ` (§6) and diag reports basis `cwd-fallback` (§7). + +## 5. Configuration + +Deleted: `--ssh-auth-cache-mode`, `--decrypt-auth-cache-mode`, +`AuthCacheMode`, `classify_cache_context`, the TTY gate, and the +mode-specific `ContextBasis` variants. + +Kept, with changed semantics: the two duration flags, because sign and +decrypt carry deliberately different blast radii (a cached decrypt grant +releases per-record DEK material; a sign grant concedes single challenges). +Collapsing them would force an operator who caches signs but keeps decrypt +always-fresh to widen decrypt exposure. + +```text +--ssh-auth-cache-duration default 0 +--decrypt-auth-cache-duration default 0 +``` + +- `0` ⇒ that operation uses the engine's first-class `Fresh` policy (not + `StrictTtl(0)`). Defaults preserve V1's out-of-box behavior: always + prompt. +- `> 0` ⇒ that operation's reusable scopes use `StrictTtl(n)`. `auth@vt` + and `run@vt` ignore both flags (existing invariant). + +TTL semantics are unchanged mechanically (strict dual-clock, non-sliding) +but should be read as "cap within a presence session": screen lock, wake, +agent lock, and idle timeout still revoke everything, so generous values +(hours) are reasonable — the human leaving is the real expiry event. +Suggested values in examples: `28800` (8 h) sign, `3600` decrypt. + +The idle timeout (`--timeout` / `[agent].timeout`) is the one backstop that +overlaps with lock/sleep in purpose but fires only after *inactivity*, so a +short idle timeout would prematurely cap an otherwise-generous cache TTL even +while the human is present and the screen unlocked. Because lock/sleep +invalidate far sooner (seconds, via the cache watcher) on any Mac with prompt +auto-lock, the default idle timeout is **2 h** — long enough not to fight +generous TTLs, while still bounding the unlocked-but-unattended window. Hosts +without reliable auto-lock should lower it, since there the idle timeout is +the only walked-away guard. + +## 6. Prompt transparency invariant + +**The prompt must state the reuse scope whenever an approval can create a +grant.** When the effective policy is reusable, the prompt gains a final +line: + +```text +reuse: github.com (SHA256:…) · 8h # destination sign +reuse: ~/code/vt · 8h # workspace sign / decrypt (bare path + # = the whole repository; $HOME + # contracted to ~ for display) +reuse: directory /opt/deploy · 8h # cwd-fallback sign / decrypt +reuse: app Paseo Daemon · 8h # parent-app sign / decrypt +``` + +The workspace label is deliberately unmarked — it is the overwhelmingly +common case, and repeating a `workspace` keyword on every prompt added no +information. The narrower fallback families keep their prefix so "this +exact directory" / "this app" can never read as a repository scope. + +Fresh operations (`auth@vt`, `run@vt`, legacy decrypt, unbound-ssh sign) +show no reuse line. The approved range and the displayed range must be the +same sentence; handlers build the label from the same fd-derived data the +scope digest uses (§4.3). + +Beyond the reuse line, prompts carry further agent-derived truth lines +(docs/approval-transparency.md): a kernel-verified `caller:` line on the +four vt extensions, and on raw signs a `dest:` line for a verified +non-forwarding session-bind (when no reuse line already names it), a +forwarding flag, or a taint warning. **Every agent-derived truth line +precedes every client-reported line** (command body, `pwd:`/`via:`/`ssh:`), +so a hostile caller can pad only its own region off-screen. + +## 7. Diagnostics + +`diag@vt` replaces `mode` + V1 `ContextBasis` with the scope classification: + +- sign report: `basis ∈ {session-bind, forwarding, tainted, unbound-ssh, + workspace, cwd-fallback, parent-app, no-workspace-root, relay-connection, + ssh-connection, disabled, …}`; +- decrypt report: `basis ∈ {workspace, cwd-fallback, parent-app, + no-workspace-root, relay-connection, ssh-connection, disabled, …}`. + +`live_entries` counts only grants the caller's **own** classification would +hit: a `session-bind` caller counts the user-wide destination grants (those +are exactly what its requests reuse), a workspace caller its workspace +grants, a confined connection its connection grants, and any never-cached +basis reports 0 — never a whole-store count, and never grants a +differently-classified caller would need. + +The agent is a long-lived daemon and does not restart on CLI upgrade, so +`vt doctor` must handle a stale agent gracefully: compare the existing +`agent_version` field against the client's own version and print an explicit +"agent is vX, client is vY — restart the agent" warning; a diag body that +fails to parse produces the same hint instead of a raw error. `diag@vt` +remains read-only, prompt-free, and must still not reset the idle clock. + +## 8. What does not change + +- The engine: `Operation × SubjectId × ResourceDigest`, permits, + commit-after-success, epoch revocation, strict dual-clock TTL, prompt + serialization. V2 is scope construction + connection classification only. +- Relay behavior (`--forward-real-agent`): filter, per-connection + confinement, `run@vt` refusal. The relay's own filter continues to refuse + `session-bind@openssh.com` (it is not in the allow-list), so remote hosts + cannot bind the upstream connection. +- `auth@vt`/`run@vt` fresh-always; legacy decrypt fresh; `--no-legacy-decrypt`. +- Idle/lock/wake/sweep machinery and audit labels/outcomes. + +## 9. Non-goals + +- OpenSSH destination constraints (`restrict-destination-v00@openssh.com`). +- Verifying that a sign request's embedded session id matches the bound + session (hardening note for a later pass; forwarding exclusion already + covers the cache-relevant case). +- Unattended periodic jobs: still a credential-tiering problem (read-only + deploy keys), not a caching problem. Document in README/FAQ. +- Approval-time scope choice UI ("allow once / allow 8 h"): the prompt text + states the scope; interactive choice is a later UX pass. +- Config-file per-scope TTL overrides. + +## 10. Residual risks and regressions (accepted, documented) + +- Same-UID local processes can operate inside any scope (§2), including by + renaming an ssh binary into the unbound-non-ssh workspace arm (§3.3) or + replaying a captured session-bind. Unchanged in substance from V1. +- A destination grant covers that server for every local caller for the + TTL. Within the presence-session reading of TTL this is the intended + meaning of the approval. +- Workspace identity follows `.git` root detection; submodules/worktrees + resolve to the nearest `.git` entry, which may be narrower than the + superproject. Narrower = more prompts, never broader reuse. +- Raw signs from OpenSSH < 8.9 (no session-bind) are permanently Fresh — + they previously could cache under an explicit `Global`/`PerSession` mode. + macOS has shipped ≥ 8.9 since Ventura; treat via README note, no knob. +- Servers using SSH host certificates or curves this build cannot verify + (nistp521) never become destination-cacheable: their binds are refused + without poisoning the connection, so raw signs to them stay Fresh. +- A long-lived non-ssh local client (IDE git integration, a daemonized + signer) has its workspace snapshotted at connection time; if it later + `chdir`s to another repository on the same connection, its workspace + requests degrade to Fresh (pwd consistency check) and its raw-sign grants + stay keyed to the original root until it reconnects. Fail-narrow, + documented; reconnecting re-resolves. +- `sign@vt` via relay signs with the relay's configured `--host` label, not + a verified downstream destination (unchanged V1 behavior); it is + per-connection confined and must never be described as destination-bound. +- Working outside any git repository caches per exact cwd directory (§4a) — + the operator-requested revision of V2's original never-cache stance. The + activity unit is coarser than a git root ("same launch directory" rather + than "same project"): long-lived helpers that launch from a generic + directory and CI runners that reuse a checkout root share grants they + would not share under `.git` scoping. Bounded by the shared-directory + exclusions, the distinct grant family, TTL, and the revocation epoch. +- The parent-app arm keys on the immediate kernel parent. Everything one + application instance does from a broad cwd shares per-record grants, + regardless of which of its windows/jobs asked; and a helper that + daemonizes (reparents to launchd) loses its scope and degrades to Fresh + rather than pooling under launchd. Both are the intended fail-narrow + reading. Within §2, a same-UID process claiming another app's identity + was never prevented by any scope. + +## 11. Test plan + +Engine tests are untouched. New/changed coverage: + +1. Bind state machine: valid bind, bad signature ⇒ Tainted, duplicate + session id with different key ⇒ Tainted, forwarding flag ⇒ never + destination-cacheable, >16 ids ⇒ Tainted, Tainted is sticky. +2. **`Session::extension()` integration test with a real plaintext + `session-bind@openssh.com` payload** — asserting it is parsed before the + auth-cipher path, answers plain success, and flips the connection state + (the seam most likely to be implemented wrong). +3. Scope derivation: bound ⇒ destination digest over wire KeyData bytes; + forwarding/tainted/unbound-ssh ⇒ Fresh; unbound-non-ssh ⇒ workspace; + digest domain separation across scope families. +4. Workspace resolution: `.git` dir and `.git` file roots, no-root ⇒ Fresh, + pwd-outside-workspace ⇒ Fresh, fd-derived dev/ino + path binding. +5. `sign@vt` local=workspace vs relay=per-connection classification; raw + commit-sign and sign@vt sharing the workspace scope family. +6. Decrypt batches: workspace scope per `(type, salt)`, all-of hit, + legacy ⇒ fresh (existing tests adapted). +7. CLI: duration 0 ⇒ Fresh for that operation; >0 ⇒ StrictTtl; mode flags + rejected/absent. +8. Diag: new basis strings, live counts scoped as specified; doctor + version-mismatch warning on `agent_version` skew and on unparsable diag + body. +9. known_hosts display resolution: match, hashed-entry miss, absent file. + +## 12. Migration steps + +1. Add `proc_info::get_cwd` + fd-based workspace resolution + tests. +2. Add bind state machine + plaintext `session-bind` interception in + `extension()` **before** the keychain/auth-cipher path + tests (§3.1). +3. Add new `GrantScope` constructors with domain-separated digests. +4. Rewire handlers (raw sign, sign@vt, decrypt) to the new scopes. +5. Re-semanticize the two duration flags (0 = Fresh default); delete the + two mode flags. +6. Replace diag basis reporting; update `vt doctor` incl. agent-version + skew handling. +7. Add prompt reuse lines. +8. Delete `AuthCacheMode`, `classify_cache_context`, TTY/app/session + helpers that lose their last caller, and V1 `ContextBasis` variants. +9. Update `docs/README.md`, `docs/diag-design.md`, `docs/sign-vt-design.md`, + `CLAUDE.md` invariants, `config.example.toml`, `README.md` FAQ + (ControlMaster + read-only-credential guidance + OpenSSH <8.9 note). diff --git a/docs/diag-design.md b/docs/diag-design.md index f7941b1..68e7984 100644 --- a/docs/diag-design.md +++ b/docs/diag-design.md @@ -8,11 +8,13 @@ feature's decision record — verify current behavior against `src/client.rs` vt's behavior is decided by three config layers (env → config.toml → defaults), routing rules (`VT_BACKEND` pin, agent probe, passkey fallback), and agent-side -cache-context rules (TTY gate, ssh narrowing, vt-relay narrowing, pwd-scoped -keys, modes defaulting to `none`). Each rule is individually justified, but the -composition is opaque to the operator: the observable symptom is just "Touch ID -prompted again" or "the phone buzzed", and diagnosing *why* currently requires -reading `resolve_cache_context` in `src/server_macos/ssh_agent.rs`. +scope-classification rules (session-bind destination binding, workspace +resolution, relay/ssh connection confinement, durations defaulting to `0`). +Each rule is individually justified, but the composition is opaque to the +operator: the observable symptom is just "Touch ID prompted again" or "the +phone buzzed", and diagnosing *why* otherwise requires reading the +classification helpers (`sign_basis` / `decrypt_basis`) in +`src/server_macos/ssh_agent.rs`. ## 2. Goal @@ -20,9 +22,9 @@ One command — `vt doctor` — that answers: 1. Which config values are in effect and where each came from (env vs file). 2. Which transport path a call from *this* process would take, and why. -3. On the agent path: what cache modes/TTLs the agent is running with, and how - the agent classifies *this caller's* connection (cacheable? which context? - which narrowing applied?). +3. On the agent path: what cache durations the agent is running with, and how + the agent scope-classifies *this caller's* connection (cacheable? which + scope — destination, workspace, or connection?). Non-goals: fixing anything (pure diagnosis); worker-side deep checks (an HMAC-validating `/api/health` is deferred); auditing/pushing diag events. @@ -50,9 +52,9 @@ pub struct DiagRes { } pub struct DiagCacheReport { - pub mode: String, // none|per-session|per-app|global - pub ttl_secs: u64, - pub live_entries: usize, // unexpired entries right now + pub ttl_secs: u64, // 0 = Fresh (always prompt) + pub live_entries: usize, // unexpired grants THIS caller + // could use right now pub context_basis: String, // see §3.2 (cacheability is implied // by the basis; no separate bool) } @@ -70,10 +72,10 @@ pub struct DiagPeerReport { `handle_diag` on `VtSshSession`. The session already resolves both cache contexts at `new_session`; the handler reports those plus the *basis* for the -resolution (§3.2) and counts live entries via a new -`AuthCache::live_len(context)` with a clock-injectable -`live_len_at(context, now_mono, now_wall)` core — **both clocks**, the same -dual-clock validity predicate as `is_authorized` (review R3). +resolution (§3.2) and counts live entries through +`AuthorizationEngine::live_len(operation, subject)`. The unified grant store +uses **both clocks**, the same dual-clock validity predicate as authorization +lookup (review R3), and filters by typed operation plus the caller's subject. Two hard rules from review: @@ -81,51 +83,48 @@ Two hard rules from review: resets the idle clock before dispatch; a pollable no-Touch-ID extension must not keep the agent "active" forever and defeat the idle-timeout cache flush and key clear. -- **`live_entries` is scoped to the caller's own resolved context** (review - R2), never a global count. A relayed remote (or an uncacheable local caller) - must not learn how many grants other sessions/tabs on the Mac hold. Context - `None` → `live_entries = 0`. +- **`live_entries` counts only grants the caller's own scope classification + could reuse** (review R2), never a global count. A relayed remote (or an + uncacheable local caller) must not learn how many grants other scopes on + the Mac hold. A basis that never caches → `live_entries = 0`; a + destination-bound (`session-bind`) caller counts the user-wide destination + grants because those ARE the grants its own requests would hit. -### 3.2 Refactor: `resolve_cache_context` → classification with basis +### 3.2 Basis reporting (activity scopes V2) -To explain *why* a context resolved the way it did without duplicating the -logic (divergence risk), the function is refactored to return +> The original design described the caller-topology classifier +> (`resolve_cache_context`, modes, TTY gate). That machinery was replaced by +> activity scopes — see +> [`authorization-scopes-v2.md`](authorization-scopes-v2.md). This section +> describes the current basis surface. -```rust -// mode rides in the resolution (not as parallel session fields) so a -// sign/decrypt pairing mix-up in diag reporting is unrepresentable; the -// ContextBasis enum lives in core.rs so the agent's wire tags and the CLI's -// human sentences are one compile-checked mapping. -struct ContextResolution { mode: AuthCacheMode, context: Option, basis: ContextBasis } +`ContextBasis` still lives in core.rs so the agent's wire tags and the CLI's +human sentences are one compile-checked mapping. The V2 variants name how the +connection is scope-classified: +```rust enum ContextBasis { - ModeNone, // caching disabled for this op - NoPeerPid, // peer PID unavailable → uncacheable - VtRelay, // narrowed to relay (pid,start) — all modes - NoTty, // per-session/per-app TTY gate → uncacheable - SshClient, // narrowed to ssh (pid,start) — all modes - SessionLeader, // per-session: (sid,start) - AppAncestor, // per-app: (.app pid,start) - NoAppAncestor, // per-app under tmux/ssh login → uncacheable - Global, // shared (0,0) slot - ProcLookupFailed,// see below → uncacheable + Disabled, // duration 0 (the default): every request prompts + NoPeerPid, // peer PID unavailable → Fresh + RelayConnection, // grants confined to this relay connection + SessionBind, // sign: destination proven by session-bind@openssh.com + Forwarding, // sign: bound connection carries forwarded traffic → Fresh + Tainted, // sign: a session-bind failed verification → Fresh + UnboundSsh, // sign: ssh peer without session-bind → Fresh + Workspace, // local peer scoped to its kernel .git workspace root + CwdWorkspace, // no .git root: scoped to the kernel cwd directory itself + ParentApp, // broad cwd ($HOME, /, temp roots): scoped to the calling app + NoWorkspaceRoot, // cwd too broad and no usable parent process → Fresh + ProcLookupFailed,// proc-info/cwd/stat lookup failed → Fresh } ``` -`ProcLookupFailed` is the explicit catch-all for **all five** fallible proc -lookups that today `?`-return `None` (review R5): `get_start_tvsec` in the -vt-relay branch, in the ssh-client branch, on the session leader, and on the -app ancestor, plus `get_sid` itself. The refactor must NOT use `?` early -returns (they discard which branch failed) — each fallible lookup assigns its -basis explicitly, and the existing precedence order (ModeNone → NoPeerPid → -VtRelay → NoTty gate → SshClient → mode arm) is preserved exactly. - -`resolve_cache_context` becomes a thin `.context` wrapper — **existing callers -and semantics unchanged**; `new_session` stores the full resolution (the two -direct context reads inside the session gain a `.context`, review R6). The -enum serializes to the wire as a stable string; the CLI maps it to a human -sentence (e.g. `NoTty` → "caller has no controlling TTY — per-session/per-app -never cache for orchestrated callers; use --…-cache-mode global"). +The sign basis is derived per connection from the bind state, relay flag, and +workspace resolution; the decrypt basis from the relay flag and workspace +resolution. The enum serializes to the wire as a stable string; the CLI maps +it to a human sentence and passes unknown tags through verbatim. `vt doctor` +additionally warns when `agent_version` differs from the client's own version +(the agent is a long-lived daemon and does not restart on CLI upgrade). ### 3.3 Relay @@ -187,9 +186,10 @@ Exit code 0 always in v1 (diagnostic, not a health gate). ## 5. Testing -- Pure: `ContextBasis` mapping unit tests (all branches of the classifier, - extending the existing `resolve_cache_context` tests to assert basis); - DiagReq/DiagRes serde round-trip; basis→human-string mapping total. +- Pure: `ContextBasis` mapping unit tests (all branches of the + `sign_basis`/`decrypt_basis` classifiers, asserted by the scope + classification tests); DiagReq/DiagRes serde round-trip; basis→human-string + mapping total. - `route_extension` test updated for `diag@vt`. - macOS handler compiles only under `cfg(target_os = "macos")` — verified by CI (macos-latest), not locally on Linux. @@ -199,7 +199,8 @@ Exit code 0 always in v1 (diagnostic, not a health gate). | file | change | |---|---| | `src/core.rs` | DiagReq/DiagRes/DiagCacheReport/DiagPeerReport | -| `src/server_macos/ssh_agent.rs` | EXT_DIAG, classification refactor, handle_diag, AuthCache::live_len | +| `src/core/authorization.rs` | operation/subject-scoped live grant counts | +| `src/server_macos/ssh_agent.rs` | EXT_DIAG, classification refactor, handle_diag | | `src/ssh_sign.rs` | relay `diag@vt` + tests | | `src/client.rs` | doctor body (config/routing/agent/worker sections) | | `src/config.rs` | hydrate returns populated keys | diff --git a/docs/sign-vt-design.md b/docs/sign-vt-design.md index 00c405f..9c3642a 100644 --- a/docs/sign-vt-design.md +++ b/docs/sign-vt-design.md @@ -69,9 +69,11 @@ Keychain copy" idea does not apply here. agent's existing multi-algorithm signing core (Ed25519 / RSA-SHA2 / ECDSA P-256/P-384), so it is not pinned to Ed25519 even though the git identity from `vt ssh keygen` is Ed25519. -- `sign@vt` shares the sign auth cache with the standard `SIGN_REQUEST` path - (opt-in via `--ssh-auth-cache-mode`; default `none` = always prompts). v1 - shipped cache-free; see §6 for the revised decision. +- `sign@vt` shares the sign grant store with the standard `SIGN_REQUEST` path + (opt-in via `--ssh-auth-cache-duration`; default `0` = always prompts). v1 + shipped cache-free; see §6 for the revised decision and + [`authorization-scopes-v2.md`](authorization-scopes-v2.md) for the current + activity scopes. ## 3. Wire additions (`src/core.rs`, cross-platform) @@ -140,9 +142,9 @@ fn sign_data_with_privkey( } ``` -`Session::sign` (standard path) becomes: lookup + `check_or_prompt_auth` + -`sign_data_with_privkey(...)`. Behaviour is byte-identical (guarded by a test -that signs the same data via the old inline path vs the helper). +`Session::sign` (standard path) performs lookup, requests an +`Operation::Sign` permit from the unified authorization engine, then calls +`sign_data_with_privkey(...)`. The permit commits only after signing succeeds. ## 5. Agent: `sign@vt` handler + dispatcher wiring (current) @@ -201,11 +203,10 @@ async fn handle_sign_vt( if !body.is_empty() { auth_message.push('\n'); auth_message.push_str(&body); } append_meta_lines(&mut auth_message, &req.meta); - // Cache-aware (shared sign auth cache, see §6). Distinguish reject vs - // unavailable so the client gets the right ErrKind (AuthRejected => no - // fallback). (v1 always prompted here.) - let decision = self.check_or_prompt_auth(&fp_str, &auth_message).await; - /* … audit + map CacheHit/Approved → proceed, Rejected/Unavailable → ErrKind … */ + // Shared Operation::Sign grant scope; distinguish reject vs unavailable + // so the client gets the right ErrKind (AuthRejected => no fallback). + let permit = self.authorization.authorize(sign_request).await?; + /* … sign + serialize; the dispatcher commits after response encryption … */ let sig = sign_data_with_privkey(&privkey, &req.data, req.flags) .map_err(|_| (ErrKind::Generic, Some(DETAIL_SIGN_FAILED)))?; @@ -224,7 +225,7 @@ verification BEFORE dispatch, so `sign@vt` is VT_AUTH-gated for free, and the `handle_sign_vt` errors flow back through the same `ExtResponse` envelope as the other extensions. -## 6. AuthCache decision — shared with the standard sign cache (v1 was NO cache) +## 6. Grant decision — shared with standard SSH signing (v1 was NO cache) v1 always prompted, on the theory that git pushes are infrequent and a per-sign Touch ID is cheap. In practice `vt ssh connect` is also the transport @@ -232,18 +233,17 @@ for multi-host fan-outs (`tssh h1 h2 …`), where a single command produced one Touch ID **per host** — exactly the prompt storm the sign cache exists to prevent. -`sign@vt` therefore now goes through `check_or_prompt_auth`, sharing the -standard sign cache (keyed `(context, fingerprint)`; opt-in via -`--ssh-auth-cache-mode`, default `none` = v1 behaviour). The -originally-feared "cache-context escalation" (a standard-sign grant silently -authorizing `sign@vt` for the same key, and vice versa) is now accepted -deliberately: both operations are "sign an arbitrary challenge with this key", -so a cached grant on either path already concedes the same capability for the -TTL — a dedicated `(context, fingerprint, host, op)` namespace would re-add -one prompt per host on a fan-out (defeating the point) without denying a -cache holder any real power. The shared cache carries all the standard -protections: strict TTL, dual-clock expiry, lock/wake/idle flush, and the -prompt-queue re-check that collapses concurrent bursts to one dialog. +`sign@vt` and standard signing therefore both use `Operation::Sign` in the +unified grant store (opt-in via `--ssh-auth-cache-duration`, default `0` = v1 +behaviour). Under activity scopes V2 +([`authorization-scopes-v2.md`](authorization-scopes-v2.md)) a local +`sign@vt` caller is scoped to its kernel-derived git workspace, so a +multi-host fan-out from one project shares one grant and avoids one prompt +per host; raw ssh signs are scoped to their session-bind-verified destination +instead, and relay callers stay confined per connection. +The shared store carries all the standard protections: +strict TTL, dual-clock expiry, lock/wake/idle flush, and the prompt-queue +re-check that collapses concurrent bursts to one dialog. ## 7. Client: `VTClient::sign_vt` (`src/client.rs`, `#[cfg(unix)]`) @@ -367,9 +367,9 @@ async fn sign(&mut self, request: SignRequest) -> Result)`. Failure responses must not leave caches partially populated: -1. **Sign auth cache**: granted only when `AuthOutcome::Success(method)` and `method.is_cacheable()`. Returning a structured error never enters `cache.grant(...)`. No change required — existing code already returns before granting. +1. **Sign grants**: a successful engine decision returns a non-cloneable + permit with a pending grant. Raw signing or `sign@vt` failure drops the + permit; only a successful signature (and, for extensions, encrypted + response) consumes it with `commit()`. -2. **Decrypt auth cache**: same. The `missing` set is only granted after `AuthOutcome::Success`. Verify that "partial hit → user rejects" leaves the previously-cached entries untouched (it does: we never call `cache.revoke`). +2. **Decrypt grants**: pure-v2 batches use all-of lookup. A partial hit followed + by rejection leaves existing entries untouched and adds none; successful + response encryption commits the complete deduplicated scope set. Any legacy + member makes the entire request fresh. -3. **`AuthCache::grant` idempotency**: structured errors must never extend a still-valid grant — already guaranteed by `AuthCache::grant` skipping when the entry is still in-window. No change. +3. **Strict TTL**: committing an equal or wider policy never extends a + still-valid grant. A shorter policy cannot reuse a wider grant and replaces + it only after a fresh successful approval. -4. **Lock state**: `AgentLocked` is checked before any auth prompt. The structured envelope still goes out auth-cipher-encrypted, so the lock check itself doesn't bypass the lock semantics. +4. **Lock state**: agent lock is checked before deriving the auth cipher or + showing a prompt, so it remains an unstructured SSH-agent failure and can + neither consume nor create a grant. ## What can break - **Cross-version mismatch**: someone runs an old `vt` client against a new agent (or vice versa). Mitigation: hard-fail on `v` mismatch with `ErrKind::ProtocolVersion`. We do **not** silently degrade. Document in CHANGELOG that the client/agent binaries must match. - **`detail` accidentally containing user data**: easy to regress. Mitigation: `detail` field type is `&'static str` at the construction site; we use an `enum WireFail { kind, detail: Option<&'static str> }` internally and the JSON serializer turns the static into an owned `String` only at the JSON layer. Anything dynamic forces a compile error. - **Forwarded-socket leak**: see § "auth@vt and forwarded sockets". No regression vs today's logs. -- **Cache poisoning via error path**: ruled out by review of `check_or_prompt_*` — both helpers `return false` before any `cache.grant` on the failure path. +- **Cache poisoning via error path**: ruled out by the engine's pending-grant + permit. Rejection, unavailable state, operation failure, serialization + failure, and response-encryption failure all drop without commit. - **Test surface bloat**: every kind needs a pure unit test. The current suite covers wire round-trips, `AuthOutcome` mapping, exit-code mapping, unknown future kinds, version mismatches, and malformed envelopes without requiring a diff --git a/docs/unified-authorization-engine.md b/docs/unified-authorization-engine.md new file mode 100644 index 0000000..9adf576 --- /dev/null +++ b/docs/unified-authorization-engine.md @@ -0,0 +1,367 @@ +# Unified SSH-agent authorization engine + +Status: **implemented** + +This document defines the V1 refactor that routes `auth@vt`, `run@vt`, raw +SSH signing, `sign@vt`, and `decrypt@vt` through one authorization engine. +It replaces duplicated prompt/cache state machines without making every +operation equally reusable. + +## 1. Decision + +Unify the authorization mechanism, not the risk policy: + +| Operation | V1 reuse policy | Existing behavior | +|---|---|---| +| raw SSH sign / `sign@vt` | configured strict TTL | preserved | +| pure-v2 `decrypt@vt` | configured strict TTL | preserved | +| legacy-containing `decrypt@vt` | fresh | preserved | +| `auth@vt` | fresh | preserved | +| `run@vt` | fresh | preserved | + +`Fresh` is a first-class policy. It never reads or writes grants; it is not +implemented as a zero-second TTL. `auth` and `run` still use the same scope, +prompt serialization, epoch validation, typed decision, permit, and audit +path as reusable operations. + +V1 does not add CLI flags, change the extension wire format, or make +client-reported command text a security boundary. Future caching for `auth` +or `run` requires structured scopes described in section 11. + +## 2. Goals + +1. One state machine for cache lookup, prompt serialization, authentication, + invalidation, and grant commit. +2. One in-memory grant store, partitioned by typed operation and subject. +3. Preserve current sign/decrypt cache keys, TTLs, diagnostics, audit labels, + and full-batch decrypt semantics. +4. Route `auth` and `run` through the engine while keeping them fresh. +5. Fix the clear-versus-in-flight-prompt race with an epoch and security gate. +6. Do not create a grant when the authorized operation itself fails. +7. Keep the authorization state machine platform-neutral and deterministically + testable without Touch ID or Keychain access. + +## 3. Non-goals + +- Workspace/repository discovery and normalized command intents. +- Caching `auth@vt` or `run@vt` in V1. +- Implementing OpenSSH `session-bind@openssh.com` or destination constraints. +- Changing the Worker DEK cache. +- Combining authorization decisions with secret/signature execution code. +- Persisting grants across agent restarts. + +## 4. Model + +The reusable lookup identity is: + +```text +GrantKey = Operation x SubjectId x ResourceDigest +``` + +`Operation` is typed and always participates in equality: + +```rust +enum Operation { + Auth, + Run, + Sign, + Decrypt, +} +``` + +This prevents cross-capability reuse even if two resource digests happen to +contain equivalent source material. Raw SSH sign and `sign@vt` intentionally +share `Operation::Sign`, matching current behavior. Their existing `pwd` +inputs still usually partition them because raw agent requests have no +`ClientMeta` and use an empty `pwd`. + +`SubjectId` is an opaque `(u64, u64)` anchor. At the time of this document it +was derived from caller topology (session leader / app ancestor / global / +ssh process / vt relay); activity scopes V2 +([`authorization-scopes-v2.md`](authorization-scopes-v2.md)) replaced that +derivation with destination / workspace / relay-connection subjects. A +missing subject makes a configured reusable request effectively fresh. + +`ResourceDigest` remains domain-separated. The V1 digests — kept for relay +connections, with destination and workspace digest families added by V2 — +were: + +- sign: fingerprint + client-reported `pwd`; +- decrypt: secret type + salt + client-reported host + `pwd`. + +The store never retains raw host, path, command, reason, key material, DEK, or +signature data. + +## 5. Policies and decisions + +```rust +enum ReusePolicy { + Fresh, + StrictTtl(Duration), +} + +enum Decision { + CacheHit, + Approved(AuthMethod), + Rejected, + Unavailable(UnavailableReason), + Invalidated, +} +``` + +Successful authorization returns a non-cloneable `AuthorizationPermit`, not +a boolean. A failed authorization returns an `AuthorizationFailure` carrying +the failure `Decision` and latency. The permit records the success decision and owns the synchronization +guards needed to linearize the operation against invalidation. + +Failure mapping remains stable: + +| Engine failure | Extension error | Raw SSH sign | +|---|---|---| +| rejected | `AuthRejected` | agent failure | +| not interactive | `SessionLocked` | agent failure | +| no GUI session | `NoGuiSession` | agent failure | +| revoked during authorization | `Transient` | agent failure | + +`Invalidated` uses a static detail string only. It never reflects request data. + +## 6. Authorization state machine + +All four operation families follow this sequence: + +```text +handler validation and policy checks + -> build typed scope and reuse policy + -> reusable full-set lookup + hit -> live security validation -> permit(CacheHit) + unsafe -> publish revocation latch -> invalidate -> failure + miss/fresh + -> acquire the global prompt permit + -> reusable full-set lookup again + hit -> live security validation -> permit(CacheHit) + miss/fresh + -> capture epoch + -> invoke the injected authenticator + reject -> failure, no grant + unavailable -> publish revocation latch -> invalidate -> failure + -> acquire security read gate + -> compare epoch and revalidate locked/interactive state + changed/invalid -> failure, no grant + -> permit(Approved, pending grant) + -> handler executes and serializes the already-validated operation + failure -> drop permit, no grant + -> extension dispatcher builds and encrypts the success envelope + failure -> drop permit, no grant + success -> permit.commit() + -> return encrypted result +``` + +Raw SSH signing has no extension envelope: it commits after signature +generation succeeds. Decision audit is emitted when authorization resolves; +operation-specific failure audit (for example `run` spawn failure) remains +separate from grant commit. + +The prompt semaphore remains held by an approval permit until commit or drop. +Consequently, a same-scope waiter cannot slip between approval and grant: it +rechecks only after the first operation has either committed its grant or +failed without one. + +No grant-store lock, agent-lock lock, or security write gate is held while a +human prompt is displayed. + +A live permit holds the global prompt slot and a security read guard until +commit or drop. Handlers therefore must not perform unbounded-latency work +while a permit is live: a slow handler delays every other prompt-requiring +request and stalls lock/wake revocation, which waits for outstanding permits. +Today's handlers only sign, derive, spawn, and serialize locally; keep it +that way. + +## 7. Permit and invalidation linearization + +The engine owns: + +```text +epoch: generation protected by the grant-store lock +revocation_pending: atomic latch +revoker_running: atomic single-revoker claim +security_gate: async RwLock +grant_store: async RwLock> +prompt_sem: one-permit semaphore +``` + +An `AuthorizationPermit` owns a security read guard through the sensitive +operation. An invalidator takes the security write guard, increments the epoch, +and clears the store. This defines a deterministic order: + +- if the operation obtained its permit first, it completes before lock/wake/ + idle invalidation completes; +- if invalidation obtained the write gate first, the pending authorization + observes a changed epoch and cannot execute or grant. + +Every completed revocation cycle increments the epoch even when the grant store +is empty. Concurrent observations are coalesced into one cycle. This is required +to revoke a prompt that is currently displayed before it has created any entry. + +Unsafe live validation publishes `revocation_pending` synchronously before it +returns. Concurrent readers recheck that latch before receiving a permit. One +CAS-claimed, cancellation-safe revoker clears the store and advances the epoch; +concurrent failures wait for that revoker instead of queueing duplicate writers. +Waiters retry the CAS claim after every completion notification, so a +cross-atomic hand-off cannot leave `revocation_pending=true` without an owner. +The detached prompt worker drains revocation itself on `Unavailable`, even if +the requesting connection was cancelled while the system prompt was visible. + +Invalidation sources are: + +- `ssh-add -x` agent lock; +- idle timeout; +- interactive to locked/off-console transition; +- sleep/wake clock divergence. + +Cache-hit and post-prompt paths both perform an uncached macOS session-state +check. The five-second watcher is revocation backup and cleanup, not the sole +hit-path security gate. The macOS validator also compares monotonic and wall +clock progress so a detected wake is rejected before the next watcher tick. + +## 8. Grant commit and TTL + +Fresh approval does not immediately write the reusable grant. The pending +grant is committed only after the handler successfully signs, derives and +serializes the decrypt result, or completes its operation-specific success +point. Extension grants commit only after the success envelope is serialized +and encrypted. If any of those steps fails, dropping the permit writes nothing. + +Grant commit is all-or-nothing for the deduplicated scope set and checks the +captured epoch again under the security read gate. + +Expiry retains the existing rules: + +- both monotonic and wall deadlines are stored; +- an entry is valid only while both clocks are before their deadlines; +- a still-valid re-grant does not extend either deadline; +- an expired entry may be replaced after a fresh approval; +- one batch captures one clock pair and one deadline. + +Duration construction uses checked arithmetic both before lookup/prompt and at +commit; hostile configuration fails closed without prompting or executing the +protected operation and cannot panic the agent. + +## 9. Operation adapters + +### Sign + +Raw `SIGN_REQUEST` and `sign@vt` complete key lookup and request validation +before authorization. Both submit one sign resource and the configured sign +policy. A successful signature is produced before the permit commits a new +grant. + +### Decrypt + +Parsing, size limits, empty-batch rejection, `UNKNOWN` rejection, legacy +policy, and key-store availability all precede reusable lookup. + +Pure-v2 batches submit one resource per `(type, salt)`. A cache hit requires +all resources. A partial hit prompts once and a successful operation commits +the complete deduplicated set. A rejected or failed request preserves existing +entries and adds none. Any legacy item makes the whole request fresh. + +### Auth + +`auth@vt` submits `Operation::Auth` with `Fresh`. It uses the same prompt, +epoch, live-state validation, permit, decision mapping, and audit path, but +never looks up or writes a grant. + +### Run + +`run@vt` parses limits, resolves the canonical executable against the +allowlist, and builds its sanitized prompt before authorization. It submits +`Operation::Run` with `Fresh`. Spawn happens while the permit is live. A spawn +failure drops the permit and retains the existing second `spawn_failed` audit +event. + +A future reusable run policy must not be able to bypass these validations. + +## 10. Diagnostics and audit + +The `diag@vt` wire shape remains unchanged. Sign/decrypt live counts query the +unified store with both operation and current context, so counts remain scoped +to the caller. Mode, TTL, and context basis still come from existing configs. + +Audit operation labels remain unchanged (`sign`, `ssh-sign`, `decrypt`, +`auth`, `run`). Engine decisions map to the existing outcomes: + +- cache hit -> `cache_hit`, latency `0`; +- fresh approval -> `approved`; +- rejection -> `rejected`; +- unavailable or revoked -> `unavailable`. + +Audit is observational and never authorizes an operation. + +## 11. Future auth/run reuse + +The V1 engine supports reusable policies for any typed operation, but V1 does +not define cacheable scopes for `auth` or `run`. + +Before enabling them: + +- auth needs structured service, target user, TTY/PAM session, and consumer + identity; free-form `reason` is display metadata only; +- run needs an explicit allowlist rule opt-in, canonical executable identity, + full argv digest, fixed cwd/environment profile, policy version, short TTL, + and preferably `max_uses`/rate limiting. + +These additions require a separate security review and user-visible policy. + +## 12. Forwarding limitation + +The existing ssh/vt relay context remains process-based, not a cryptographically +verified SSH destination. V1 preserves that behavior but must not describe it +as destination-bound. + +Implementing `session-bind@openssh.com` requires host-key signature validation, +duplicate session-id rejection, authentication-versus-forwarding separation, +and binding consistency. Until then, forwarding retains its documented +per-process risk and should stay uncached on untrusted hosts. + +## 13. Test requirements + +Cross-platform engine tests must cover: + +1. Fresh always prompts and never creates a grant. +2. Reusable first approval then cache hit. +3. Operation, subject, and resource isolation. +4. Full, partial, duplicate, and empty resource sets. +5. Rejected/unavailable outcomes never grant. +6. Same-scope concurrent misses produce one prompt. +7. Different scopes and Fresh requests do not coalesce incorrectly. +8. Invalidation during a prompt returns revoked and cannot commit. +9. Invalidation waits for an active operation permit. +10. Failed operation drops a pending grant. +11. Strict dual-clock TTL and non-sliding re-grant. +12. Live counts remain operation/context scoped. + +macOS adapter and handler verification must cover: + +- live locked/interactive checks on hit and post-prompt paths; +- sign and `sign@vt` using the same typed operation; +- pure-v2 full/partial hit and legacy-fresh behavior; +- auth/run always traversing the engine as Fresh; +- run validation occurring before authorization; +- lock/idle/wake invalidation; +- unchanged structured-error, audit, and diagnostic output. + +## 14. Migration and completion criteria + +Migration is a direct replacement, not dual-write: + +1. Add and test the platform-neutral engine. +2. Add the macOS authenticator/session-state adapter. +3. Migrate sign, decrypt, auth, then run handlers. +4. Replace both old caches and the standalone prompt semaphore. +5. Switch lock/idle/wake invalidation and diagnostic counts. +6. Delete old decisions, cache helpers, and duplicated state machines. +7. Update repository invariants and implementation documentation. + +The refactor is complete only when all four operations call the same engine, +the old cache/prompt decision paths are absent, the race and concurrency tests +pass, and the repository Rust checks pass on macOS and Linux CI targets. diff --git a/justfile b/justfile index fca2bc9..68d63f2 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,56 @@ install: cp "$BIN" ~/.local/bin/vt echo "installed: ~/.local/bin/vt ($(du -h ~/.local/bin/vt | cut -f1))" +# Assemble VT.app (macOS): Rust binary + Swift menu-bar shell + icns. +# Ad-hoc signed by default; export VT_CODESIGN_ID for a stable identity +# (last keychain re-auth ever — see docs/app-bundle.md §7). +# VT_APP_BIN: reuse a prebuilt `vt` instead of `just build` (CI passes the +# target-specific release binary to avoid a second compile). +app: + #!/usr/bin/env bash + set -euo pipefail + [ "$(uname -s)" = "Darwin" ] || { echo "error: VT.app builds on macOS only" >&2; exit 1; } + if [ -n "${VT_APP_BIN:-}" ]; then + BIN="$VT_APP_BIN" + else + just build + BIN=target/release/vt + fi + APP=build/VT.app + rm -rf "$APP" + mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + VERSION="$("$BIN" version 2>/dev/null | awk 'NR==1{print $2}')" + sed "s/VT_BUNDLE_VERSION/${VERSION:-0.0.0}/g" app/Info.plist > "$APP/Contents/Info.plist" + # Shell binary is "VTApp" (NOT "VT"): APFS is case-insensitive by + # default, so "VT" would collide with the "vt" CLI beside it. + swiftc -O -o "$APP/Contents/MacOS/VTApp" app/VTShell.swift + cp "$BIN" "$APP/Contents/MacOS/vt" + # AppIcon.icns from the Worker PWA icon (single source of truth). + ICONSET=build/AppIcon.iconset + rm -rf "$ICONSET" && mkdir -p "$ICONSET" + SRC=cf-worker/pwa/icon-512.png + for SZ in 16 32 64 128 256 512; do + sips -z $SZ $SZ "$SRC" --out "$ICONSET/icon_${SZ}x${SZ}.png" >/dev/null + DBL=$((SZ * 2)) + sips -z $DBL $DBL "$SRC" --out "$ICONSET/icon_${SZ}x${SZ}@2x.png" >/dev/null + done + iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" + rm -rf "$ICONSET" + codesign --force --deep -s "${VT_CODESIGN_ID:--}" "$APP" + echo "built: $APP (version ${VERSION:-unknown}, signed: ${VT_CODESIGN_ID:-ad-hoc})" + +# Install VT.app to /Applications and symlink the CLI to ~/.local/bin/vt +install-app: app + #!/usr/bin/env bash + set -euo pipefail + rm -rf /Applications/VT.app + cp -R build/VT.app /Applications/VT.app + mkdir -p ~/.local/bin + rm -f ~/.local/bin/vt + ln -s /Applications/VT.app/Contents/MacOS/vt ~/.local/bin/vt + echo "installed: /Applications/VT.app; CLI: ~/.local/bin/vt -> bundle" + echo "next: run 'vt secret rebind' once if your keychain store predates wrap v2 and the binary moved (docs/app-bundle.md §2)" + # Type-check for host + linux-gnu targets check: cargo check @@ -66,7 +116,8 @@ ci: check test check-worker deploy-worker: wrangler deploy -# The GitHub `Release` workflow builds macOS arm64 + Linux amd64 and publishes. +# The GitHub `Release` workflow builds the bare `vt` (macOS arm64 + Linux +# amd64) plus an ad-hoc-signed VT.app tarball (macOS), and publishes. # The short hash makes same-day releases unique and matches `vt version` output. # # Cut a CalVer release: tag `vYYYYMMDD-` and push it diff --git a/src/cf.rs b/src/cf.rs index 9d48f22..73b574f 100644 --- a/src/cf.rs +++ b/src/cf.rs @@ -202,6 +202,20 @@ fn tty_name() -> String { #[cfg(not(unix))] fn tty_name() -> String { String::new() } +/// Shorten a parent command line to `basename(argv[0]) + args`. A long +/// absolute argv[0] (`/opt/homebrew/Cellar/…/bin/zsh -c …`) drowned the +/// signal on every display surface (Touch ID `via:`, approval page 父进程, +/// notifications); the field is client-claimed display data everywhere, so +/// the shortening happens once at collection. +#[cfg(unix)] +fn basename_cmdline(first: &str, rest: &[String]) -> String { + let base = first.rsplit('/').next().unwrap_or(first); + std::iter::once(base) + .chain(rest.iter().map(String::as_str)) + .collect::>() + .join(" ") +} + #[cfg(unix)] fn parent_cmd() -> String { let ppid = unsafe { libc::getppid() }; @@ -217,8 +231,8 @@ fn parent_cmd() -> String { .filter(|p| !p.is_empty()) .map(|p| String::from_utf8_lossy(p).into_owned()) .collect(); - if !parts.is_empty() { - return parts.join(" "); + if let Some(first) = parts.first() { + return basename_cmdline(first, &parts[1..]); } } } @@ -228,7 +242,11 @@ fn parent_cmd() -> String { .output() { if out.status.success() { - return String::from_utf8_lossy(&out.stdout).trim().to_string(); + let full = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let mut it = full.split_whitespace().map(str::to_string); + if let Some(first) = it.next() { + return basename_cmdline(&first, &it.collect::>()); + } } } String::new() diff --git a/src/client.rs b/src/client.rs index 014cc53..50a6935 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1246,31 +1246,39 @@ pub async fn inject( only_env: Option>, mut args: Vec, ) -> Result<()> { - let raw_command = args.join(" "); - debug!("Original command: {}", raw_command); - let mut original_command = String::from("op: inject"); + debug!("Original command: {}", args.join(" ")); + // Display body shown on the Touch ID prompt, the approval page, and the + // notifications. No `op:` header — the `cmd:`/`file:` lines themselves say + // "inject" (`vt read` keeps its explicit `op: read`), and the surrounding + // surface already names the operation (prompt header / 类型 field). The + // command is shortened to basename + args and capped: a long absolute + // argv[0] drowned the signal a human actually reads (operator feedback, + // docs/approval-transparency.md §C5); the executable path was + // client-claimed display data anyway, never a verified field. + let mut lines: Vec = Vec::new(); if let Some(p) = replace_file.as_ref() { - original_command.push_str("\nfile: "); - original_command.push_str(&sanitize_for_display(p, 100)); - } - if raw_command.is_empty() { - original_command.push_str("\ncmd: [no shell command]"); + lines.push(format!("file: {}", sanitize_for_display(p, 100))); + } + let display_cmd = args + .first() + .map(|argv0| { + std::iter::once(argv0.rsplit('/').next().unwrap_or(argv0)) + .chain(args[1..].iter().map(String::as_str)) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); + let normalized = collapse_whitespace(&display_cmd); + if normalized.is_empty() { + lines.push("cmd: [no shell command]".to_string()); } else { - let normalized = collapse_whitespace(&raw_command); let redacted = redact_vt_urls(&normalized, "vt://***"); - original_command.push_str("\ncmd: "); - // Keep the full command in the audit/approval record (the detail dialog - // renders it verbatim). The Touch ID prompt re-caps per line - // (PROMPT_COMMAND_MAX_LINE_LEN) and the whole request body is bounded by - // PROMPT_DISPLAY_MAX_BYTES (8 KiB) / CEREMONY_POST_MAX_BYTES, so a - // generous cap here shows real commands in full without truncating to - // "gh api…". - original_command.push_str(&sanitize_for_display(&redacted, 1024)); + lines.push(format!("cmd: {}", sanitize_for_display(&redacted, 160))); } if let Some(r) = reason { - original_command.push_str("\nreason: "); - original_command.push_str(&sanitize_for_display(r, 200)); + lines.push(format!("reason: {}", sanitize_for_display(r, 200))); } + let original_command = lines.join("\n"); // Open the target ONCE with O_NOFOLLOW, capture its mode, and read its // content. The same in-memory bytes are reused as both the decrypt source @@ -2026,6 +2034,17 @@ pub async fn doctor(auth_token: &str, file_populated_keys: &[String]) -> Result< ), Ok(DiagOutcome::Report(d)) => { println!(" agent version: {}", d.agent_version); + // The agent is a long-lived daemon and does not restart on CLI + // upgrade — skew is common right after an update and changes + // scope/diag behavior silently. + if d.agent_version != env!("VT_VERSION") { + println!( + " ⚠ agent is {}, this client is {} — restart the agent \ + (`vt ssh agent`) so both run the same build", + d.agent_version, + env!("VT_VERSION") + ); + } // Over --forward-real-agent the upstream agent's peer is the // relay process on the agent's host — that IS the connection // relayed requests ride, so label it honestly. @@ -2045,9 +2064,8 @@ pub async fn doctor(auth_token: &str, file_populated_keys: &[String]) -> Result< ); for (label, c) in [("sign", &d.sign_cache), ("decrypt", &d.decrypt_cache)] { println!( - " {:8} mode {}, ttl {}s, live grants (this context): {}", + " {:8} ttl {}s, live grants (this caller): {}", format!("{}:", label), - c.mode, c.ttl_secs, c.live_entries ); @@ -2340,8 +2358,8 @@ mod tests { // core (adding a variant forces an arm); here pin the two doctor-side // behaviors: wire tags resolve, unknown tags degrade by naming the tag. assert_eq!( - basis_human("session-leader"), - crate::core::ContextBasis::SessionLeader.human() + basis_human("session-bind"), + crate::core::ContextBasis::SessionBind.human() ); assert!(basis_human("future-tag").contains("future-tag")); } @@ -2353,14 +2371,14 @@ mod tests { // old clients). let json = r#"{ "agent_version": "v1", - "sign_cache": {"mode":"per-session","ttl_secs":120,"live_entries":1,"context_basis":"session-leader"}, - "decrypt_cache": {"mode":"none","ttl_secs":30,"live_entries":0,"context_basis":"mode-none"}, + "sign_cache": {"ttl_secs":120,"live_entries":1,"context_basis":"session-bind"}, + "decrypt_cache": {"ttl_secs":30,"live_entries":0,"context_basis":"disabled"}, "peer": {"pid":42,"exe":"zsh","has_tty":true,"is_ssh_client":false,"is_vt_relay":false}, "run_allow_len": 0, "audit_push": false }"#; let d: crate::core::DiagRes = serde_json::from_str(json).unwrap(); - assert_eq!(d.sign_cache.context_basis, "session-leader"); + assert_eq!(d.sign_cache.context_basis, "session-bind"); assert_eq!(d.decrypt_cache.live_entries, 0); assert_eq!(d.peer.exe.as_deref(), Some("zsh")); // And the request serializes to an (empty) object. diff --git a/src/config.rs b/src/config.rs index cd77da9..80cab17 100644 --- a/src/config.rs +++ b/src/config.rs @@ -83,27 +83,35 @@ fn warn_if_world_readable(path: &Path) { /// Must be called before any threads are spawned (i.e. before the tokio runtime /// is built), because it mutates the process environment via /// [`std::env::set_var`]. `main()` calls it at the very top, single-threaded. -pub fn hydrate_env_from_file() -> Vec { - let Some(path) = config_path() else { - return Vec::new(); - }; +/// Read and TOML-parse the config file once. `None` when the file is absent +/// (silent — the common case) or unreadable/malformed (warned). Shared read +/// path for `hydrate_env_from_file` and `load_agent_file_config` so the file +/// is opened and parsed through one place with one error policy. +fn read_config_table() -> Option<(PathBuf, toml::Table)> { + let path = config_path()?; let contents = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, Err(e) => { tracing::warn!("could not read {}: {}", path.display(), e); - return Vec::new(); + return None; } }; - warn_if_world_readable(&path); - - let table: toml::Table = match contents.parse() { - Ok(t) => t, + match contents.parse::() { + Ok(table) => Some((path, table)), Err(e) => { tracing::warn!("ignoring malformed config {}: {}", path.display(), e); - return Vec::new(); + None } + } +} + +pub fn hydrate_env_from_file() -> Vec { + let Some((path, table)) = read_config_table() else { + return Vec::new(); }; + warn_if_world_readable(&path); + let mut populated = Vec::new(); for (key, value) in &table { @@ -136,6 +144,49 @@ pub fn hydrate_env_from_file() -> Vec { populated } +// --------------------------------------------------------------------------- +// [agent] section — file defaults for `vt ssh agent` flags + +/// Optional `[agent]` table in the same config file: startup *defaults* for +/// `vt ssh agent`, so a supervisor (the VT.app shell) can spawn the agent +/// without hardcoding the operator's flags (docs/app-bundle.md §4). Explicit +/// CLI flags always override these; the env-over-file invariant is untouched +/// because no key here is an env var (`hydrate_env_from_file` skips tables). +#[derive(Debug, Default, Clone, serde::Deserialize)] +pub struct AgentFileConfig { + /// `--timeout` + pub timeout: Option, + /// `--ssh-auth-cache-duration` (0 = Fresh) + pub ssh_auth_cache_duration: Option, + /// `--decrypt-auth-cache-duration` (0 = Fresh) + pub decrypt_auth_cache_duration: Option, + /// Cache-hit transparency notifications (`--no-cache-hit-notify` inverts) + pub cache_hit_notify: Option, + /// `--run-allow` allowlist (comma-separated, same grammar as the flag). + /// Not a secret — agent policy. Lets a supervisor (VT.app) keep run@vt + /// enabled without hardcoding the list. + pub run_allow: Option, +} + +/// Read the `[agent]` section. Absent file/section or a malformed table all +/// degrade to defaults with a warning — same "never brick the CLI" stance as +/// `hydrate_env_from_file`, and reusing its read/parse path. +pub fn load_agent_file_config() -> AgentFileConfig { + let Some((path, table)) = read_config_table() else { + return AgentFileConfig::default(); + }; + let Some(agent) = table.get("agent") else { + return AgentFileConfig::default(); + }; + match agent.clone().try_into() { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!("ignoring malformed [agent] section in {}: {}", path.display(), e); + AgentFileConfig::default() + } + } +} + // --------------------------------------------------------------------------- // Transport-path routing preference (VT_BACKEND) // --------------------------------------------------------------------------- diff --git a/src/core.rs b/src/core.rs index cb104ee..53baf44 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,3 +1,4 @@ +pub mod authorization; pub mod crypto; pub mod session; pub mod wire; @@ -310,8 +311,8 @@ pub struct DiagReq {} /// Response for `diag@vt`: how the agent is configured and how it classifies /// THIS connection for caching purposes. Discloses no secret, no cache keys, -/// and nothing about other sessions (`live_entries` is scoped to the caller's -/// own resolved context). +/// and nothing about scopes the caller could not reuse (`live_entries` +/// counts only grants the caller's own scope classification would hit). #[derive(Deserialize, Serialize, Debug, Clone)] pub struct DiagRes { pub agent_version: String, @@ -324,47 +325,60 @@ pub struct DiagRes { pub audit_push: bool, } -/// Per-cache diagnostic report (one for sign, one for decrypt). +/// Per-operation diagnostic report (one for sign, one for decrypt). #[derive(Deserialize, Serialize, Debug, Clone)] pub struct DiagCacheReport { - /// none | per-session | per-app | global - pub mode: String, + /// Configured cache duration; 0 = Fresh (always prompt). pub ttl_secs: u64, - /// Currently-valid grants for THIS connection's context only (0 when the - /// connection is uncacheable) — never a global count. + /// Currently-valid grants THIS caller could use (its workspace / relay + /// connection, plus user-wide destination grants for sign) — never a + /// whole-store count. pub live_entries: usize, - /// Stable wire tag naming WHY the context resolved the way it did + /// Stable wire tag naming how this connection is scope-classified /// ([`ContextBasis::as_wire`] on the agent); the CLI maps it back via /// [`ContextBasis::from_wire`] and passes unknown tags through verbatim /// (client/agent version skew degrades to an "unknown basis" line). pub context_basis: String, } -/// Which rule of the agent's cache-context classifier decided the outcome. -/// Lives here (not in the macOS-only agent module) so the agent's wire tags -/// and the CLI's human explanations are one compile-checked mapping — adding -/// a variant forces both [`Self::as_wire`] and [`Self::human`] arms. +/// How the agent scope-classifies a connection (activity scopes V2 — +/// docs/authorization-scopes-v2.md). Lives here (not in the macOS-only agent +/// module) so the agent's wire tags and the CLI's human explanations are one +/// compile-checked mapping — adding a variant forces both [`Self::as_wire`] +/// and [`Self::human`] arms. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextBasis { - /// Caching disabled (`--…-cache-mode none`). - ModeNone, - /// Peer PID unavailable → uncacheable. + /// Cache duration is 0 (the default): every request prompts. + Disabled, + /// Peer PID unavailable → Fresh. NoPeerPid, - /// Peer is a `vt ssh connect --forward-real-agent` relay: per-connection. - VtRelay, - /// per-session/per-app TTY gate: peer has no controlling terminal. - NoTty, - /// Peer is an OpenSSH client: narrowed to that ssh process. - SshClient, - /// per-session: anchored on the terminal session leader. - SessionLeader, - /// per-app: anchored on the `.app` bundle ancestor. - AppAncestor, - /// per-app: no `.app` ancestor (tmux, ssh login session) → uncacheable. - NoAppAncestor, - /// global: the shared `(0, 0)` context. - Global, - /// A proc-info lookup (`get_start_tvsec` / `get_sid`) failed → uncacheable. + /// `vt ssh connect --forward-real-agent` relay: grants confined to this + /// connection. + RelayConnection, + /// Plain ssh peer (possibly `ssh -A` carrying forwarded remote traffic): + /// vt extensions confined to this connection. + SshConnection, + /// Destination proven by a verified `session-bind@openssh.com`. + SessionBind, + /// Bound connection marked forwarding-capable → Fresh. + Forwarding, + /// A session-bind failed verification/consistency; sticky → Fresh. + Tainted, + /// ssh peer that never sent a session-bind (OpenSSH < 8.9 or a filtered + /// path) → Fresh. + UnboundSsh, + /// Local peer scoped to its kernel-derived `.git` workspace root. + Workspace, + /// Local peer with no `.git` ancestor, scoped to its kernel-derived cwd + /// directory itself (distinct grant family from `Workspace`). + CwdWorkspace, + /// Local peer whose cwd is a broad shared directory ($HOME, `/`, temp + /// roots): scoped to its kernel-derived parent process (application). + ParentApp, + /// Kernel cwd is too broad to scope and no usable parent process either + /// (parent is launchd / lookup failed) → Fresh. + NoWorkspaceRoot, + /// A proc-info lookup (start time / cwd / workspace stat) failed → Fresh. ProcLookupFailed, } @@ -373,15 +387,18 @@ impl ContextBasis { /// change its tag. pub fn as_wire(&self) -> &'static str { match self { - ContextBasis::ModeNone => "mode-none", + ContextBasis::Disabled => "disabled", ContextBasis::NoPeerPid => "no-peer-pid", - ContextBasis::VtRelay => "vt-relay", - ContextBasis::NoTty => "no-tty", - ContextBasis::SshClient => "ssh-client", - ContextBasis::SessionLeader => "session-leader", - ContextBasis::AppAncestor => "app-ancestor", - ContextBasis::NoAppAncestor => "no-app-ancestor", - ContextBasis::Global => "global", + ContextBasis::RelayConnection => "relay-connection", + ContextBasis::SshConnection => "ssh-connection", + ContextBasis::SessionBind => "session-bind", + ContextBasis::Forwarding => "forwarding", + ContextBasis::Tainted => "tainted", + ContextBasis::UnboundSsh => "unbound-ssh", + ContextBasis::Workspace => "workspace", + ContextBasis::CwdWorkspace => "cwd-fallback", + ContextBasis::ParentApp => "parent-app", + ContextBasis::NoWorkspaceRoot => "no-workspace-root", ContextBasis::ProcLookupFailed => "proc-lookup-failed", } } @@ -390,15 +407,18 @@ impl ContextBasis { /// know (a newer agent). pub fn from_wire(tag: &str) -> Option { Some(match tag { - "mode-none" => ContextBasis::ModeNone, + "disabled" => ContextBasis::Disabled, "no-peer-pid" => ContextBasis::NoPeerPid, - "vt-relay" => ContextBasis::VtRelay, - "no-tty" => ContextBasis::NoTty, - "ssh-client" => ContextBasis::SshClient, - "session-leader" => ContextBasis::SessionLeader, - "app-ancestor" => ContextBasis::AppAncestor, - "no-app-ancestor" => ContextBasis::NoAppAncestor, - "global" => ContextBasis::Global, + "relay-connection" => ContextBasis::RelayConnection, + "ssh-connection" => ContextBasis::SshConnection, + "session-bind" => ContextBasis::SessionBind, + "forwarding" => ContextBasis::Forwarding, + "tainted" => ContextBasis::Tainted, + "unbound-ssh" => ContextBasis::UnboundSsh, + "workspace" => ContextBasis::Workspace, + "cwd-fallback" => ContextBasis::CwdWorkspace, + "parent-app" => ContextBasis::ParentApp, + "no-workspace-root" => ContextBasis::NoWorkspaceRoot, "proc-lookup-failed" => ContextBasis::ProcLookupFailed, _ => return None, }) @@ -407,28 +427,51 @@ impl ContextBasis { /// Operator-facing explanation shown by `vt doctor`. pub fn human(&self) -> &'static str { match self { - ContextBasis::ModeNone => "caching disabled (cache mode 'none', the default)", + ContextBasis::Disabled => { + "caching disabled (duration 0, the default): every request prompts" + } ContextBasis::NoPeerPid => "agent could not identify the peer process", - ContextBasis::VtRelay => { - "narrowed to this relay connection (grants die with it; other \ + ContextBasis::RelayConnection => { + "confined to this relay connection (grants die with it; other \ connections never share them)" } - ContextBasis::NoTty => { - "no controlling TTY — per-session/per-app never cache for \ - orchestrated callers (CI / AI agents); use cache mode 'global'" + ContextBasis::SshConnection => { + "peer is an ssh process (may carry forwarded remote traffic) — \ + confined to this connection; grants die with it" + } + ContextBasis::SessionBind => { + "destination-bound: reuses one approval per (key, server) \ + across all local callers within the TTL" + } + ContextBasis::Forwarding => { + "connection carries forwarded agent traffic — never cached" + } + ContextBasis::Tainted => { + "a session-bind failed verification on this connection — never \ + cached until reconnect" + } + ContextBasis::UnboundSsh => { + "ssh peer without session-bind (OpenSSH < 8.9?) — cannot \ + distinguish auth from forwarding, never cached" } - ContextBasis::SshClient => { - "peer is an ssh process: narrowed to this ssh connection. \ - One-shot ssh/git spawns never share grants; use ssh \ - ControlMaster to share within the TTL" + ContextBasis::Workspace => { + "scoped to this git workspace: any caller working in the same \ + checkout shares one approval within the TTL" } - ContextBasis::SessionLeader => "cacheable within this terminal session", - ContextBasis::AppAncestor => "cacheable within this .app bundle", - ContextBasis::NoAppAncestor => { - "no .app ancestor (tmux / ssh login session) — per-app cannot \ - cache here" + ContextBasis::CwdWorkspace => { + "no git checkout: scoped to this exact working directory — \ + callers in the same directory share one approval within the \ + TTL" + } + ContextBasis::ParentApp => { + "broad working directory ($HOME, /, temp root): scoped to the \ + calling application — repeated requests from the same app \ + instance share one approval within the TTL" + } + ContextBasis::NoWorkspaceRoot => { + "working directory is too broad to scope and the calling \ + application could not be identified — never cached" } - ContextBasis::Global => "cacheable across ALL local callers (shared global context)", ContextBasis::ProcLookupFailed => "process info lookup failed", } } @@ -445,6 +488,45 @@ pub struct DiagPeerReport { pub is_vt_relay: bool, } +// ---- ui-status@vt (docs/app-bundle.md §5) ----------------------------------- + +pub const UI_STATUS_ACTION_STATUS: &str = "status"; +pub const UI_STATUS_ACTION_REVOKE_ALL: &str = "revoke_all"; + +/// Request: shell → agent for `ui-status@vt`. Plaintext JSON (the shell +/// holds no VT_AUTH), gated by the 32-byte spawn token the shell passed the +/// agent over an inherited pipe at startup. Like `diag@vt` it never resets +/// the idle clock; unlike `diag@vt` a valid token sees the WHOLE grant +/// store — the one deliberate exception to caller-scoped visibility. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct UiStatusReq { + /// base64-url-no-pad of the 32-byte spawn token. + pub token: String, + /// [`UI_STATUS_ACTION_STATUS`] or [`UI_STATUS_ACTION_REVOKE_ALL`] + /// (authority-reducing only — there is deliberately no action that + /// grants, extends, or approves anything). + pub action: String, +} + +/// Response for `ui-status@vt`. No secrets, digests, or key material; +/// grant `display` strings are the same text the Touch ID prompts showed. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct UiStatusRes { + pub agent_version: String, + pub locked: bool, + pub sign_ttl_secs: u64, + pub decrypt_ttl_secs: u64, + /// Idle timeout in seconds — surfaced so the shell can show *why* grants + /// clear after a quiet spell (docs/app-bundle.md §10). Policy, not secret. + pub idle_timeout_secs: u64, + pub run_allow_len: usize, + pub audit_push: bool, + /// Grants dropped, present only in a `revoke_all` reply. + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked: Option, + pub grants: Vec, +} + // ---- v2 URL parsing --------------------------------------------------------- /// Parsed `vt://...` URL. Strict parser (no `url::Url`, no normalization). @@ -761,16 +843,17 @@ mod tests { fn context_basis_wire_round_trip_and_human_are_total() { // Compile-time exhaustiveness lives in the match arms; this pins the // wire tags (a protocol surface) and the from_wire inverse. - const ALL: [ContextBasis; 10] = [ - ContextBasis::ModeNone, + const ALL: [ContextBasis; 11] = [ + ContextBasis::Disabled, ContextBasis::NoPeerPid, - ContextBasis::VtRelay, - ContextBasis::NoTty, - ContextBasis::SshClient, - ContextBasis::SessionLeader, - ContextBasis::AppAncestor, - ContextBasis::NoAppAncestor, - ContextBasis::Global, + ContextBasis::RelayConnection, + ContextBasis::SshConnection, + ContextBasis::SessionBind, + ContextBasis::Forwarding, + ContextBasis::Tainted, + ContextBasis::UnboundSsh, + ContextBasis::Workspace, + ContextBasis::NoWorkspaceRoot, ContextBasis::ProcLookupFailed, ]; for b in ALL { @@ -778,9 +861,13 @@ mod tests { assert!(!b.human().is_empty()); } assert_eq!(ContextBasis::from_wire("future-tag"), None); - assert_eq!(ContextBasis::ModeNone.as_wire(), "mode-none"); - assert_eq!(ContextBasis::VtRelay.as_wire(), "vt-relay"); - assert_eq!(ContextBasis::SshClient.as_wire(), "ssh-client"); + assert_eq!(ContextBasis::Disabled.as_wire(), "disabled"); + assert_eq!(ContextBasis::SessionBind.as_wire(), "session-bind"); + assert_eq!(ContextBasis::Workspace.as_wire(), "workspace"); + assert_eq!( + ContextBasis::RelayConnection.as_wire(), + "relay-connection" + ); } #[test] diff --git a/src/core/authorization.rs b/src/core/authorization.rs new file mode 100644 index 0000000..129fde3 --- /dev/null +++ b/src/core/authorization.rs @@ -0,0 +1,2424 @@ +//! Shared authorization engine used by the macOS SSH-agent transport. +//! +//! The state machine is platform-neutral: callers inject the human authenticator +//! and the real-time session validator. A successful authorization returns a +//! non-cloneable [`AuthorizationPermit`]. Reusable grants are written only when +//! the protected operation succeeds and consumes the permit with `commit()`. + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; + +use async_trait::async_trait; +use sha2::{Digest, Sha256}; +use tokio::sync::{watch, OwnedRwLockReadGuard, OwnedSemaphorePermit, RwLock, Semaphore}; + +use super::session::{AuthMethod, AuthOutcome, UnavailableReason}; + +/// Kernel-derived caller anchor: `(context_id, context_start_tvsec)`. +pub type SubjectId = (u64, u64); + +/// Security operation authorized by a grant. This discriminator is part of +/// every reusable key, so grants can never cross operation boundaries. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Operation { + Auth, + Run, + Sign, + Decrypt, +} + +impl Operation { + /// Stable tag for `ui-status@vt` snapshots. One-way, never parsed back. + pub fn as_wire(self) -> &'static str { + match self { + Operation::Auth => "auth", + Operation::Run => "run", + Operation::Sign => "sign", + Operation::Decrypt => "decrypt", + } + } +} + +/// Which scope family a grant belongs to. Redundant with the digest's domain +/// label for matching (the digest already separates families), but carried +/// explicitly so diag counting can filter by family: workspace and +/// cwd-fallback grants can share a `(dev, ino)` subject when a directory +/// gains or loses a `.git` entry, and a caller must never count the other +/// family's grants as reusable. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ScopeFamily { + /// Per-connection confinement (relay / plain-ssh peers). + Connection, + /// Session-bind-verified destination host key. + Destination, + /// Kernel-derived `.git` workspace root. + Workspace, + /// No `.git` root: the kernel-derived cwd itself. + CwdFallback, + /// Broad shared cwd ($HOME, `/`, temp roots): the kernel-derived parent + /// process (application) of the caller. + ParentApp, +} + +impl ScopeFamily { + /// Stable tag for audit telemetry (docs/approval-transparency.md §B). + /// One-way: never parsed back, so — unlike `ContextBasis` — no + /// `from_wire` counterpart exists. + pub fn as_wire(self) -> &'static str { + match self { + ScopeFamily::Connection => "connection", + ScopeFamily::Destination => "destination", + ScopeFamily::Workspace => "workspace", + ScopeFamily::CwdFallback => "cwd-fallback", + ScopeFamily::ParentApp => "parent-app", + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct GrantKey { + operation: Operation, + family: ScopeFamily, + subject: SubjectId, + digest: [u8; 32], +} + +/// One atomic authorization scope. A request may contain several scopes; +/// decrypt batches use one scope per v2 record and require an all-of hit. +#[derive(Clone, Debug)] +pub struct GrantScope { + operation: Operation, + key: Option, + /// Human label of the scoped resource (destination host, workspace root, + /// parent app) — the same string the Touch ID reuse line shows. Never + /// hashed; carried into the grant store for `ui-status@vt` snapshots and + /// cache-hit notifications (docs/app-bundle.md §5). Memory-only. + display: String, +} + +/// Subject for destination-bound sign grants. They are deliberately +/// user-wide (any local caller could drive the real ssh to the same +/// destination anyway); the destination host key lives in the resource +/// digest. It cannot merge with a kernel `(pid, start_tvsec)` or workspace +/// `(dev, ino)` subject because every scope family uses a distinct digest +/// domain label — a subject collision alone never merges grants. +pub const DESTINATION_SUBJECT: SubjectId = (0, 0); + +impl GrantScope { + /// An explicitly non-reusable scope. `auth@vt`, `run@vt`, legacy decrypt, + /// and callers without a resolvable subject use this constructor. + pub fn fresh(operation: Operation) -> Self { + Self { + operation, + key: None, + display: String::new(), + } + } + + /// Attach the human scope label (the reuse-line string). Display-only: + /// never part of the digest or the lookup key. + pub fn with_display(mut self, display: impl Into) -> Self { + self.display = display.into(); + self + } + + /// Relay sign scope: fingerprint + claimed working directory, bounded by + /// the relay connection's kernel-derived `(pid, start_tvsec)` subject. + pub fn sign(subject: Option, fingerprint: &str, pwd: &str) -> Self { + let Some(subject) = subject else { + return Self::fresh(Operation::Sign); + }; + let mut h = Sha256::new(); + h.update(b"vt-authorization-sign-v1"); + hash_field(&mut h, fingerprint.as_bytes()); + hash_field(&mut h, pwd.as_bytes()); + Self::reusable( + Operation::Sign, + ScopeFamily::Connection, + subject, + h.finalize().into(), + ) + } + + /// Destination-bound sign scope: the connection proved its destination + /// with a verified `session-bind@openssh.com`. `hostkey_wire` must be the + /// exact wire-encoded `KeyData` bytes of the destination host key — + /// string fingerprints are display-only and never hashed here. + pub fn sign_destination(hostkey_wire: &[u8], fingerprint: &str) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-sign-dest-v1"); + hash_field(&mut h, hostkey_wire); + hash_field(&mut h, fingerprint.as_bytes()); + Self::reusable( + Operation::Sign, + ScopeFamily::Destination, + DESTINATION_SUBJECT, + h.finalize().into(), + ) + } + + /// Workspace-bound sign scope (local `sign@vt` and unbound non-ssh raw + /// signers such as `ssh-keygen -Y sign`). `subject` is the workspace + /// root's `(st_dev, st_ino)`; `root_path` is the canonical root path + /// captured from the same file descriptor, bound into the digest so a + /// recycled inode under a different path cannot match. A resolved + /// workspace always has a subject, so unlike the relay constructors + /// there is no `Option` fallback. + pub fn sign_workspace(subject: SubjectId, root_path: &str, fingerprint: &str) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-sign-ws-v1"); + hash_field(&mut h, root_path.as_bytes()); + hash_field(&mut h, fingerprint.as_bytes()); + Self::reusable( + Operation::Sign, + ScopeFamily::Workspace, + subject, + h.finalize().into(), + ) + } + + /// Cwd-fallback sign scope: the peer's kernel cwd has no `.git` ancestor, + /// so the cwd directory itself is the activity boundary. Same fd-derived + /// `(dev, ino)` + canonical-path binding as [`Self::sign_workspace`], but + /// a distinct digest domain: a directory that later gains a `.git` must + /// start a new grant family, never silently continue this one. + pub fn sign_cwd(subject: SubjectId, root_path: &str, fingerprint: &str) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-sign-cwd-v1"); + hash_field(&mut h, root_path.as_bytes()); + hash_field(&mut h, fingerprint.as_bytes()); + Self::reusable( + Operation::Sign, + ScopeFamily::CwdFallback, + subject, + h.finalize().into(), + ) + } + + /// Workspace-bound decrypt scope for local pure-v2 batches. Same subject + /// and path-binding rules as [`Self::sign_workspace`]. + pub fn decrypt_workspace( + subject: SubjectId, + root_path: &str, + secret_type: u8, + salt: &[u8], + ) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-decrypt-ws-v1"); + hash_field(&mut h, root_path.as_bytes()); + h.update([secret_type]); + hash_field(&mut h, salt); + Self::reusable( + Operation::Decrypt, + ScopeFamily::Workspace, + subject, + h.finalize().into(), + ) + } + + /// Parent-app sign scope: the caller's kernel cwd is a broad shared + /// directory ($HOME, `/`, a temp root), so the activity is identified by + /// the caller's kernel-derived parent process instead — "this application + /// instance keeps making the same request". `subject` is the parent's + /// `(pid, start_tvsec)` (grants die with the parent); the digest binds + /// the parent's kernel-verified executable path, never the + /// client-claimed `ppid_cmd`. + pub fn sign_app(subject: SubjectId, parent_exe: &str, fingerprint: &str) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-sign-app-v1"); + hash_field(&mut h, parent_exe.as_bytes()); + hash_field(&mut h, fingerprint.as_bytes()); + Self::reusable( + Operation::Sign, + ScopeFamily::ParentApp, + subject, + h.finalize().into(), + ) + } + + /// Parent-app decrypt scope. Same rules as [`Self::sign_app`]. + pub fn decrypt_app( + subject: SubjectId, + parent_exe: &str, + secret_type: u8, + salt: &[u8], + ) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-decrypt-app-v1"); + hash_field(&mut h, parent_exe.as_bytes()); + h.update([secret_type]); + hash_field(&mut h, salt); + Self::reusable( + Operation::Decrypt, + ScopeFamily::ParentApp, + subject, + h.finalize().into(), + ) + } + + /// Cwd-fallback decrypt scope. Same rules as [`Self::sign_cwd`]. + pub fn decrypt_cwd( + subject: SubjectId, + root_path: &str, + secret_type: u8, + salt: &[u8], + ) -> Self { + let mut h = Sha256::new(); + h.update(b"vt-authz-decrypt-cwd-v1"); + hash_field(&mut h, root_path.as_bytes()); + h.update([secret_type]); + hash_field(&mut h, salt); + Self::reusable( + Operation::Decrypt, + ScopeFamily::CwdFallback, + subject, + h.finalize().into(), + ) + } + + /// Relay decrypt scope: type + salt + claimed host/pwd, bounded by the + /// relay connection's kernel-derived subject. + pub fn decrypt_v2( + subject: Option, + secret_type: u8, + salt: &[u8], + host: &str, + pwd: &str, + ) -> Self { + let Some(subject) = subject else { + return Self::fresh(Operation::Decrypt); + }; + let mut h = Sha256::new(); + h.update(b"vt-authorization-decrypt-v1"); + h.update([secret_type]); + hash_field(&mut h, salt); + hash_field(&mut h, host.as_bytes()); + hash_field(&mut h, pwd.as_bytes()); + Self::reusable( + Operation::Decrypt, + ScopeFamily::Connection, + subject, + h.finalize().into(), + ) + } + + fn reusable( + operation: Operation, + family: ScopeFamily, + subject: SubjectId, + digest: [u8; 32], + ) -> Self { + Self { + operation, + key: Some(GrantKey { + operation, + family, + subject, + digest, + }), + display: String::new(), + } + } + + /// True when an approval under this scope can create a standing grant — + /// the condition under which the prompt must carry a reuse line + /// (docs/authorization-scopes-v2.md §6). Currently exercised only by the + /// invariant tests. + #[cfg(test)] + pub fn is_reusable(&self) -> bool { + self.key.is_some() + } + + /// The scope family when this scope can mint a grant; `None` for fresh. + /// Feeds the audit rows' `scope_family` field. + pub fn family(&self) -> Option { + self.key.as_ref().map(|k| k.family) + } +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReusePolicy { + /// Authenticate every request and never read or write a grant. + Fresh, + /// Reuse grants until either clock reaches the fixed deadline. Hits and + /// repeated approvals never slide an existing live deadline. + StrictTtl(Duration), +} + +impl ReusePolicy { + pub fn strict_ttl_secs(seconds: u64) -> Self { + Self::StrictTtl(Duration::from_secs(seconds)) + } + + /// Map a configured cache duration to a policy: `0` selects the + /// first-class `Fresh` policy (never reads or writes grants) — NOT + /// `StrictTtl(0)`. Lives on the type so every engine consumer inherits + /// the rule. + pub fn from_ttl_secs(seconds: u64) -> Self { + if seconds == 0 { + Self::Fresh + } else { + Self::strict_ttl_secs(seconds) + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Decision { + CacheHit, + Approved(AuthMethod), + Rejected, + Unavailable(UnavailableReason), + /// The authorization epoch or real-time security state invalidated the + /// request. Extension handlers map this retryable condition to Transient. + Invalidated, +} + +impl Decision { + pub fn audit_outcome(self) -> &'static str { + match self { + Decision::CacheHit => "cache_hit", + Decision::Approved(_) => "approved", + Decision::Rejected => "rejected", + Decision::Unavailable(_) | Decision::Invalidated => "unavailable", + } + } +} + +pub struct AuthorizationRequest { + scopes: Vec, + reuse: ReusePolicy, + prompt: String, +} + +impl AuthorizationRequest { + pub fn new(scopes: Vec, reuse: ReusePolicy, prompt: impl Into) -> Self { + Self { + scopes, + reuse, + prompt: prompt.into(), + } + } + + pub fn fresh(scope: GrantScope, prompt: impl Into) -> Self { + Self::new(vec![scope], ReusePolicy::Fresh, prompt) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ValidationError { + Unavailable(UnavailableReason), + Invalidated, +} + +#[async_trait] +pub trait AuthorizationAuthenticator: Send + Sync { + async fn authenticate(&self, prompt: &str, revocation_pending: Arc) -> AuthOutcome; +} + +/// Must query current state rather than a TTL-cached snapshot. The engine calls +/// it immediately before returning a cache hit and after a fresh prompt. A +/// validator that observes an unsafe state must publish `revocation_pending` +/// before returning an error, closing the window for concurrent readers. +pub trait AuthorizationValidator: Send + Sync { + fn validate(&self, revocation_pending: &AtomicBool) -> Result<(), ValidationError>; + + /// Called under the security write gate after grants are cleared. Stateful + /// validators can reset wake/session baselines before the pending latch is + /// released. + fn invalidation_complete(&self) {} +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CacheExpiry { + // `Instant` bounds awake time; `SystemTime` keeps expiring through sleep. + // Requiring both also prevents a backwards wall-clock step from extending + // the grant indefinitely. + mono: Instant, + wall: SystemTime, + /// Policy under which this grant was issued. A request may reuse an entry + /// only when it asks for an equal or wider TTL; policy tightening must + /// require a fresh approval. + ttl: Duration, +} + +impl CacheExpiry { + fn checked( + ttl: Duration, + approved_mono: Instant, + approved_wall: SystemTime, + ) -> Result { + Ok(Self { + mono: approved_mono + .checked_add(ttl) + .ok_or(CommitError::InvalidTtl)?, + wall: approved_wall + .checked_add(ttl) + .ok_or(CommitError::InvalidTtl)?, + ttl, + }) + } + + fn is_valid_at(self, now_mono: Instant, now_wall: SystemTime) -> bool { + now_mono < self.mono && now_wall < self.wall + } + + /// Time left before the earlier of the two deadlines; zero once either + /// clock has passed. Display-only companion to [`Self::is_valid_at`]. + fn remaining_at(self, now_mono: Instant, now_wall: SystemTime) -> Duration { + let mono_left = self.mono.saturating_duration_since(now_mono); + let wall_left = self + .wall + .duration_since(now_wall) + .unwrap_or(Duration::ZERO); + mono_left.min(wall_left) + } +} + +/// Stored value per live grant: the dual-clock expiry plus the human scope +/// label (display-only; feeds `ui-status@vt` snapshots). +#[derive(Clone, Debug)] +struct GrantEntry { + expiry: CacheExpiry, + display: String, +} + +/// A reusable key paired with its scope's display label — what `authorize` +/// carries from request scopes into lookups and pending commits. +#[derive(Clone, Debug)] +struct KeyedScope { + key: GrantKey, + display: String, +} + +/// One live grant as reported to the token-gated `ui-status@vt` channel +/// (docs/app-bundle.md §5) — the deliberate whole-store exception to +/// diag@vt's caller-scoped counts. No digests, subjects, or key material; +/// `display` is the same string the approval prompt showed. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct GrantSnapshot { + /// Stable operation tag: "sign" | "decrypt" | "auth" | "run". + pub operation: String, + /// `ScopeFamily::as_wire` tag. + pub family: String, + pub display: String, + pub remaining_secs: u64, + pub ttl_secs: u64, +} + +#[derive(Debug)] +struct GrantStore { + entries: HashMap, + epoch: u64, +} + +impl GrantStore { + fn new() -> Self { + Self { + entries: HashMap::new(), + epoch: 0, + } + } + + fn lookup_at( + &self, + keys: &[KeyedScope], + requested_ttl: Duration, + now_mono: Instant, + now_wall: SystemTime, + ) -> Lookup { + let all_hit = keys.iter().all(|scoped| { + self.entries.get(&scoped.key).is_some_and(|entry| { + entry.expiry.is_valid_at(now_mono, now_wall) && entry.expiry.ttl <= requested_ttl + }) + }); + // Tightest remaining lifetime across the hit set — informational only + // (cache-hit notifications / ui-status); never feeds a reuse decision. + let remaining = if all_hit { + keys.iter() + .filter_map(|scoped| self.entries.get(&scoped.key)) + .map(|entry| entry.expiry.remaining_at(now_mono, now_wall)) + .min() + } else { + None + }; + Lookup { + epoch: self.epoch, + all_hit, + remaining, + } + } + + fn commit_at( + &mut self, + expected_epoch: u64, + keys: &[KeyedScope], + ttl: Duration, + approved_mono: Instant, + approved_wall: SystemTime, + ) -> Result { + if self.epoch != expected_epoch { + return Err(CommitError::Invalidated); + } + let fresh = CacheExpiry::checked(ttl, approved_mono, approved_wall)?; + let mut inserted = 0; + for scoped in keys { + self.entries + .entry(scoped.key.clone()) + .and_modify(|entry| { + if !entry.expiry.is_valid_at(approved_mono, approved_wall) + || entry.expiry.ttl > ttl + { + entry.expiry = fresh; + entry.display = scoped.display.clone(); + inserted += 1; + } + }) + .or_insert_with(|| { + inserted += 1; + GrantEntry { + expiry: fresh, + display: scoped.display.clone(), + } + }); + } + Ok(inserted) + } + + fn invalidate_all(&mut self) -> usize { + let dropped = self.entries.len(); + self.entries.clear(); + self.epoch = self + .epoch + .checked_add(1) + .expect("authorization epoch exhausted"); + dropped + } + + fn sweep_expired_at(&mut self, now_mono: Instant, now_wall: SystemTime) { + self.entries + .retain(|_, entry| entry.expiry.is_valid_at(now_mono, now_wall)); + } + + fn live_len_at( + &self, + operation: Operation, + family: ScopeFamily, + subject: SubjectId, + now_mono: Instant, + now_wall: SystemTime, + ) -> usize { + self.entries + .iter() + .filter(|(key, entry)| { + key.operation == operation + && key.family == family + && key.subject == subject + && entry.expiry.is_valid_at(now_mono, now_wall) + }) + .count() + } + + /// Whole-store enumeration for the token-gated `ui-status@vt` channel + /// ONLY (docs/app-bundle.md §5). Sorted for stable UI ordering. + fn snapshot_at(&self, now_mono: Instant, now_wall: SystemTime) -> Vec { + let mut grants: Vec = self + .entries + .iter() + .filter(|(_, entry)| entry.expiry.is_valid_at(now_mono, now_wall)) + .map(|(key, entry)| GrantSnapshot { + operation: key.operation.as_wire().to_string(), + family: key.family.as_wire().to_string(), + display: entry.display.clone(), + remaining_secs: entry.expiry.remaining_at(now_mono, now_wall).as_secs(), + ttl_secs: entry.expiry.ttl.as_secs(), + }) + .collect(); + grants.sort_by(|a, b| { + (&a.operation, &a.family, &a.display).cmp(&(&b.operation, &b.family, &b.display)) + }); + grants + } +} + +#[derive(Clone, Copy, Debug)] +struct Lookup { + epoch: u64, + all_hit: bool, + /// Tightest remaining lifetime across the hit set when `all_hit`. + remaining: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommitError { + Invalidated, + InvalidTtl, +} + +struct PendingGrant { + expected_epoch: u64, + keys: Vec, + ttl: Duration, + approved_mono: Instant, + approved_wall: SystemTime, +} + +/// Capability returned by a successful authorization. It deliberately does +/// not implement Clone. Dropping it releases the security gate and prompt +/// permit without writing a pending grant. +pub struct AuthorizationPermit { + decision: Decision, + latency_ms: u64, + /// Tightest remaining grant lifetime when `decision` is `CacheHit`. + /// Informational only (cache-hit notifications); never a reuse input. + reuse_remaining: Option, + pending: Option, + store: Arc>, + _security: OwnedRwLockReadGuard<()>, + _prompt: Option, +} + +impl AuthorizationPermit { + pub fn decision(&self) -> Decision { + self.decision + } + + pub fn reuse_remaining(&self) -> Option { + self.reuse_remaining + } + + pub fn latency_ms(&self) -> u64 { + self.latency_ms + } + + /// Commit the pending reusable grant after the protected operation has + /// completed successfully. Cache hits and Fresh policies have no pending + /// write but still consume the permit to release its gates explicitly. + pub async fn commit(mut self) -> Result<(), CommitError> { + if let Some(pending) = self.pending.take() { + self.store.write().await.commit_at( + pending.expected_epoch, + &pending.keys, + pending.ttl, + pending.approved_mono, + pending.approved_wall, + )?; + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AuthorizationFailure { + decision: Decision, + latency_ms: u64, +} + +impl AuthorizationFailure { + pub fn decision(&self) -> Decision { + self.decision + } + + pub fn latency_ms(&self) -> u64 { + self.latency_ms + } +} + +pub struct AuthorizationEngine { + store: Arc>, + security_gate: Arc>, + prompt_sem: Arc, + revocation_pending: Arc, + revoker_running: AtomicBool, + revocation_generation: AtomicU64, + revocation_completed: watch::Sender, + authenticator: Arc, + validator: Arc, +} + +impl AuthorizationEngine { + pub fn new( + authenticator: Arc, + validator: Arc, + ) -> Arc { + let (revocation_completed, _) = watch::channel(0); + Arc::new(Self { + store: Arc::new(RwLock::new(GrantStore::new())), + security_gate: Arc::new(RwLock::new(())), + prompt_sem: Arc::new(Semaphore::new(1)), + revocation_pending: Arc::new(AtomicBool::new(false)), + revoker_running: AtomicBool::new(false), + revocation_generation: AtomicU64::new(0), + revocation_completed, + authenticator, + validator, + }) + } + + pub async fn authorize( + self: &Arc, + request: AuthorizationRequest, + ) -> Result { + let started = Instant::now(); + if let ReusePolicy::StrictTtl(ttl) = request.reuse { + // Reject hostile or corrupt configuration before a cache lookup, + // human prompt, or protected operation. `commit_at` repeats this + // check defensively because it is also exercised independently. + if CacheExpiry::checked(ttl, Instant::now(), SystemTime::now()).is_err() { + return Err(failure(Decision::Invalidated, started)); + } + } + let Some(operation) = request.scopes.first().map(|scope| scope.operation) else { + return Err(failure(Decision::Invalidated, started)); + }; + if request + .scopes + .iter() + .any(|scope| scope.operation != operation) + { + return Err(failure(Decision::Invalidated, started)); + } + let mut subjects = request + .scopes + .iter() + .filter_map(|scope| scope.key.as_ref().map(|key| key.subject)); + if let Some(subject) = subjects.next() { + if subjects.any(|candidate| candidate != subject) { + return Err(failure(Decision::Invalidated, started)); + } + } + + let keys = reusable_keys(&request.scopes, request.reuse); + let reusable_ttl = match request.reuse { + ReusePolicy::StrictTtl(ttl) => Some(ttl), + ReusePolicy::Fresh => None, + }; + if let (Some(keys), Some(ttl)) = (keys.as_deref(), reusable_ttl) { + let security = Arc::clone(&self.security_gate).read_owned().await; + let lookup = self.lookup(keys, ttl).await; + if lookup.all_hit { + if let Err(error) = self.validate_live() { + drop(security); + self.revoke_after_validation_failure().await; + return Err(failure(validation_decision(error), started)); + } + return Ok(AuthorizationPermit { + decision: Decision::CacheHit, + latency_ms: 0, + reuse_remaining: lookup.remaining, + pending: None, + store: Arc::clone(&self.store), + _security: security, + _prompt: None, + }); + } + } + + let prompt = match Arc::clone(&self.prompt_sem).acquire_owned().await { + Ok(permit) => permit, + Err(_) => { + return Err(failure( + Decision::Unavailable(UnavailableReason::NotInteractive), + started, + )) + } + }; + // Recheck under a temporary security read gate. It is deliberately + // dropped before displaying a prompt: invalidation must be able to + // acquire the write gate and revoke an in-flight prompt via epoch. + let epoch = { + let security = Arc::clone(&self.security_gate).read_owned().await; + if let Err(error) = self.validate_live() { + drop(security); + self.revoke_after_validation_failure().await; + return Err(failure(validation_decision(error), started)); + } + if let (Some(keys), Some(ttl)) = (keys.as_deref(), reusable_ttl) { + let lookup = self.lookup(keys, ttl).await; + if lookup.all_hit { + if let Err(error) = self.validate_live() { + drop(security); + drop(prompt); + self.revoke_after_validation_failure().await; + return Err(failure(validation_decision(error), started)); + } + drop(prompt); + return Ok(AuthorizationPermit { + decision: Decision::CacheHit, + latency_ms: 0, + reuse_remaining: lookup.remaining, + pending: None, + store: Arc::clone(&self.store), + _security: security, + _prompt: None, + }); + } + lookup.epoch + } else { + self.store.read().await.epoch + } + }; + + // The task owns the semaphore permit while the platform prompt is + // active. Dropping/cancelling the caller's future detaches this task, + // so a blocking system prompt cannot outlive the serialization guard + // and overlap a later request. + let authenticator = Arc::clone(&self.authenticator); + let revocation_pending = Arc::clone(&self.revocation_pending); + let prompt_engine = Arc::clone(self); + let prompt_task = tokio::spawn(async move { + let outcome = authenticator + .authenticate(&request.prompt, Arc::clone(&revocation_pending)) + .await; + if matches!(outcome, AuthOutcome::Unavailable(_)) { + // Defensive fallback for authenticators that did not publish + // the latch at the point where unavailability was observed. + revocation_pending.store(true, Ordering::Release); + // This worker owns the prompt independently of the requesting + // connection. Drain here as well so a caller cancelled while + // the system dialog is open cannot leave old grants standing. + prompt_engine.drain_pending_revocation().await; + } + (outcome, prompt) + }); + let (outcome, prompt) = match prompt_task.await { + Ok(result) => result, + Err(_) => { + // Treat an authenticator task failure as an unsafe prompt + // failure too. The task normally converts platform errors to + // `Unavailable`, but this keeps custom adapters fail-closed. + self.invalidate_all().await; + return Err(failure( + Decision::Unavailable(UnavailableReason::NotInteractive), + started, + )); + } + }; + let method = match outcome { + AuthOutcome::Success(method) => method, + AuthOutcome::Rejected => return Err(failure(Decision::Rejected, started)), + AuthOutcome::Unavailable(reason) => { + return Err(failure(Decision::Unavailable(reason), started)) + } + }; + + // The returned permit owns this read gate through the protected + // operation. If invalidation won the write gate during the prompt, the + // epoch comparison below observes it and the operation never starts. + let security = Arc::clone(&self.security_gate).read_owned().await; + if self.store.read().await.epoch != epoch { + return Err(failure(Decision::Invalidated, started)); + } + if let Err(error) = self.validate_live() { + drop(security); + drop(prompt); + self.revoke_after_validation_failure().await; + return Err(failure(validation_decision(error), started)); + } + + let approved_mono = Instant::now(); + let approved_wall = SystemTime::now(); + let pending = match (keys, request.reuse) { + (Some(keys), ReusePolicy::StrictTtl(ttl)) if method.is_cacheable() => { + Some(PendingGrant { + expected_epoch: epoch, + keys, + ttl, + approved_mono, + approved_wall, + }) + } + _ => None, + }; + Ok(AuthorizationPermit { + decision: Decision::Approved(method), + latency_ms: started.elapsed().as_millis() as u64, + reuse_remaining: None, + pending, + store: Arc::clone(&self.store), + _security: security, + _prompt: Some(prompt), + }) + } + + /// Linearized, cancellation-safe invalidation. The first caller starts one + /// detached revoker; concurrent callers wait for it instead of queueing a + /// writer per failed request. The pending latch blocks new permits before + /// the writer can acquire the security gate. + pub async fn invalidate_all(self: &Arc) -> usize { + self.revocation_pending.store(true, Ordering::Release); + self.drain_pending_revocation().await + } + + async fn drain_pending_revocation(self: &Arc) -> usize { + let mut completed = self.revocation_completed.subscribe(); + loop { + let pending = self.revocation_pending.load(Ordering::Acquire); + let running = self.revoker_running.load(Ordering::Acquire); + if !pending && !running { + return 0; + } + if pending + && self + .revoker_running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let engine = Arc::clone(self); + let revoker = tokio::spawn(async move { engine.run_revoker().await }); + return revoker.await.unwrap_or(0); + } + + // Subscribe before inspecting state (above), so a completion + // between the inspection and this await remains visible. After + // every wake, retry the claim: separate pending/running atomics + // can transiently leave an orphaned pending state during revoker + // hand-off, and a waiter must be able to recover it. + if completed.changed().await.is_err() { + return 0; + } + } + } + + pub async fn sweep_expired(&self) { + self.store + .write() + .await + .sweep_expired_at(Instant::now(), SystemTime::now()); + } + + pub async fn live_len( + &self, + operation: Operation, + family: ScopeFamily, + subject: SubjectId, + ) -> usize { + self.store.read().await.live_len_at( + operation, + family, + subject, + Instant::now(), + SystemTime::now(), + ) + } + + /// Whole-store grant enumeration. ONLY for the token-gated `ui-status@vt` + /// handler (docs/app-bundle.md §5) — every other read surface stays + /// caller-scoped (`live_len`). Read-lock only; never sweeps or mutates. + pub async fn snapshot(&self) -> Vec { + self.store + .read() + .await + .snapshot_at(Instant::now(), SystemTime::now()) + } + + async fn lookup(&self, keys: &[KeyedScope], requested_ttl: Duration) -> Lookup { + self.store + .read() + .await + .lookup_at(keys, requested_ttl, Instant::now(), SystemTime::now()) + } + + /// Once a live validator observes an unsafe state, old grants must not + /// become usable again merely because the state later returns to normal. + /// Spawn first so cancellation of the requesting connection cannot cancel + /// the revocation after the observation has already been made. + async fn revoke_after_validation_failure(self: &Arc) { + // The validator (or validate_live's defensive fallback) published the + // latch synchronously. Do not reassert it here: a concurrent revoker + // may already have covered this observation before this task resumes. + self.drain_pending_revocation().await; + } + + fn validate_live(&self) -> Result<(), ValidationError> { + if self.revocation_pending.load(Ordering::Acquire) { + return Err(ValidationError::Invalidated); + } + match self.validator.validate(&self.revocation_pending) { + Err(error) => { + // Defensive publication for validators that did not follow the + // trait contract. Platform validators publish before return. + self.revocation_pending.store(true, Ordering::Release); + Err(error) + } + Ok(()) if self.revocation_pending.load(Ordering::Acquire) => { + Err(ValidationError::Invalidated) + } + Ok(()) => Ok(()), + } + } + + async fn run_revoker(self: Arc) -> usize { + let mut dropped_total = 0usize; + loop { + let security = Arc::clone(&self.security_gate).write_owned().await; + if self.revocation_pending.load(Ordering::Acquire) { + dropped_total += self.store.write().await.invalidate_all(); + self.validator.invalidation_complete(); + self.revocation_pending.store(false, Ordering::Release); + } + drop(security); + + self.revoker_running.store(false, Ordering::Release); + if self.revocation_pending.load(Ordering::Acquire) + && self + .revoker_running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + continue; + } + + let generation = self + .revocation_generation + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + self.revocation_completed.send_replace(generation); + return dropped_total; + } + } +} + +fn reusable_keys(scopes: &[GrantScope], policy: ReusePolicy) -> Option> { + if !matches!(policy, ReusePolicy::StrictTtl(_)) { + return None; + } + let mut unique = HashSet::with_capacity(scopes.len()); + let mut keys = Vec::with_capacity(scopes.len()); + for scope in scopes { + let key = scope.key.as_ref()?; + if unique.insert(key.clone()) { + keys.push(KeyedScope { + key: key.clone(), + display: scope.display.clone(), + }); + } + } + (!keys.is_empty()).then_some(keys) +} + +fn validation_decision(error: ValidationError) -> Decision { + match error { + ValidationError::Unavailable(reason) => Decision::Unavailable(reason), + ValidationError::Invalidated => Decision::Invalidated, + } +} + +fn failure(decision: Decision, started: Instant) -> AuthorizationFailure { + AuthorizationFailure { + decision, + latency_ms: started.elapsed().as_millis() as u64, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Barrier; + use tokio::sync::Notify; + + struct AllowValidator { + allowed: AtomicBool, + } + + impl AllowValidator { + fn allowed() -> Arc { + Arc::new(Self { + allowed: AtomicBool::new(true), + }) + } + } + + impl AuthorizationValidator for AllowValidator { + fn validate(&self, revocation_pending: &AtomicBool) -> Result<(), ValidationError> { + if self.allowed.load(Ordering::Acquire) { + Ok(()) + } else { + revocation_pending.store(true, Ordering::Release); + Err(ValidationError::Invalidated) + } + } + } + + struct SuccessAuthenticator { + calls: AtomicUsize, + } + + struct FixedAuthenticator { + calls: AtomicUsize, + outcome: AuthOutcome, + } + + #[async_trait] + impl AuthorizationAuthenticator for FixedAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + revocation_pending: Arc, + ) -> AuthOutcome { + self.calls.fetch_add(1, Ordering::AcqRel); + if matches!(self.outcome, AuthOutcome::Unavailable(_)) { + revocation_pending.store(true, Ordering::Release); + } + self.outcome + } + } + + impl SuccessAuthenticator { + fn new() -> Arc { + Arc::new(Self { + calls: AtomicUsize::new(0), + }) + } + } + + #[async_trait] + impl AuthorizationAuthenticator for SuccessAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + _revocation_pending: Arc, + ) -> AuthOutcome { + self.calls.fetch_add(1, Ordering::AcqRel); + AuthOutcome::Success(AuthMethod::Biometric) + } + } + + fn sign_scope(subject: SubjectId, fingerprint: &str) -> GrantScope { + GrantScope::sign(Some(subject), fingerprint, "/repo") + } + + fn sign_request(subject: SubjectId, fingerprint: &str) -> AuthorizationRequest { + AuthorizationRequest::new( + vec![sign_scope(subject, fingerprint)], + ReusePolicy::strict_ttl_secs(120), + "sign", + ) + } + + /// Direct-store tests address entries as `KeyedScope` (key + display), + /// mirroring what `reusable_keys` hands the store. + fn keyed(scope: GrantScope) -> KeyedScope { + KeyedScope { + key: scope.key.unwrap(), + display: scope.display, + } + } + + #[test] + fn strict_ttl_requires_both_clocks() { + let key = keyed(sign_scope((1, 2), "fp")); + let mut store = GrantStore::new(); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + store + .commit_at( + 0, + std::slice::from_ref(&key), + Duration::from_secs(120), + m0, + w0, + ) + .unwrap(); + assert!( + store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(120), + m0 + Duration::from_secs(60), + w0 + Duration::from_secs(60) + ) + .all_hit + ); + assert!( + !store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(120), + m0 + Duration::from_secs(60), + w0 + Duration::from_secs(121) + ) + .all_hit + ); + assert!( + !store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(120), + m0 + Duration::from_secs(121), + w0 + Duration::from_secs(60) + ) + .all_hit + ); + } + + #[test] + fn strict_ttl_does_not_slide() { + let key = keyed(sign_scope((1, 2), "fp")); + let mut store = GrantStore::new(); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + store + .commit_at( + 0, + std::slice::from_ref(&key), + Duration::from_secs(120), + m0, + w0, + ) + .unwrap(); + store + .commit_at( + 0, + std::slice::from_ref(&key), + Duration::from_secs(120), + m0 + Duration::from_secs(60), + w0 + Duration::from_secs(60), + ) + .unwrap(); + assert!( + !store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(120), + m0 + Duration::from_secs(121), + w0 + Duration::from_secs(121) + ) + .all_hit + ); + } + + #[test] + fn shorter_policy_replaces_a_live_wider_ttl_without_sliding() { + let key = keyed(sign_scope((1, 2), "fp")); + let mut store = GrantStore::new(); + let m0 = Instant::now(); + let w0 = SystemTime::now(); + store + .commit_at( + 0, + std::slice::from_ref(&key), + Duration::from_secs(120), + m0, + w0, + ) + .unwrap(); + assert!( + !store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(30), + m0 + Duration::from_secs(1), + w0 + Duration::from_secs(1), + ) + .all_hit + ); + + store + .commit_at( + 0, + std::slice::from_ref(&key), + Duration::from_secs(30), + m0 + Duration::from_secs(1), + w0 + Duration::from_secs(1), + ) + .unwrap(); + assert!( + store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(30), + m0 + Duration::from_secs(30), + w0 + Duration::from_secs(30), + ) + .all_hit + ); + assert!( + !store + .lookup_at( + std::slice::from_ref(&key), + Duration::from_secs(30), + m0 + Duration::from_secs(32), + w0 + Duration::from_secs(32), + ) + .all_hit + ); + } + + #[test] + fn scope_families_are_domain_separated() { + // The same source material must never produce the same grant key + // across scope families: a subject collision (e.g. workspace + // (dev, ino) numerically equal to a relay (pid, tvsec)) is harmless + // only because the digest domain labels differ. + let subject = (7, 42); + let relay_sign = GrantScope::sign(Some(subject), "fp", "/repo").key.unwrap(); + let ws_sign = GrantScope::sign_workspace(subject, "/repo", "fp") + .key + .unwrap(); + let dest_sign = GrantScope::sign_destination(b"hostkey-wire", "fp") + .key + .unwrap(); + assert_ne!(relay_sign, ws_sign); + assert_ne!(relay_sign, dest_sign); + assert_ne!(ws_sign, dest_sign); + + // Cwd-fallback is its own family: the SAME directory (same subject, + // same canonical path) must never match a git-workspace grant, so a + // later `git init` (or `.git` removal) starts a new family instead + // of silently continuing the old grants. + let cwd_sign = GrantScope::sign_cwd(subject, "/repo", "fp").key.unwrap(); + assert_ne!(cwd_sign, ws_sign); + assert_ne!(cwd_sign, relay_sign); + assert_ne!(cwd_sign, dest_sign); + + // Parent-app is its own family too (and partitions on the exe path). + let app_sign = GrantScope::sign_app(subject, "/repo", "fp").key.unwrap(); + assert_ne!(app_sign, ws_sign); + assert_ne!(app_sign, cwd_sign); + assert_ne!(app_sign, relay_sign); + assert_ne!( + GrantScope::sign_app(subject, "/App/A", "fp").key.unwrap(), + GrantScope::sign_app(subject, "/App/B", "fp").key.unwrap() + ); + + let relay_dec = GrantScope::decrypt_v2(Some(subject), b'0', &[7; 16], "/repo", "/repo") + .key + .unwrap(); + let ws_dec = GrantScope::decrypt_workspace(subject, "/repo", b'0', &[7; 16]) + .key + .unwrap(); + let cwd_dec = GrantScope::decrypt_cwd(subject, "/repo", b'0', &[7; 16]) + .key + .unwrap(); + let app_dec = GrantScope::decrypt_app(subject, "/repo", b'0', &[7; 16]) + .key + .unwrap(); + assert_ne!(relay_dec, ws_dec); + assert_ne!(cwd_dec, ws_dec); + assert_ne!(cwd_dec, relay_dec); + assert_ne!(app_dec, ws_dec); + assert_ne!(app_dec, cwd_dec); + assert_ne!(app_dec, relay_dec); + + // Destination scope partitions on host key and key fingerprint. + assert_ne!( + GrantScope::sign_destination(b"hostkey-a", "fp").key.unwrap(), + GrantScope::sign_destination(b"hostkey-b", "fp").key.unwrap() + ); + assert_ne!( + GrantScope::sign_destination(b"hostkey-a", "fp-a").key.unwrap(), + GrantScope::sign_destination(b"hostkey-a", "fp-b").key.unwrap() + ); + // Workspace scope partitions on root path. + assert_ne!( + GrantScope::sign_workspace(subject, "/repo-a", "fp") + .key + .unwrap(), + GrantScope::sign_workspace(subject, "/repo-b", "fp") + .key + .unwrap() + ); + // Reusability is observable: it gates the prompt reuse line. + assert!(GrantScope::sign_workspace(subject, "/repo", "fp").is_reusable()); + assert!(!GrantScope::fresh(Operation::Sign).is_reusable()); + assert!(!GrantScope::sign(None, "fp", "/repo").is_reusable()); + } + + #[test] + fn typed_operation_and_subject_partition_grants() { + let sign = sign_scope((1, 2), "fp").key.unwrap(); + let other_subject = sign_scope((2, 2), "fp").key.unwrap(); + let decrypt = GrantScope::decrypt_v2(Some((1, 2)), b'0', &[7; 16], "h", "/repo") + .key + .unwrap(); + assert_ne!(sign, other_subject); + assert_ne!(sign.operation, decrypt.operation); + assert_ne!(sign, decrypt); + } + + #[test] + fn sign_scope_partitions_fingerprint_and_pwd() { + let base = sign_scope((1, 2), "fp-a").key.unwrap(); + let fingerprint = GrantScope::sign(Some((1, 2)), "fp-b", "/repo").key.unwrap(); + let pwd = GrantScope::sign(Some((1, 2)), "fp-a", "/other") + .key + .unwrap(); + assert_ne!(base, fingerprint); + assert_ne!(base, pwd); + } + + #[test] + fn decrypt_scope_partitions_type_salt_host_and_pwd() { + let key = |kind, salt: u8, host: &str, pwd: &str| { + GrantScope::decrypt_v2(Some((1, 2)), kind, &[salt; 16], host, pwd) + .key + .unwrap() + }; + let base = key(b'0', 1, "host-a", "/repo"); + assert_ne!(base, key(b'1', 1, "host-a", "/repo")); + assert_ne!(base, key(b'0', 2, "host-a", "/repo")); + assert_ne!(base, key(b'0', 1, "host-b", "/repo")); + assert_ne!(base, key(b'0', 1, "host-a", "/other")); + } + + #[test] + fn invalidation_bumps_empty_store_epoch_and_blocks_stale_commit() { + let key = keyed(sign_scope((1, 2), "fp")); + let mut store = GrantStore::new(); + assert_eq!(store.invalidate_all(), 0); + assert_eq!(store.epoch, 1); + assert_eq!( + store.commit_at( + 0, + &[key], + Duration::from_secs(120), + Instant::now(), + SystemTime::now(), + ), + Err(CommitError::Invalidated) + ); + } + + #[test] + fn oversized_ttl_fails_without_panicking() { + let key = keyed(sign_scope((1, 2), "fp")); + let mut store = GrantStore::new(); + assert_eq!( + store.commit_at( + 0, + &[key], + Duration::from_secs(u64::MAX), + Instant::now(), + SystemTime::now(), + ), + Err(CommitError::InvalidTtl) + ); + } + + #[tokio::test] + async fn oversized_ttl_fails_before_prompting() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let failure = engine + .authorize(AuthorizationRequest::new( + vec![sign_scope((1, 2), "fp")], + ReusePolicy::strict_ttl_secs(u64::MAX), + "sign", + )) + .await + .err() + .expect("oversized TTL must fail authorization"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(auth.calls.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn grant_is_written_only_after_operation_commits_permit() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let permit = engine.authorize(sign_request((1, 2), "fp")).await.unwrap(); + assert!(matches!(permit.decision(), Decision::Approved(_))); + drop(permit); + + let permit = engine.authorize(sign_request((1, 2), "fp")).await.unwrap(); + assert!(matches!(permit.decision(), Decision::Approved(_))); + permit.commit().await.unwrap(); + + let hit = engine.authorize(sign_request((1, 2), "fp")).await.unwrap(); + assert_eq!(hit.decision(), Decision::CacheHit); + hit.commit().await.unwrap(); + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + } + + #[tokio::test] + async fn ttl_policy_tightening_requires_fresh_approval() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let request = |ttl| { + AuthorizationRequest::new( + vec![sign_scope((1, 2), "fp")], + ReusePolicy::strict_ttl_secs(ttl), + "sign", + ) + }; + + engine + .authorize(request(120)) + .await + .unwrap() + .commit() + .await + .unwrap(); + let tighter = engine.authorize(request(30)).await.unwrap(); + assert!(matches!(tighter.decision(), Decision::Approved(_))); + tighter.commit().await.unwrap(); + let hit = engine.authorize(request(30)).await.unwrap(); + assert_eq!(hit.decision(), Decision::CacheHit); + hit.commit().await.unwrap(); + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + } + + #[tokio::test] + async fn empty_scope_set_fails_closed_without_prompting() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let failure = engine + .authorize(AuthorizationRequest::new( + Vec::new(), + ReusePolicy::strict_ttl_secs(30), + "empty", + )) + .await + .err() + .expect("empty scope set must fail"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(auth.calls.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn duplicate_scopes_create_one_live_grant() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth, AllowValidator::allowed()); + let scope = sign_scope((1, 2), "fp"); + let permit = engine + .authorize(AuthorizationRequest::new( + vec![scope.clone(), scope], + ReusePolicy::strict_ttl_secs(30), + "sign", + )) + .await + .unwrap(); + permit.commit().await.unwrap(); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 1); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (2, 2)).await, 0); + assert_eq!(engine.live_len(Operation::Decrypt, ScopeFamily::Connection, (1, 2)).await, 0); + } + + #[tokio::test] + async fn live_len_filters_by_scope_family() { + // A workspace grant and a cwd-fallback grant can share a (dev, ino) + // subject (the same directory before/after `git init`); diag counts + // must not report the other family's grants as reusable. + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth, AllowValidator::allowed()); + let subject = (7, 42); + let permit = engine + .authorize(AuthorizationRequest::new( + vec![GrantScope::sign_cwd(subject, "/dir", "fp")], + ReusePolicy::strict_ttl_secs(30), + "sign", + )) + .await + .unwrap(); + permit.commit().await.unwrap(); + assert_eq!( + engine + .live_len(Operation::Sign, ScopeFamily::CwdFallback, subject) + .await, + 1 + ); + assert_eq!( + engine + .live_len(Operation::Sign, ScopeFamily::Workspace, subject) + .await, + 0 + ); + } + + #[tokio::test] + async fn rejection_and_unavailable_never_grant() { + for outcome in [ + AuthOutcome::Rejected, + AuthOutcome::Unavailable(UnavailableReason::NoGuiSession), + ] { + let auth = Arc::new(FixedAuthenticator { + calls: AtomicUsize::new(0), + outcome, + }); + let engine = AuthorizationEngine::new(auth, AllowValidator::allowed()); + assert!(engine.authorize(sign_request((1, 2), "fp")).await.is_err()); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + } + } + + struct UnavailableOnSecondAuthenticator { + calls: AtomicUsize, + } + + #[async_trait] + impl AuthorizationAuthenticator for UnavailableOnSecondAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + revocation_pending: Arc, + ) -> AuthOutcome { + match self.calls.fetch_add(1, Ordering::AcqRel) { + 1 => { + revocation_pending.store(true, Ordering::Release); + AuthOutcome::Unavailable(UnavailableReason::NotInteractive) + } + _ => AuthOutcome::Success(AuthMethod::Biometric), + } + } + } + + #[tokio::test] + async fn prompt_unavailable_revokes_preexisting_grants() { + let auth = Arc::new(UnavailableOnSecondAuthenticator { + calls: AtomicUsize::new(0), + }); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap() + .commit() + .await + .unwrap(); + + let failure = engine + .authorize(sign_request((1, 2), "unavailable")) + .await + .err() + .expect("second prompt must be unavailable"); + assert_eq!( + failure.decision(), + Decision::Unavailable(UnavailableReason::NotInteractive) + ); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + + let retry = engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap(); + assert!(matches!(retry.decision(), Decision::Approved(_))); + drop(retry); + assert_eq!(auth.calls.load(Ordering::Acquire), 3); + } + + struct BlockingUnavailableAuthenticator { + calls: AtomicUsize, + entered: Notify, + release: Notify, + } + + #[async_trait] + impl AuthorizationAuthenticator for BlockingUnavailableAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + _revocation_pending: Arc, + ) -> AuthOutcome { + if self.calls.fetch_add(1, Ordering::AcqRel) == 0 { + return AuthOutcome::Success(AuthMethod::Biometric); + } + self.entered.notify_one(); + self.release.notified().await; + AuthOutcome::Unavailable(UnavailableReason::NotInteractive) + } + } + + #[tokio::test] + async fn cancelled_unavailable_prompt_still_revokes_preexisting_grants() { + let auth = Arc::new(BlockingUnavailableAuthenticator { + calls: AtomicUsize::new(0), + entered: Notify::new(), + release: Notify::new(), + }); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap() + .commit() + .await + .unwrap(); + + let request_engine = Arc::clone(&engine); + let request = tokio::spawn(async move { + request_engine + .authorize(sign_request((1, 2), "unavailable")) + .await + }); + auth.entered.notified().await; + request.abort(); + match request.await { + Err(error) => assert!(error.is_cancelled()), + Ok(_) => panic!("cancelled authorization unexpectedly completed"), + } + auth.release.notify_one(); + + tokio::time::timeout(Duration::from_secs(1), async { + while engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("detached unavailable prompt did not revoke old grants"); + assert_eq!(engine.store.read().await.epoch, 1); + assert!(!engine.revocation_pending.load(Ordering::Acquire)); + assert!(!engine.revoker_running.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn different_reusable_scopes_do_not_coalesce() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + for fingerprint in ["fp-a", "fp-b"] { + let permit = engine + .authorize(sign_request((1, 2), fingerprint)) + .await + .unwrap(); + permit.commit().await.unwrap(); + } + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + } + + #[tokio::test] + async fn fresh_policy_never_reuses() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + for _ in 0..2 { + let permit = engine + .authorize(AuthorizationRequest::fresh( + GrantScope::fresh(Operation::Auth), + "auth", + )) + .await + .unwrap(); + permit.commit().await.unwrap(); + } + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + assert_eq!(engine.live_len(Operation::Auth, ScopeFamily::Connection, (1, 2)).await, 0); + } + + #[tokio::test] + async fn missing_subject_makes_strict_ttl_request_effectively_fresh() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + for _ in 0..2 { + engine + .authorize(AuthorizationRequest::new( + vec![GrantScope::sign(None, "fp", "/repo")], + ReusePolicy::strict_ttl_secs(30), + "sign", + )) + .await + .unwrap() + .commit() + .await + .unwrap(); + } + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + } + + #[tokio::test] + async fn operation_subject_and_resource_are_runtime_isolation_boundaries() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let digest = [7; 32]; + let base = GrantScope::reusable(Operation::Sign, ScopeFamily::Connection, (1, 2), digest); + engine + .authorize(AuthorizationRequest::new( + vec![base.clone()], + ReusePolicy::strict_ttl_secs(30), + "base", + )) + .await + .unwrap() + .commit() + .await + .unwrap(); + + let hit = engine + .authorize(AuthorizationRequest::new( + vec![base], + ReusePolicy::strict_ttl_secs(30), + "hit", + )) + .await + .unwrap(); + assert_eq!(hit.decision(), Decision::CacheHit); + hit.commit().await.unwrap(); + + for isolated in [ + GrantScope::reusable(Operation::Decrypt, ScopeFamily::Connection, (1, 2), digest), + GrantScope::reusable(Operation::Sign, ScopeFamily::Connection, (2, 2), digest), + GrantScope::reusable(Operation::Sign, ScopeFamily::Connection, (1, 2), [8; 32]), + ] { + let permit = engine + .authorize(AuthorizationRequest::new( + vec![isolated], + ReusePolicy::strict_ttl_secs(30), + "isolated", + )) + .await + .unwrap(); + assert!(matches!(permit.decision(), Decision::Approved(_))); + drop(permit); + } + assert_eq!(auth.calls.load(Ordering::Acquire), 4); + } + + #[tokio::test] + async fn mixed_operation_scope_set_fails_closed_without_prompting() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let failure = engine + .authorize(AuthorizationRequest::new( + vec![ + GrantScope::fresh(Operation::Sign), + GrantScope::fresh(Operation::Decrypt), + ], + ReusePolicy::Fresh, + "mixed", + )) + .await + .err() + .expect("mixed operation request must fail"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(auth.calls.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn mixed_subject_scope_set_fails_closed_without_prompting() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let failure = engine + .authorize(AuthorizationRequest::new( + vec![sign_scope((1, 2), "fp-a"), sign_scope((2, 2), "fp-b")], + ReusePolicy::strict_ttl_secs(30), + "mixed subjects", + )) + .await + .err() + .expect("mixed subject request must fail"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(auth.calls.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn concurrent_same_scope_singleflights_to_one_prompt() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let mut tasks = Vec::new(); + for _ in 0..2 { + let engine = Arc::clone(&engine); + let barrier = Arc::clone(&barrier); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + let permit = engine.authorize(sign_request((1, 2), "fp")).await.unwrap(); + let decision = permit.decision(); + permit.commit().await.unwrap(); + decision + })); + } + barrier.wait().await; + let a = tasks.remove(0).await.unwrap(); + let b = tasks.remove(0).await.unwrap(); + assert!(matches!(a, Decision::Approved(_) | Decision::CacheHit)); + assert!(matches!(b, Decision::Approved(_) | Decision::CacheHit)); + assert_ne!(a, b); + assert_eq!(auth.calls.load(Ordering::Acquire), 1); + } + + struct BlockingAuthenticator { + calls: AtomicUsize, + entered: Notify, + release: Notify, + } + + #[async_trait] + impl AuthorizationAuthenticator for BlockingAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + _revocation_pending: Arc, + ) -> AuthOutcome { + self.calls.fetch_add(1, Ordering::AcqRel); + self.entered.notify_one(); + self.release.notified().await; + AuthOutcome::Success(AuthMethod::Biometric) + } + } + + #[tokio::test] + async fn concurrent_fresh_requests_serialize_but_never_coalesce() { + let auth = Arc::new(BlockingAuthenticator { + calls: AtomicUsize::new(0), + entered: Notify::new(), + release: Notify::new(), + }); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let mut requests = Vec::new(); + for _ in 0..2 { + let engine = Arc::clone(&engine); + requests.push(tokio::spawn(async move { + let permit = engine + .authorize(AuthorizationRequest::fresh( + GrantScope::fresh(Operation::Run), + "run", + )) + .await + .unwrap(); + assert!(matches!(permit.decision(), Decision::Approved(_))); + permit.commit().await.unwrap(); + })); + } + + auth.entered.notified().await; + tokio::task::yield_now().await; + assert_eq!(auth.calls.load(Ordering::Acquire), 1); + auth.release.notify_one(); + auth.entered.notified().await; + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + auth.release.notify_one(); + for request in requests { + request.await.unwrap(); + } + assert_eq!(engine.live_len(Operation::Run, ScopeFamily::Connection, (1, 2)).await, 0); + } + + #[tokio::test] + async fn invalidation_during_prompt_revokes_authorization() { + let auth = Arc::new(BlockingAuthenticator { + calls: AtomicUsize::new(0), + entered: Notify::new(), + release: Notify::new(), + }); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let request_engine = Arc::clone(&engine); + let request = + tokio::spawn(async move { request_engine.authorize(sign_request((1, 2), "fp")).await }); + auth.entered.notified().await; + assert_eq!(engine.invalidate_all().await, 0); + + auth.release.notify_one(); + let failure = request + .await + .unwrap() + .err() + .expect("in-flight prompt must be revoked"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + } + + #[tokio::test] + async fn cache_hit_revalidates_live_security_state() { + let auth = SuccessAuthenticator::new(); + let validator = AllowValidator::allowed(); + let engine = AuthorizationEngine::new(auth.clone(), validator.clone()); + engine + .authorize(sign_request((1, 2), "fp")) + .await + .unwrap() + .commit() + .await + .unwrap(); + validator.allowed.store(false, Ordering::Release); + + let failure = engine + .authorize(sign_request((1, 2), "fp")) + .await + .err() + .expect("a stale hit must fail live validation"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(auth.calls.load(Ordering::Acquire), 1); + + validator.allowed.store(true, Ordering::Release); + let retry = engine.authorize(sign_request((1, 2), "fp")).await.unwrap(); + assert!(matches!(retry.decision(), Decision::Approved(_))); + drop(retry); + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + } + + #[tokio::test] + async fn approval_revalidates_live_security_state_after_prompt() { + let auth = Arc::new(BlockingAuthenticator { + calls: AtomicUsize::new(0), + entered: Notify::new(), + release: Notify::new(), + }); + let validator = AllowValidator::allowed(); + let engine = AuthorizationEngine::new(auth.clone(), validator.clone()); + let request_engine = Arc::clone(&engine); + let request = + tokio::spawn(async move { request_engine.authorize(sign_request((1, 2), "fp")).await }); + auth.entered.notified().await; + validator.allowed.store(false, Ordering::Release); + auth.release.notify_one(); + + let failure = request + .await + .unwrap() + .err() + .expect("post-prompt live validation must fail"); + assert_eq!(failure.decision(), Decision::Invalidated); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + } + + struct ConcurrentInvalidValidator { + armed: AtomicBool, + calls: AtomicUsize, + completions: AtomicUsize, + barrier: Barrier, + } + + impl AuthorizationValidator for ConcurrentInvalidValidator { + fn validate(&self, revocation_pending: &AtomicBool) -> Result<(), ValidationError> { + if !self.armed.load(Ordering::Acquire) { + return Ok(()); + } + let call = self.calls.fetch_add(1, Ordering::AcqRel); + if call == 1 { + revocation_pending.store(true, Ordering::Release); + } + self.barrier.wait(); + if call == 1 { + Err(ValidationError::Invalidated) + } else { + Ok(()) + } + } + + fn invalidation_complete(&self) { + self.armed.store(false, Ordering::Release); + self.completions.fetch_add(1, Ordering::AcqRel); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_readers_cannot_cross_pending_revocation() { + let auth = SuccessAuthenticator::new(); + let validator = Arc::new(ConcurrentInvalidValidator { + armed: AtomicBool::new(false), + calls: AtomicUsize::new(0), + completions: AtomicUsize::new(0), + barrier: Barrier::new(2), + }); + let engine = AuthorizationEngine::new(auth.clone(), validator.clone()); + engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap() + .commit() + .await + .unwrap(); + + validator.armed.store(true, Ordering::Release); + let mut readers = Vec::new(); + for _ in 0..2 { + let engine = Arc::clone(&engine); + readers.push(tokio::spawn(async move { + engine.authorize(sign_request((1, 2), "cached")).await + })); + } + for reader in readers { + assert!(reader.await.unwrap().is_err()); + } + assert_eq!(validator.calls.load(Ordering::Acquire), 2); + assert_eq!(validator.completions.load(Ordering::Acquire), 1); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + + let retry = engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap(); + assert!(matches!(retry.decision(), Decision::Approved(_))); + drop(retry); + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn simultaneous_revocation_observations_share_one_epoch() { + const OBSERVERS: usize = 32; + + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth, AllowValidator::allowed()); + engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap() + .commit() + .await + .unwrap(); + + let published = Arc::new(tokio::sync::Barrier::new(OBSERVERS + 1)); + let mut observers = Vec::with_capacity(OBSERVERS); + for _ in 0..OBSERVERS { + let engine = Arc::clone(&engine); + let published = Arc::clone(&published); + observers.push(tokio::spawn(async move { + engine.revocation_pending.store(true, Ordering::Release); + published.wait().await; + engine.drain_pending_revocation().await + })); + } + published.wait().await; + + let mut dropped = 0; + for observer in observers { + dropped += observer.await.unwrap(); + } + assert_eq!(dropped, 1); + assert_eq!(engine.store.read().await.epoch, 1); + assert_eq!(engine.revocation_generation.load(Ordering::Acquire), 1); + assert!(!engine.revocation_pending.load(Ordering::Acquire)); + assert!(!engine.revoker_running.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn revocation_waiter_recovers_orphaned_pending_state() { + let engine = + AuthorizationEngine::new(SuccessAuthenticator::new(), AllowValidator::allowed()); + engine.revocation_pending.store(true, Ordering::Release); + // Model a waiter that initially observes an exiting revoker. The + // synthetic completion then exposes pending=true/running=false, the + // cross-atomic hand-off state that must be reclaimed rather than wait + // forever for a revoker that no longer exists. + engine.revoker_running.store(true, Ordering::Release); + let waiting_engine = Arc::clone(&engine); + let waiter = tokio::spawn(async move { waiting_engine.drain_pending_revocation().await }); + while engine.revocation_completed.receiver_count() == 0 { + tokio::task::yield_now().await; + } + tokio::task::yield_now().await; + engine.revoker_running.store(false, Ordering::Release); + engine.revocation_completed.send_replace(1); + + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("orphaned revocation waiter hung") + .unwrap(), + 0 + ); + assert_eq!(engine.store.read().await.epoch, 1); + assert!(!engine.revocation_pending.load(Ordering::Acquire)); + assert!(!engine.revoker_running.load(Ordering::Acquire)); + } + + struct SignalingValidator { + allowed: AtomicBool, + invalid_seen: Notify, + } + + impl AuthorizationValidator for SignalingValidator { + fn validate(&self, revocation_pending: &AtomicBool) -> Result<(), ValidationError> { + if self.allowed.load(Ordering::Acquire) { + Ok(()) + } else { + revocation_pending.store(true, Ordering::Release); + self.invalid_seen.notify_one(); + Err(ValidationError::Invalidated) + } + } + } + + #[tokio::test] + async fn observed_invalid_state_revokes_even_if_request_is_cancelled() { + let auth = SuccessAuthenticator::new(); + let validator = Arc::new(SignalingValidator { + allowed: AtomicBool::new(true), + invalid_seen: Notify::new(), + }); + let engine = AuthorizationEngine::new(auth.clone(), validator.clone()); + + engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap() + .commit() + .await + .unwrap(); + let active = engine + .authorize(sign_request((1, 2), "active")) + .await + .unwrap(); + + validator.allowed.store(false, Ordering::Release); + let failing_engine = Arc::clone(&engine); + let failing = tokio::spawn(async move { + failing_engine + .authorize(sign_request((1, 2), "cached")) + .await + }); + validator.invalid_seen.notified().await; + failing.abort(); + match failing.await { + Err(error) => assert!(error.is_cancelled()), + Ok(_) => panic!("cancelled validation unexpectedly completed"), + } + + validator.allowed.store(true, Ordering::Release); + active.commit().await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("detached revocation did not complete"); + + let retry = engine + .authorize(sign_request((1, 2), "cached")) + .await + .unwrap(); + assert!(matches!(retry.decision(), Decision::Approved(_))); + drop(retry); + assert_eq!(auth.calls.load(Ordering::Acquire), 3); + } + + #[tokio::test] + async fn cancelled_authorize_keeps_prompt_serialized_until_authenticator_finishes() { + let auth = Arc::new(BlockingAuthenticator { + calls: AtomicUsize::new(0), + entered: Notify::new(), + release: Notify::new(), + }); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + + let first_engine = Arc::clone(&engine); + let first = + tokio::spawn( + async move { first_engine.authorize(sign_request((1, 2), "first")).await }, + ); + auth.entered.notified().await; + first.abort(); + match first.await { + Err(error) => assert!(error.is_cancelled()), + Ok(_) => panic!("aborted authorization unexpectedly completed"), + } + + let second_engine = Arc::clone(&engine); + let second = tokio::spawn(async move { + let permit = second_engine + .authorize(sign_request((1, 2), "second")) + .await + .unwrap(); + permit.commit().await.unwrap(); + }); + tokio::task::yield_now().await; + assert_eq!(auth.calls.load(Ordering::Acquire), 1); + + auth.release.notify_one(); + auth.entered.notified().await; + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + auth.release.notify_one(); + second.await.unwrap(); + } + + #[tokio::test] + async fn invalidation_waits_for_active_operation_permit() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth, AllowValidator::allowed()); + let permit = engine.authorize(sign_request((1, 2), "fp")).await.unwrap(); + let invalidate_engine = Arc::clone(&engine); + let invalidate = tokio::spawn(async move { invalidate_engine.invalidate_all().await }); + tokio::task::yield_now().await; + assert!(!invalidate.is_finished()); + permit.commit().await.unwrap(); + assert_eq!(invalidate.await.unwrap(), 1); + assert_eq!(engine.live_len(Operation::Sign, ScopeFamily::Connection, (1, 2)).await, 0); + } + + #[tokio::test] + async fn partial_batch_prompts_once_then_becomes_full_hit() { + let auth = SuccessAuthenticator::new(); + let engine = AuthorizationEngine::new(auth.clone(), AllowValidator::allowed()); + let a = GrantScope::decrypt_v2(Some((1, 2)), b'0', &[1; 16], "h", "/p"); + let b = GrantScope::decrypt_v2(Some((1, 2)), b'0', &[2; 16], "h", "/p"); + let first = engine + .authorize(AuthorizationRequest::new( + vec![a.clone()], + ReusePolicy::strict_ttl_secs(30), + "decrypt", + )) + .await + .unwrap(); + first.commit().await.unwrap(); + let partial = engine + .authorize(AuthorizationRequest::new( + vec![a.clone(), b.clone()], + ReusePolicy::strict_ttl_secs(30), + "decrypt", + )) + .await + .unwrap(); + assert!(matches!(partial.decision(), Decision::Approved(_))); + partial.commit().await.unwrap(); + let full = engine + .authorize(AuthorizationRequest::new( + vec![a, b], + ReusePolicy::strict_ttl_secs(30), + "decrypt", + )) + .await + .unwrap(); + assert_eq!(full.decision(), Decision::CacheHit); + full.commit().await.unwrap(); + assert_eq!(auth.calls.load(Ordering::Acquire), 2); + } + + struct ApproveThenRejectAuthenticator { + calls: AtomicUsize, + } + + #[async_trait] + impl AuthorizationAuthenticator for ApproveThenRejectAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + _revocation_pending: Arc, + ) -> AuthOutcome { + match self.calls.fetch_add(1, Ordering::AcqRel) { + 0 => AuthOutcome::Success(AuthMethod::Biometric), + _ => AuthOutcome::Rejected, + } + } + } + + #[tokio::test] + async fn rejected_partial_batch_preserves_existing_grants_and_adds_none() { + let auth = Arc::new(ApproveThenRejectAuthenticator { + calls: AtomicUsize::new(0), + }); + let engine = AuthorizationEngine::new(auth, AllowValidator::allowed()); + let a = GrantScope::decrypt_v2(Some((1, 2)), b'0', &[1; 16], "h", "/p"); + let b = GrantScope::decrypt_v2(Some((1, 2)), b'0', &[2; 16], "h", "/p"); + engine + .authorize(AuthorizationRequest::new( + vec![a.clone()], + ReusePolicy::strict_ttl_secs(30), + "first", + )) + .await + .unwrap() + .commit() + .await + .unwrap(); + + let failure = engine + .authorize(AuthorizationRequest::new( + vec![a, b], + ReusePolicy::strict_ttl_secs(30), + "partial", + )) + .await + .err() + .expect("second prompt is rejected"); + assert_eq!(failure.decision(), Decision::Rejected); + assert_eq!(engine.live_len(Operation::Decrypt, ScopeFamily::Connection, (1, 2)).await, 1); + } +} diff --git a/src/core/crypto.rs b/src/core/crypto.rs index 5208c10..68ed2d4 100644 --- a/src/core/crypto.rs +++ b/src/core/crypto.rs @@ -36,21 +36,39 @@ pub fn derive_dek(mac_key: &[u8; 32], salt: &[u8; 16]) -> [u8; 32] { okm } -/// Derive the per-user/per-binary passphrase secret. Pure: SHA-256(SHA-256( +/// Fixed final derivation term for wrap v2 (docs/app-bundle.md §2). Replaces +/// the wrap-v1 binary-path term so the store no longer locks to an install +/// location. Domain-separated from any real path by not starting with `/`. +pub const WRAP_V2_LABEL: &str = "vt-wrap-v2"; + +/// Derive the wrap-v1 (legacy) passphrase secret. Pure: SHA-256(SHA-256( /// `base64(passcode):$USER:bin_path`)). `bin_path` defaults to -/// `current_exe()` when not supplied. +/// `current_exe()` when not supplied. Kept for reading/upgrading v1 stores +/// and for `vt secret rebind --to-v1` (downgrade before rolling back to an +/// old binary); new stores are always wrap v2. pub fn derive_passphrase_secret(passcode: &[u8; 32], bin_path: Option<&str>) -> Result<[u8; 32]> { - // The b64 passcode and the concatenated derivation string both carry the - // master secret; keep them in scrubbed memory so they don't linger on the - // heap after the SHA-256 collapse. - let passcode = Zeroizing::new(BASE64_URL_SAFE_NO_PAD.encode(passcode)); let bin_path = match bin_path { Some(s) => s.to_string(), // `?` rather than `.unwrap()`: current_exe() can fail in restricted / // containerized contexts, and this is on the key-derivation path. None => env::current_exe()?.to_string_lossy().to_string(), }; - let derived_str = Zeroizing::new(format!("{}:{}:{}", passcode.as_str(), env::var("USER")?, bin_path)); + derive_passphrase_secret_with_term(passcode, &bin_path) +} + +/// Derive the wrap-v2 passphrase secret: the derivation string ends in the +/// fixed [`WRAP_V2_LABEL`] instead of a binary path, so moving or renaming +/// the binary (e.g. into VT.app) does not invalidate the wrap. +pub fn derive_passphrase_secret_v2(passcode: &[u8; 32]) -> Result<[u8; 32]> { + derive_passphrase_secret_with_term(passcode, WRAP_V2_LABEL) +} + +fn derive_passphrase_secret_with_term(passcode: &[u8; 32], term: &str) -> Result<[u8; 32]> { + // The b64 passcode and the concatenated derivation string both carry the + // master secret; keep them in scrubbed memory so they don't linger on the + // heap after the SHA-256 collapse. + let passcode = Zeroizing::new(BASE64_URL_SAFE_NO_PAD.encode(passcode)); + let derived_str = Zeroizing::new(format!("{}:{}:{}", passcode.as_str(), env::var("USER")?, term)); let hash = Sha256::digest(&Sha256::digest(derived_str.as_bytes())); let mut key = [0u8; 32]; key.copy_from_slice(&hash[..32]); diff --git a/src/core/session.rs b/src/core/session.rs index e7eebc7..8de7123 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -67,6 +67,9 @@ impl AuthOutcome { pub enum NotifyKind { TouchIdRejected, Locked, + /// A grant reuse satisfied sign/decrypt without a Touch ID prompt + /// (docs/app-bundle.md §3) — transparency for otherwise-silent reuse. + CacheHit, } /// Classification of session state. Pure type, no FFI — produced by diff --git a/src/main.rs b/src/main.rs index a854dd0..acc2e6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -277,9 +277,20 @@ pub enum SecretCommands { /// Import an encrypted master secret Import, /// Rotate the passcode for the master secret - RotatePasscode { - #[arg(long, help = "Absolute path to the new vt binary")] - bin_absolute_path: Option, + RotatePasscode, + /// Migrate the master-key wrap to the path-independent v2 derivation + /// (or back to v1 with --to-v1 before rolling back to an old binary) + Rebind { + #[arg( + long, + help = "Absolute path of the vt binary that wrote the store, for v1 stores bound to a location this binary no longer occupies. The old binary need not exist — only the path string enters the derivation." + )] + old_bin_path: Option, + #[arg( + long, + help = "Rewrap back to the legacy v1 derivation bound to THIS binary's current path (escape hatch before downgrading vt)" + )] + to_v1: bool, }, } @@ -321,37 +332,32 @@ pub enum SshCommands { /// Start the SSH agent (listens on ~/.ssh/vt.sock) #[cfg(target_os = "macos")] Agent { + // The three duration knobs are `Option` so "flag passed" is + // distinguishable from "compiled default": effective value is + // flag > config.toml `[agent]` > built-in default + // (docs/app-bundle.md §4). #[arg( short = 't', long = "timeout", - default_value_t = server_macos::ssh_agent::DEFAULT_IDLE_TIMEOUT_SECS, - help = "Idle timeout in seconds before clearing keys from memory" - )] - timeout: u64, - #[arg( - long = "ssh-auth-cache-mode", - default_value = "none", - help = "Sign auth cache mode: none, per-session, per-app, or global. per-session/per-app require the caller to have a controlling terminal (TTY-less orchestrators like AI agents / CI never hit); global shares ONE context across all callers — the only mode that serves orchestrated callers, and the coarsest (cache keys still partition by the client-reported working directory). Forwarded agent sockets (ssh -A) get a per-connection context: a remote host reuses only its own approvals within the TTL, never grants from local tabs or other hosts. Still keep 'none' when forwarding to hosts you don't trust at all — within the TTL any process on that host reuses that connection's grants." + help = "Idle timeout in seconds before clearing keys from memory and revoking all grants (default 7200 = 2h; config.toml [agent].timeout overrides the default). Screen lock and sleep/wake revoke far sooner regardless; lower this on hosts without reliable auto-lock." )] - auth_cache_mode: server_macos::ssh_agent::AuthCacheMode, + timeout: Option, #[arg( long = "ssh-auth-cache-duration", - default_value_t = server_macos::ssh_agent::DEFAULT_AUTH_CACHE_DURATION_SECS, - help = "Sign auth cache duration in seconds" + help = "Sign approval reuse duration in seconds; 0 (default) = prompt every time. Grants are activity-scoped: raw ssh signs bind to the session-bind-verified destination server (repeated one-shot git/ssh calls to the same host reuse one approval); local sign@vt and ssh-keygen signing bind to the caller's git workspace; forwarded/relay traffic is confined per connection and never rides local approvals. Strict TTL, no sliding refresh; screen lock, sleep/wake, agent lock, and idle timeout revoke all grants immediately. config.toml [agent].ssh_auth_cache_duration overrides the default." )] - auth_cache_duration: u64, + auth_cache_duration: Option, #[arg( - long = "decrypt-auth-cache-mode", - default_value = "none", - help = "Decrypt auth cache mode: none, per-session, per-app, or global. Only v2 envelope URLs are cache-eligible; legacy items always prompt. Same mode semantics and forwarded-agent caveat as --ssh-auth-cache-mode." + long = "decrypt-auth-cache-duration", + help = "Decrypt approval reuse duration in seconds; 0 (default) = prompt every time. Only v2 envelope URLs are cache-eligible; legacy items always prompt. Grants bind to the caller's git workspace (kernel-derived), or per relay connection when forwarded. Kept separate from the sign duration because a cached decrypt grant releases per-record DEK material. config.toml [agent].decrypt_auth_cache_duration overrides the default." )] - decrypt_auth_cache_mode: server_macos::ssh_agent::AuthCacheMode, + decrypt_auth_cache_duration: Option, #[arg( - long = "decrypt-auth-cache-duration", - default_value_t = server_macos::ssh_agent::DEFAULT_DECRYPT_AUTH_CACHE_DURATION_SECS, - help = "Decrypt auth cache duration in seconds (strict TTL, no sliding refresh)" + long = "no-cache-hit-notify", + default_value_t = false, + help = "Disable the system notification fired when a cached grant satisfies sign/decrypt without a Touch ID prompt. Also configurable as [agent].cache_hit_notify in config.toml." )] - decrypt_auth_cache_duration: u64, + no_cache_hit_notify: bool, #[arg( long = "no-legacy-decrypt", default_value_t = false, @@ -360,10 +366,9 @@ pub enum SshCommands { no_legacy_decrypt: bool, #[arg( long = "run-allow", - default_value = "", - help = "Comma-separated allowlist for `run@vt` (e.g. `zed,code,/Applications/Zed.app/Contents/MacOS/cli`). Bare names match argv[0] without `/` and are resolved via the agent's own PATH; entries containing `/` must be absolute and match argv[0] post-canonicalization. Empty (the default) disables run@vt entirely." + help = "Comma-separated allowlist for `run@vt` (e.g. `zed,code,/Applications/Zed.app/Contents/MacOS/cli`). Bare names match argv[0] without `/` and are resolved via the agent's own PATH; entries containing `/` must be absolute and match argv[0] post-canonicalization. Unset falls back to config.toml [agent].run_allow, then empty (run@vt disabled)." )] - run_allow: String, + run_allow: Option, #[arg( long = "audit-url", help = "Worker base URL for agent audit push, e.g. https://vt-passkey.example.com. Unset (the default) disables audit push." @@ -380,6 +385,11 @@ pub enum SshCommands { help = "Disable audit push even when --audit-url is set." )] no_audit_push: bool, + #[arg( + long = "ui-token-fd", + help = "File descriptor to read the 32-byte ui-status@vt token from at startup (inherited pipe). Set only by the VT.app shell supervisor — a pipe, never an env var (visible in `ps e`) or a file. Without it, ui-status@vt refuses every request." + )] + ui_token_fd: Option, }, /// Add an SSH private key to the keychain #[cfg(target_os = "macos")] @@ -438,8 +448,9 @@ async fn run(cli: Cli, file_populated_keys: Vec) -> Result<()> { Commands::Secret(secret_command) => match secret_command { SecretCommands::Export => server_macos::admin::export_secret().await, SecretCommands::Import => server_macos::admin::import_secret().await, - SecretCommands::RotatePasscode { bin_absolute_path } => { - server_macos::admin::rotate_passcode(bin_absolute_path.clone()).await + SecretCommands::RotatePasscode => server_macos::admin::rotate_passcode().await, + SecretCommands::Rebind { old_bin_path, to_v1 } => { + server_macos::admin::rebind(old_bin_path.clone(), *to_v1).await } }, Commands::Ssh(ssh_command) => match ssh_command { @@ -463,34 +474,71 @@ async fn run(cli: Cli, file_populated_keys: Vec) -> Result<()> { #[cfg(target_os = "macos")] SshCommands::Agent { timeout, - auth_cache_mode, auth_cache_duration, - decrypt_auth_cache_mode, decrypt_auth_cache_duration, + no_cache_hit_notify, no_legacy_decrypt, run_allow, audit_url, audit_key, no_audit_push, + ui_token_fd, } => { - use server_macos::ssh_agent::{AuthCacheConfig, RunAllowlist}; - let run_allow = RunAllowlist::parse(run_allow) - .map_err(|e| anyhow::anyhow!("--run-allow: {}", e))?; + use server_macos::ssh_agent::{AuthCacheTtls, RunAllowlist}; let audit_push = build_audit_push_config(audit_url, audit_key, *no_audit_push); + // flag > config.toml [agent] > built-in default + // (docs/app-bundle.md §4). + let file_cfg = config::load_agent_file_config(); + let run_allow_spec = run_allow + .clone() + .or(file_cfg.run_allow) + .unwrap_or_default(); + let run_allow = RunAllowlist::parse(&run_allow_spec) + .map_err(|e| anyhow::anyhow!("run-allow: {}", e))?; + // Floor the idle timeout at 60s: unlike the cache durations, + // idle `0` is NOT a "Fresh" special case — it would make the + // sweeper busy-loop (check_interval = min(60, timeout) = 0). + // Guards the config / CLI / UserDefaults-restart paths. + let timeout = timeout + .or(file_cfg.timeout) + .unwrap_or(server_macos::ssh_agent::DEFAULT_IDLE_TIMEOUT_SECS) + .max(60); + let sign_secs = auth_cache_duration + .or(file_cfg.ssh_auth_cache_duration) + .unwrap_or(0); + let decrypt_secs = decrypt_auth_cache_duration + .or(file_cfg.decrypt_auth_cache_duration) + .unwrap_or(0); + let notify_cache_hits = + !*no_cache_hit_notify && file_cfg.cache_hit_notify.unwrap_or(true); + let ui_token = match ui_token_fd { + Some(fd) => { + use std::io::Read; + use std::os::unix::io::FromRawFd; + // SAFETY: the fd number was handed to us by the + // spawning supervisor explicitly for this purpose; + // we take ownership exactly once, read 32 bytes, + // and drop (close) it before the agent serves. + let mut pipe = unsafe { std::fs::File::from_raw_fd(*fd) }; + let mut token = [0u8; 32]; + pipe.read_exact(&mut token) + .map_err(|e| anyhow::anyhow!("--ui-token-fd {fd}: {e}"))?; + Some(token) + } + None => None, + }; server_macos::ssh_agent::start_ssh_agent( - *timeout, - AuthCacheConfig { - mode: *auth_cache_mode, - ttl_secs: *auth_cache_duration, - }, - AuthCacheConfig { - mode: *decrypt_auth_cache_mode, - ttl_secs: *decrypt_auth_cache_duration, + timeout, + AuthCacheTtls { + sign_secs, + decrypt_secs, }, *no_legacy_decrypt, run_allow, std::sync::Arc::new(audit_push), + notify_cache_hits, + ui_token, ) .await } diff --git a/src/server_macos/admin.rs b/src/server_macos/admin.rs index f83f923..ee473e0 100644 --- a/src/server_macos/admin.rs +++ b/src/server_macos/admin.rs @@ -1,13 +1,12 @@ -//! macOS-only admin command bodies: `init`, `secret export/import/rotate-passcode`. - -use std::env; -use std::io::{self, Write}; +//! macOS-only admin command bodies: `init`, `secret +//! export/import/rotate-passcode/rebind`. use crate::core::crypto::AesGcmCrypto; use super::security::{ create_and_save_passcode_passphrase, derive_passcode_ciphers, local_authentication, + rewrap_passphrase, }; -use super::store::KeychainStore; +use super::store::{KeychainStore, WRAP_V1, WRAP_V2}; use anyhow::{Context, Result}; use base64::prelude::BASE64_URL_SAFE_NO_PAD; use base64::Engine; @@ -20,7 +19,7 @@ pub fn init() -> Result<()> { ))?; std::process::exit(1); } - create_and_save_passcode_passphrase(&AesGcmCrypto::generate_key(), None)?; + create_and_save_passcode_passphrase(&AesGcmCrypto::generate_key())?; Ok(()) } @@ -78,29 +77,19 @@ pub async fn import_secret() -> Result<()> { let import_cipher = AesGcmCrypto::new(&key).context("Failed to create AES-GCM cipher for master secret")?; - let vt_path = env::current_exe().unwrap().to_string_lossy().to_string(); - eprint!("Enter absolute path of vt (Default: {}): ", vt_path); - io::stderr().flush()?; - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - if input.trim().is_empty() { - input = vt_path; - } else { - input = input.trim().to_string(); - } - + // Wrap v2 derivation is path-independent — no binary-path prompt needed. let real_passphrase = import_cipher.decrypt(&encrypted_passphrase_bytes)?; let passphrase_array: [u8; 32] = real_passphrase .try_into() .map_err(|_| anyhow::anyhow!("Decrypted passphrase must be exactly 32 bytes"))?; - create_and_save_passcode_passphrase(&passphrase_array, Some(&input)) + create_and_save_passcode_passphrase(&passphrase_array) .context("Failed to create and save passcode passphrase")?; Ok(()) } -pub async fn rotate_passcode(bin_absolute_path: Option) -> Result<()> { +pub async fn rotate_passcode() -> Result<()> { if !local_authentication("rotate passcode") { Err(anyhow::anyhow!( "Local authentication failed for rotate passcode" @@ -111,14 +100,38 @@ pub async fn rotate_passcode(bin_absolute_path: Option) -> Result<()> { let encrypted_passphrase = store.encrypted_passphrase_bytes()?; let decrypted_passphrase = passphrase_cipher .decrypt(&encrypted_passphrase) - .context("Failed to decrypt passphrase. Wrong bin path?")?; + .context("Failed to decrypt passphrase. Run `vt secret rebind` first if the binary moved.")?; let passphrase_array: [u8; 32] = decrypted_passphrase .try_into() .map_err(|_| anyhow::anyhow!("Decrypted passphrase must be exactly 32 bytes"))?; - create_and_save_passcode_passphrase(&passphrase_array, bin_absolute_path.as_deref())?; + create_and_save_passcode_passphrase(&passphrase_array)?; eprintln!( "Passcode rotated. If `vt ssh agent` is running, restart it — the cached passphrase cipher \ is now stale and decrypt requests will fail until a fresh process is started." ); Ok(()) } + +/// `vt secret rebind`: migrate the master-passphrase wrap to v2 (fixed +/// label), or back to v1 with `--to-v1` before rolling back to an old +/// binary. `--old-bin-path` supplies the path term for v1 stores written by +/// a binary at a different location (docs/app-bundle.md §2). Runs under the +/// store flock; preserves VT_AUTH, SSH keys, and FIDO2 blobs byte-for-byte. +pub async fn rebind(old_bin_path: Option, to_v1: bool) -> Result<()> { + if !local_authentication("rebind master key wrap") { + Err(anyhow::anyhow!( + "Local authentication failed for rebind master key wrap" + ))?; + } + let target = if to_v1 { WRAP_V1 } else { WRAP_V2 }; + let mut from_wrap = 0u32; + KeychainStore::modify(|store| { + from_wrap = store.wrap_v; + rewrap_passphrase(store, old_bin_path.as_deref(), target) + })?; + eprintln!( + "Master key wrap: v{from_wrap} -> v{target}{}. If `vt ssh agent` is running, restart it.", + if to_v1 { " (bound to this binary's current path)" } else { " (path-independent)" } + ); + Ok(()) +} diff --git a/src/server_macos/audit.rs b/src/server_macos/audit.rs index e812a1d..35a00f8 100644 --- a/src/server_macos/audit.rs +++ b/src/server_macos/audit.rs @@ -21,6 +21,31 @@ use zeroize::Zeroizing; use crate::cf::{cf_post_with_timeout, hmac_auth_header_raw, ChallengeMeta}; +/// Agent-derived audit context: kernel/agent-authoritative fields that ride +/// as top-level siblings of the client-claimed `meta` +/// (docs/approval-transparency.md §B) — the trust boundary between the two +/// is the point. The agent always sends every field, using `""`/`0`/`false` +/// for "not applicable" (fresh scope, unknown peer, non-sign op); the Worker +/// stores a field that is *absent* (old agent) as SQL NULL, keeping the two +/// distinguishable. +#[derive(Clone, Default, Serialize)] +pub struct AgentAuditContext { + /// Kernel-verified peer executable basename (`proc_pidpath`); "" unknown. + pub peer_exe: String, + /// Sign operations: `SHA256:…` of the signing key; "" otherwise. + pub key_fp: String, + /// Verified non-forwarding session-bind destination label; "" otherwise. + pub dest: String, + /// `ScopeFamily::as_wire` of the reusable scope; "" = fresh. + pub scope_family: String, + /// The exact label the prompt's reuse line displayed; "" = fresh. + pub scope_label: String, + /// Effective TTL of the reusable scope in seconds; 0 = fresh. + pub grant_ttl_s: u64, + /// Peer is a `vt ssh connect --forward-real-agent` relay (kernel argv). + pub relayed: bool, +} + /// One agent-side audit record. Serialized as the `entry` field of the ingest /// body. `meta` carries the full [`ChallengeMeta`] wire shape (all 11 fields) /// so the Worker's `capChallengeMeta` sanitizer has everything it expects; @@ -44,6 +69,11 @@ pub struct AgentAuditEntry { pub token_id: String, /// Full display context (host/user/pwd/tty/ppid_cmd/ssh_client/ppid/…). pub meta: ChallengeMeta, + /// Agent-authoritative context, flattened to top-level siblings on the + /// wire (peer_exe / key_fp / dest / scope_family / scope_label / + /// grant_ttl_s / relayed). + #[serde(flatten)] + pub agent: AgentAuditContext, } impl AgentAuditEntry { @@ -62,6 +92,7 @@ impl AgentAuditEntry { salts: usize, latency_ms: u64, agent_id: &str, + agent: AgentAuditContext, ) -> Self { let meta = ChallengeMeta { op_kind: op_kind.to_string(), @@ -85,6 +116,7 @@ impl AgentAuditEntry { ts_ms: now_ms(), token_id: mint_token_id(agent_id), meta, + agent, } } } @@ -251,13 +283,40 @@ mod tests { ssh_client: "1.2.3.4".into(), }; let entry = AgentAuditEntry::build( - "decrypt", "approved", "host1", &meta, "cmd", "why", Some(4242), 3, 120, "AGENTID", + "decrypt", + "approved", + "host1", + &meta, + "cmd", + "why", + Some(4242), + 3, + 120, + "AGENTID", + AgentAuditContext { + peer_exe: "git".into(), + scope_family: "workspace".into(), + scope_label: "workspace /repo".into(), + grant_ttl_s: 3600, + ..AgentAuditContext::default() + }, ); let v = serde_json::to_value(&entry).unwrap(); for k in ["op_kind", "outcome", "salts", "latency_ms", "ts_ms", "token_id", "meta"] { assert!(v.get(k).is_some(), "missing sibling field {k}"); } + // The agent-authoritative context must flatten to TOP-LEVEL siblings — + // the Worker's opAuditIngest reads them beside `meta`, never inside it. + for k in [ + "peer_exe", "key_fp", "dest", "scope_family", "scope_label", "grant_ttl_s", "relayed", + ] { + assert!(v.get(k).is_some(), "missing flattened agent field {k}"); + } + assert_eq!(v["peer_exe"].as_str().unwrap(), "git"); + assert_eq!(v["scope_family"].as_str().unwrap(), "workspace"); + assert_eq!(v["grant_ttl_s"].as_u64().unwrap(), 3600); + assert_eq!(v["relayed"].as_bool().unwrap(), false); let m = v.get("meta").unwrap(); for k in [ "op_kind", "command", "host", "user", "pwd", "tty", "ppid_cmd", "ppid", "ssh_client", @@ -288,6 +347,7 @@ mod tests { 0, 0, "X", + AgentAuditContext::default(), ); let v = serde_json::to_value(&entry).unwrap(); assert_eq!(v["meta"]["ppid"].as_u64().unwrap(), 0); @@ -316,7 +376,17 @@ mod tests { fn token_id_caps_long_hostname() { let long = "h".repeat(200); let entry = AgentAuditEntry::build( - "auth", "approved", "h", &crate::core::ClientMeta::default(), "", "", None, 0, 0, &long, + "auth", + "approved", + "h", + &crate::core::ClientMeta::default(), + "", + "", + None, + 0, + 0, + &long, + AgentAuditContext::default(), ); // "a_" + 60 + "_" + 11 = 74, comfortably under the Worker's 80-char cap. assert!(entry.token_id.len() <= 74, "token_id too long: {}", entry.token_id.len()); diff --git a/src/server_macos/authorization.rs b/src/server_macos/authorization.rs new file mode 100644 index 0000000..b7233a8 --- /dev/null +++ b/src/server_macos/authorization.rs @@ -0,0 +1,135 @@ +//! macOS adapters for the platform-neutral authorization engine. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime}; + +use async_trait::async_trait; + +use crate::core::authorization::{ + AuthorizationAuthenticator, AuthorizationEngine, AuthorizationValidator, ValidationError, +}; +use crate::core::session::{AuthOutcome, SessionState, UnavailableReason}; + +use super::security::{authenticate, screen_state_now}; + +/// Wall time advancing this much further than monotonic uptime indicates that +/// macOS slept. Shared with the background watcher so request-time validation +/// and periodic invalidation use the same threshold. +pub(super) const SLEEP_DIVERGENCE: Duration = Duration::from_secs(30); + +pub(super) fn sleep_diverged(mono_delta: Duration, wall_delta: Option) -> bool { + wall_delta.is_some_and(|wall| wall.saturating_sub(mono_delta) >= SLEEP_DIVERGENCE) +} + +struct MacAuthenticator; + +#[async_trait] +impl AuthorizationAuthenticator for MacAuthenticator { + async fn authenticate(&self, prompt: &str, revocation_pending: Arc) -> AuthOutcome { + let prompt = prompt.to_string(); + match tokio::task::spawn_blocking(move || { + let outcome = authenticate(&prompt); + if matches!(outcome, AuthOutcome::Unavailable(_)) { + revocation_pending.store(true, Ordering::Release); + } + outcome + }) + .await + { + Ok(outcome) => outcome, + Err(error) => { + tracing::error!("auth prompt task failed: {}", error); + AuthOutcome::Unavailable(UnavailableReason::NotInteractive) + } + } + } +} + +struct MacValidator { + locked: Arc, + last_clock: Mutex<(Instant, SystemTime)>, +} + +impl AuthorizationValidator for MacValidator { + fn validate(&self, revocation_pending: &AtomicBool) -> Result<(), ValidationError> { + let mut last = match self.last_clock.lock() { + Ok(last) => last, + Err(_) => { + revocation_pending.store(true, Ordering::Release); + return Err(ValidationError::Invalidated); + } + }; + if revocation_pending.load(Ordering::Acquire) { + return Err(ValidationError::Invalidated); + } + if self.locked.load(Ordering::Acquire) { + revocation_pending.store(true, Ordering::Release); + return Err(ValidationError::Invalidated); + } + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); + let woke = sleep_diverged( + now_mono.saturating_duration_since(last.0), + now_wall.duration_since(last.1).ok(), + ); + *last = (now_mono, now_wall); + if woke { + revocation_pending.store(true, Ordering::Release); + return Err(ValidationError::Invalidated); + } + let result = match screen_state_now() { + SessionState::Interactive => Ok(()), + SessionState::NotInteractive => Err(ValidationError::Unavailable( + UnavailableReason::NotInteractive, + )), + SessionState::NoSession => Err(ValidationError::Unavailable( + UnavailableReason::NoGuiSession, + )), + }; + if result.is_err() { + revocation_pending.store(true, Ordering::Release); + return result; + } + if revocation_pending.load(Ordering::Acquire) { + return Err(ValidationError::Invalidated); + } + Ok(()) + } + + fn invalidation_complete(&self) { + let mut last = self + .last_clock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *last = (Instant::now(), SystemTime::now()); + } +} + +pub fn new_engine(locked: Arc) -> Arc { + AuthorizationEngine::new( + Arc::new(MacAuthenticator), + Arc::new(MacValidator { + locked, + last_clock: Mutex::new((Instant::now(), SystemTime::now())), + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sleep_detection_uses_wall_minus_monotonic_divergence() { + assert!(!sleep_diverged( + Duration::from_secs(5), + Some(Duration::from_secs(20)) + )); + assert!(sleep_diverged( + Duration::from_secs(5), + Some(Duration::from_secs(35)) + )); + assert!(!sleep_diverged(Duration::from_secs(5), None)); + } +} diff --git a/src/server_macos/mod.rs b/src/server_macos/mod.rs index 3859737..48753e3 100644 --- a/src/server_macos/mod.rs +++ b/src/server_macos/mod.rs @@ -4,6 +4,7 @@ //! point can assume macOS APIs are available. pub mod admin; +pub mod authorization; pub mod audit; pub mod fido2; pub mod fido2_cli; diff --git a/src/server_macos/security.rs b/src/server_macos/security.rs index 042670f..46ab597 100644 --- a/src/server_macos/security.rs +++ b/src/server_macos/security.rs @@ -6,7 +6,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use zeroize::Zeroizing; -use crate::core::crypto::{derive_passphrase_secret, AesGcmCrypto}; +use crate::core::crypto::{derive_passphrase_secret, derive_passphrase_secret_v2, AesGcmCrypto}; use crate::core::session::{ classify_session, lock_cache_check, throttle_check, AuthMethod, AuthOutcome, NotifyKind, SessionState, UnavailableReason, @@ -29,7 +29,7 @@ pub fn get_keychain(name: &str) -> Result> { /// Production lock-state lookup (uncached). Cheap; called at most once per /// second via `screen_state_cached` plus once per `evaluate_policy(false)` /// re-check. -fn screen_state_now() -> SessionState { +pub(crate) fn screen_state_now() -> SessionState { classify_session(cgsession::fetch_flags()) } @@ -119,10 +119,36 @@ mod cgsession { } } +/// When the running binary lives inside an `.app` bundle +/// (`…/Contents/MacOS/`), return the path of the bundled `VTApp` shell +/// binary, which doubles as the `UNUserNotificationCenter` helper +/// (docs/app-bundle.md §3). The notification then carries VT's own bundle +/// identity/icon instead of Script Editor's. The shell is named `VTApp` +/// because the default APFS volume is case-insensitive — `VT` would collide +/// with the `vt` CLI beside it. +fn bundle_notify_helper() -> Option { + let exe = std::env::current_exe().ok()?; + let macos_dir = exe.parent()?; + if macos_dir.file_name()? != "MacOS" { + return None; + } + let contents = macos_dir.parent()?; + if contents.file_name()? != "Contents" + || contents.parent()?.extension().is_none_or(|e| e != "app") + { + return None; + } + let helper = macos_dir.join("VTApp"); + (helper.is_file() && helper != exe).then_some(helper) +} + fn notify_macos(title: &str, body: &str) { - // Sanitize BOTH fields identically before interpolating into AppleScript. - // All current callers pass static titles, but sanitizing the title too - // removes a latent injection if a future caller ever passes dynamic text. + // Sanitize BOTH fields identically before they leave the agent — the + // same filter guards the AppleScript interpolation of the fallback and + // the argv of the helper (control chars could still garble the native + // notification UI). All current callers pass static titles, but + // sanitizing the title too removes a latent injection if a future + // caller ever passes dynamic text. let sanitize = |s: &str, max: usize| -> String { s.chars() .filter(|c| !c.is_control() && *c != '"' && *c != '\\') @@ -131,14 +157,33 @@ fn notify_macos(title: &str, body: &str) { }; let safe = sanitize(body, 150); let safe_title = sanitize(title, 100); - let script = format!( - r#"display notification "{}" with title "{}""#, - safe, safe_title - ); - let _ = std::process::Command::new("osascript") - .arg("-e") - .arg(script) - .status(); + + // Fire-and-forget on a reaper thread: notifying must never add latency + // to (or fail) the operation that triggered it, and the helper's + // first-use permission dialog has unbounded latency. The thread waits on + // the child so no zombie is left; the ≥30 s per-kind throttle bounds + // thread churn. + std::thread::spawn(move || { + if let Some(helper) = bundle_notify_helper() { + // Argv only — no shell, no AppleScript interpolation. + let ok = std::process::Command::new(&helper) + .args(["notify", "--title", &safe_title, "--body", &safe]) + .status() + .is_ok_and(|s| s.success()); + if ok { + return; + } + tracing::debug!("bundled notify helper failed, falling back to osascript"); + } + let script = format!( + r#"display notification "{}" with title "{}""#, + safe, safe_title + ); + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .status(); + }); } /// The `reason` we get is the full Touch ID prompt body — now multi-line @@ -149,6 +194,27 @@ fn first_line(s: &str) -> &str { s.split('\n').next().unwrap_or(s) } +/// Cache-hit transparency notification (docs/app-bundle.md §3). Called by +/// the agent only AFTER `permit.commit()` returned — never while a permit +/// (and thus the security read gate) is live — and is itself fire-and-forget +/// via `notify_macos`'s reaper thread. Throttled per kind so a burst +/// (multi-sign `git push`) notifies once per 30 s window. +pub(super) fn notify_cache_hit(operation: &str, scope_display: &str, remaining: Option) { + if !notify_throttle_should_fire(NotifyKind::CacheHit) { + tracing::debug!("cache-hit notification suppressed (throttled)"); + return; + } + let left = match remaining { + Some(d) => { + let secs = d.as_secs(); + format!(" · {}m{:02}s left", secs / 60, secs % 60) + } + None => String::new(), + }; + let body = format!("{operation} · {scope_display}{left}"); + notify_macos("VT: cached grant used (no Touch ID)", &body); +} + fn notify_touch_id_rejected(reason: &str) { if !notify_throttle_should_fire(NotifyKind::TouchIdRejected) { tracing::debug!("Touch ID rejection notification suppressed (throttled)"); @@ -422,10 +488,7 @@ pub fn local_authentication(reason: &str) -> bool { /// `vt secret import`, and `vt secret rotate-passcode` — all three either /// create the store fresh (init/import) or replace it wholesale (rotate), /// so this single call is the only write. -pub fn create_and_save_passcode_passphrase( - real_passphrase: &[u8; 32], - bin_path: Option<&str>, -) -> Result<()> { +pub fn create_and_save_passcode_passphrase(real_passphrase: &[u8; 32]) -> Result<()> { use super::store::KeychainStore; let origin_auth_token = AesGcmCrypto::generate_key(); @@ -438,7 +501,8 @@ pub fn create_and_save_passcode_passphrase( passcode_and_auth_token.extend_from_slice(&passcode); passcode_and_auth_token.extend_from_slice(&auth_token); - let passphrase_secret = derive_passphrase_secret(&passcode, bin_path)?; + // New stores are always wrap v2 (KeychainStore::new sets wrap_v). + let passphrase_secret = derive_passphrase_secret_v2(&passcode)?; let aes = AesGcmCrypto::new(&passphrase_secret)?; let encrypted_passphrase = aes.encrypt(real_passphrase)?; @@ -468,39 +532,144 @@ pub fn create_and_save_passcode_passphrase( /// key (needed as HKDF IKM for v2 envelope DEK derivation). The raw key is /// returned in a `Zeroizing` wrapper so it is wiped from memory on drop; /// callers should drop it as soon as derivation is complete. -pub fn load_mac_cipher( +fn load_mac_key( store: &super::store::KeychainStore, passphrase_cipher: &AesGcmCrypto, -) -> Result<(AesGcmCrypto, Zeroizing<[u8; 32]>)> { +) -> Result> { let encrypted_passphrase = store.encrypted_passphrase_bytes()?; let decrypted_passphrase = Zeroizing::new(passphrase_cipher.decrypt(&encrypted_passphrase)?); let mut key = Zeroizing::new([0u8; 32]); let slice: &[u8; 32] = decrypted_passphrase.as_slice().try_into()?; key.copy_from_slice(slice); + Ok(key) +} + +/// Preflight the encrypted master key without constructing a long-lived cipher +/// or retaining raw key material across a human authorization prompt. +pub(crate) fn validate_mac_key_material( + store: &super::store::KeychainStore, + passphrase_cipher: &AesGcmCrypto, +) -> Result<()> { + drop(load_mac_key(store, passphrase_cipher)?); + Ok(()) +} + +pub fn load_mac_cipher( + store: &super::store::KeychainStore, + passphrase_cipher: &AesGcmCrypto, +) -> Result<(AesGcmCrypto, Zeroizing<[u8; 32]>)> { + let key = load_mac_key(store, passphrase_cipher)?; let cipher = AesGcmCrypto::new(&key)?; Ok((cipher, key)) } /// Derive `(auth_cipher, passphrase_cipher)` from the passcode bytes inside -/// an already-loaded store. Pure CPU work; does not touch the keychain. +/// an already-loaded store, selecting the wrap derivation by `store.wrap_v`. +/// Pure CPU work; does not touch the keychain. pub fn derive_passcode_ciphers( store: &super::store::KeychainStore, ) -> Result<(AesGcmCrypto, AesGcmCrypto)> { + let (passcode_arr, auth_token) = split_passcode(store)?; + let passphrase_secret = wrap_secret_for(store.wrap_v, &passcode_arr, None)?; + let passphrase_cipher = AesGcmCrypto::new(&passphrase_secret)?; + let auth_cipher = AesGcmCrypto::new(&auth_token)?; + + Ok((auth_cipher, passphrase_cipher)) +} + +fn split_passcode(store: &super::store::KeychainStore) -> Result<([u8; 32], [u8; 32])> { let passcode = store.passcode_and_auth_token_bytes()?; ensure!( passcode.len() == 64, "Passcode length is {}, expected 64", passcode.len() ); - let passcode_arr: [u8; 32] = passcode[..32].try_into()?; - let auth_token: [u8; 32] = passcode[32..].try_into()?; + Ok((passcode[..32].try_into()?, passcode[32..].try_into()?)) +} - let passphrase_secret = derive_passphrase_secret(&passcode_arr, None)?; - let passphrase_cipher = AesGcmCrypto::new(&passphrase_secret)?; - let auth_cipher = AesGcmCrypto::new(&auth_token)?; +/// Wrap-key derivation for a given `wrap_v`. `bin_path` only applies to v1 +/// (`None` → `current_exe()`); unknown versions error out so a store written +/// by a newer vt fails loudly instead of mis-deriving. +fn wrap_secret_for(wrap_v: u32, passcode: &[u8; 32], bin_path: Option<&str>) -> Result<[u8; 32]> { + use super::store::{WRAP_V1, WRAP_V2}; + match wrap_v { + WRAP_V1 => derive_passphrase_secret(passcode, bin_path), + WRAP_V2 => derive_passphrase_secret_v2(passcode), + other => anyhow::bail!( + "rusty.vault.store has wrap version {other}, this binary supports up to {WRAP_V2} — upgrade vt" + ), + } +} - Ok((auth_cipher, passphrase_cipher)) +/// Rewrap `encrypted_passphrase` in place to `target_wrap` (v2 label, or v1 +/// bound to THIS binary's path for `--to-v1`). Pure over the store value — +/// callers wrap it in `KeychainStore::modify` for the cross-process flock. +/// Tries, in order: the store's recorded wrap, then (for v1 stores) the +/// explicit `old_bin_path` string — the old binary need not exist, only its +/// path enters the derivation. Preserves `passcode_and_auth_token` (VT_AUTH), +/// `encrypted_ssh_keys`, and `encrypted_fido2` untouched. +pub(super) fn rewrap_passphrase( + store: &mut super::store::KeychainStore, + old_bin_path: Option<&str>, + target_wrap: u32, +) -> Result<()> { + use super::store::WRAP_V1; + let (passcode_arr, _) = split_passcode(store)?; + let encrypted = store.encrypted_passphrase_bytes()?; + + let mut candidates: Vec<(u32, Option)> = vec![(store.wrap_v, None)]; + if store.wrap_v == WRAP_V1 { + if let Some(p) = old_bin_path { + candidates.push((WRAP_V1, Some(p.to_string()))); + } + } + + let mut passphrase: Option>> = None; + for (wrap_v, path) in &candidates { + let secret = wrap_secret_for(*wrap_v, &passcode_arr, path.as_deref())?; + if let Ok(plain) = AesGcmCrypto::new(&secret)?.decrypt(&encrypted) { + passphrase = Some(Zeroizing::new(plain)); + break; + } + } + let passphrase = passphrase.ok_or_else(|| { + anyhow::anyhow!( + "could not unwrap the master passphrase with the store's recorded wrap \ + (v{}){} — pass --old-bin-path ", + store.wrap_v, + if old_bin_path.is_some() { " or the given --old-bin-path" } else { "" } + ) + })?; + + let new_secret = wrap_secret_for(target_wrap, &passcode_arr, None)?; + let rewrapped = AesGcmCrypto::new(&new_secret)?.encrypt(&passphrase)?; + store.set_encrypted_passphrase(&rewrapped, target_wrap); + Ok(()) +} + +/// Transparent wrap v1→v2 upgrade, run at agent startup (docs/app-bundle.md +/// §2). Under the store flock: re-checks `wrap_v == 1`, rewraps only +/// `encrypted_passphrase`, never touches passcode/auth_token/SSH/FIDO2 +/// blobs. Returns Ok(false) if the store is absent or already v2; unwrap +/// failure (binary already moved before upgrading) is left to +/// `vt secret rebind` and reported as an error for the caller to log. +pub fn upgrade_wrap_v2_if_needed() -> Result { + use super::store::{KeychainStore, WRAP_V1, WRAP_V2}; + if !matches!(KeychainStore::load(), Ok(s) if s.wrap_v == WRAP_V1) { + return Ok(false); + } + let mut upgraded = false; + KeychainStore::modify(|store| { + // Re-check under the lock: another process may have upgraded first. + if store.wrap_v != WRAP_V1 { + return Ok(()); + } + rewrap_passphrase(store, None, WRAP_V2)?; + upgraded = true; + Ok(()) + })?; + Ok(upgraded) } @@ -514,10 +683,96 @@ mod tests { #[ignore] fn test_create_and_save_passcode_passphrase() { let real_passphrase = AesGcmCrypto::generate_key(); - let result = create_and_save_passcode_passphrase(&real_passphrase, None); + let result = create_and_save_passcode_passphrase(&real_passphrase); assert!(result.is_ok()) } + /// Pure rewrap round-trip over an in-memory store: v1 (explicit old + /// path) -> v2 -> v1, asserting the master passphrase survives and the + /// non-passphrase fields are byte-for-byte untouched. No keychain access. + #[test] + fn test_rewrap_round_trip_preserves_store() { + use super::super::store::{KeychainStore, WRAP_V1, WRAP_V2}; + use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; + + let passcode = AesGcmCrypto::generate_key(); + let auth_token = AesGcmCrypto::generate_key(); + let mut passcode_and_auth_token = Vec::new(); + passcode_and_auth_token.extend_from_slice(&passcode); + passcode_and_auth_token.extend_from_slice(&auth_token); + + let master = AesGcmCrypto::generate_key(); + let old_path = "/old/install/location/vt"; + let v1_secret = derive_passphrase_secret(&passcode, Some(old_path)).unwrap(); + let v1_wrapped = AesGcmCrypto::new(&v1_secret).unwrap().encrypt(&master).unwrap(); + + let mut store = KeychainStore::new(&passcode_and_auth_token, &v1_wrapped); + store.set_encrypted_passphrase(&v1_wrapped, WRAP_V1); + store.set_encrypted_ssh_keys(b"ssh-blob"); + store.set_encrypted_fido2(b"fido2-blob"); + let tokens_before = store.passcode_and_auth_token.clone(); + + // v1 (old path) -> v2: recorded-wrap candidate fails (current_exe != + // old_path), the explicit old path candidate succeeds. + rewrap_passphrase(&mut store, Some(old_path), WRAP_V2).unwrap(); + assert_eq!(store.wrap_v, WRAP_V2); + let v2_secret = derive_passphrase_secret_v2(&passcode).unwrap(); + let unwrapped = AesGcmCrypto::new(&v2_secret) + .unwrap() + .decrypt(&store.encrypted_passphrase_bytes().unwrap()) + .unwrap(); + assert_eq!(unwrapped, master); + + // v2 -> v1 (current_exe binding), the --to-v1 escape hatch. + rewrap_passphrase(&mut store, None, WRAP_V1).unwrap(); + assert_eq!(store.wrap_v, WRAP_V1); + let v1_now = derive_passphrase_secret(&passcode, None).unwrap(); + let unwrapped = AesGcmCrypto::new(&v1_now) + .unwrap() + .decrypt(&store.encrypted_passphrase_bytes().unwrap()) + .unwrap(); + assert_eq!(unwrapped, master); + + // Everything except the passphrase wrap is untouched. + assert_eq!(store.passcode_and_auth_token, tokens_before); + assert_eq!( + store.encrypted_ssh_keys.as_deref(), + Some(BASE64_URL_SAFE_NO_PAD.encode(b"ssh-blob").as_str()) + ); + assert_eq!( + store.encrypted_fido2.as_deref(), + Some(BASE64_URL_SAFE_NO_PAD.encode(b"fido2-blob").as_str()) + ); + } + + /// A wrap version this binary does not know must fail loudly, and a + /// failed unwrap must name the remedy without leaking key material. + #[test] + fn test_rewrap_unknown_and_wrong_path_errors() { + use super::super::store::{KeychainStore, WRAP_V1, WRAP_V2}; + + let passcode = AesGcmCrypto::generate_key(); + let mut tokens = Vec::new(); + tokens.extend_from_slice(&passcode); + tokens.extend_from_slice(&AesGcmCrypto::generate_key()); + + let master = AesGcmCrypto::generate_key(); + let v1_secret = derive_passphrase_secret(&passcode, Some("/gone/vt")).unwrap(); + let wrapped = AesGcmCrypto::new(&v1_secret).unwrap().encrypt(&master).unwrap(); + let mut store = KeychainStore::new(&tokens, &wrapped); + store.set_encrypted_passphrase(&wrapped, WRAP_V1); + + // No candidate matches: recorded wrap derives from current_exe, and + // the supplied old path is wrong. + let err = rewrap_passphrase(&mut store, Some("/also/wrong/vt"), WRAP_V2).unwrap_err(); + assert!(err.to_string().contains("--old-bin-path"), "{err}"); + assert_eq!(store.wrap_v, WRAP_V1, "failed rewrap must not mutate the store"); + + store.wrap_v = 99; + let err = rewrap_passphrase(&mut store, None, WRAP_V2).unwrap_err(); + assert!(err.to_string().contains("wrap version 99"), "{err}"); + } + #[test] #[ignore] fn test_encrypt_body() { diff --git a/src/server_macos/ssh_agent.rs b/src/server_macos/ssh_agent.rs index f848a05..55fe648 100644 --- a/src/server_macos/ssh_agent.rs +++ b/src/server_macos/ssh_agent.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; @@ -9,29 +9,35 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use ssh_agent_lib::agent::{listen, Agent, Session}; use ssh_agent_lib::error::AgentError; +use ssh_agent_lib::proto::extension::SessionBind; use ssh_agent_lib::proto::{ AddIdentity, Credential, Extension, Identity, RemoveIdentity, SignRequest, Unparsed, }; use ssh_key::private::{KeypairData, PrivateKey}; use ssh_key::public::KeyData; use ssh_key::{Algorithm, HashAlg, Signature}; -use tokio::sync::{RwLock, Semaphore}; +use tokio::sync::{Mutex, RwLock}; +use crate::core::authorization::{ + AuthorizationEngine, AuthorizationFailure, AuthorizationPermit, AuthorizationRequest, + CommitError, Decision, GrantScope, Operation, ReusePolicy, ScopeFamily, SubjectId, +}; use crate::core::crypto::{derive_dek, AesGcmCrypto}; -use crate::core::session::{AuthOutcome, UnavailableReason}; +use crate::core::session::AuthOutcome; use crate::core::wire::{ outcome_to_err_strict, wrap_ok_envelope, ErrKind, WIRE_VERSION, }; use crate::core::{ legacy_decrypt, AuthReq, AuthRes, ContextBasis, DecryptInput, DecryptReq, DecryptResItem, DiagCacheReport, DiagPeerReport, DiagReq, DiagRes, EncryptReq, EncryptResItem, RunReq, - RunRes, SignReq, SignRes, SALT_LEN, + RunRes, SignReq, SignRes, UiStatusReq, UiStatusRes, SALT_LEN, }; use rand::RngCore; use zeroize::{Zeroize, Zeroizing}; -use super::security::{authenticate, derive_passcode_ciphers, load_mac_cipher}; +use super::authorization::{new_engine, sleep_diverged}; +use super::security::{derive_passcode_ciphers, load_mac_cipher, validate_mac_key_material}; use super::store::KeychainStore; -use super::audit::{self, AgentAuditEntry, AuditPushConfig}; +use super::audit::{self, AgentAuditContext, AgentAuditEntry, AuditPushConfig}; /// SSH agent extension names used by vt. pub const EXT_ENCRYPT: &str = "encrypt@vt"; @@ -40,6 +46,10 @@ pub const EXT_AUTH: &str = "auth@vt"; pub const EXT_RUN: &str = "run@vt"; pub const EXT_SIGN: &str = "sign@vt"; pub const EXT_DIAG: &str = "diag@vt"; +/// Token-gated shell status/revoke channel (docs/app-bundle.md §5). +/// Plaintext (pre-cipher) like `session-bind@openssh.com`; NOT in the vt +/// cipher-path match below and NOT permitted by the relay filter. +pub const EXT_UI_STATUS: &str = "ui-status@vt"; fn agent_err(e: anyhow::Error) -> AgentError { AgentError::Other(Box::new(std::io::Error::new( @@ -86,189 +96,22 @@ pub fn encode_ssh_keys_into( Ok(()) } -// --- Auth Cache --- - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthCacheMode { - None, - PerSession, - PerApp, - /// One shared context for the whole agent process — no TTY or session - /// requirement. The only mode that serves orchestrated callers (AI - /// agents, CI, make) whose spawned commands have no controlling terminal - /// and a fresh session per call, so per-session/per-app can never hit. - /// Coarsest blast radius: within the TTL, ANY caller that can reach the - /// socket (including every session of a forwarded agent) rides one grant. - Global, -} - -impl FromStr for AuthCacheMode { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - match s.to_lowercase().as_str() { - "none" => Ok(AuthCacheMode::None), - "per-session" | "per_session" | "session" => Ok(AuthCacheMode::PerSession), - "per-app" | "per_app" | "app" => Ok(AuthCacheMode::PerApp), - "global" => Ok(AuthCacheMode::Global), - _ => Err(format!( - "invalid auth cache mode '{}': expected none, per-session, per-app, or global", - s - )), - } - } -} - -impl std::fmt::Display for AuthCacheMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AuthCacheMode::None => write!(f, "none"), - AuthCacheMode::PerSession => write!(f, "per-session"), - AuthCacheMode::PerApp => write!(f, "per-app"), - AuthCacheMode::Global => write!(f, "global"), - } - } -} - -/// Auth cache context: `(context_id, context_start_tvsec)`. -/// -/// Including the process start time defeats PID/TTY-device reuse: if the -/// original context process (app or TTY owner) exits and its PID or tdev is -/// recycled, the new process has a different start time and won't match a -/// cached grant. -pub type CacheContext = (u64, u64); - -/// A cache entry's expiry, tracked on two clocks. -/// -/// `std::time::Instant` on macOS reads `CLOCK_UPTIME_RAW`, which does NOT -/// advance while the system is asleep — an expiry tracked only on `Instant` -/// pauses its countdown when the lid closes, so a grant issued just before -/// sleep would still be live on wake hours (or days) later. The wall clock -/// keeps counting through sleep but can be stepped backwards (NTP), which -/// would stretch a wall-only expiry. An entry is therefore valid only while -/// BOTH clocks agree: `mono` caps total awake time at the TTL, `wall` caps -/// total real time at the TTL. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CacheExpiry { - mono: Instant, - wall: SystemTime, -} - -impl CacheExpiry { - fn is_valid_at(&self, now_mono: Instant, now_wall: SystemTime) -> bool { - now_mono < self.mono && now_wall < self.wall - } -} - -pub struct AuthCache { - /// Each entry stores when it expires (computed at grant time). - entries: HashMap<(CacheContext, String), CacheExpiry>, - /// Lifetime of a fresh grant. - ttl: Duration, -} - -impl AuthCache { - pub fn new(ttl_secs: u64) -> Self { - Self { - entries: HashMap::new(), - ttl: Duration::from_secs(ttl_secs), - } - } - - /// `key` is a composed lookup digest ([`sign_cache_key`] / - /// [`decrypt_cache_key`]), not a raw fingerprint. - pub fn is_authorized(&self, context: CacheContext, key: &str) -> bool { - self.is_authorized_at(context, key, Instant::now(), SystemTime::now()) - } - - /// Clock-injectable core of [`is_authorized`], unit-testable for the - /// sleep scenario (mono frozen, wall advanced) without real sleeping. - fn is_authorized_at( - &self, - context: CacheContext, - key: &str, - now_mono: Instant, - now_wall: SystemTime, - ) -> bool { - self.entries - .get(&(context, key.to_string())) - .is_some_and(|e| e.is_valid_at(now_mono, now_wall)) - } - - /// Strict-TTL grant: a still-valid entry's expiry is left untouched. - /// Concurrent grants for the same key cannot extend the original TTL. - /// Expired entries (or absent ones) are replaced with a fresh expiry. - pub fn grant(&mut self, context: CacheContext, key: &str) { - self.grant_at(context, key, Instant::now(), SystemTime::now()); - } - - fn grant_at( - &mut self, - context: CacheContext, - key: &str, - now_mono: Instant, - now_wall: SystemTime, - ) { - let fresh = CacheExpiry { - mono: now_mono + self.ttl, - wall: now_wall + self.ttl, - }; - self.entries - .entry((context, key.to_string())) - .and_modify(|e| { - if !e.is_valid_at(now_mono, now_wall) { - *e = fresh; - } - }) - .or_insert(fresh); - } - - /// Drop every entry; returns how many were dropped (for flush logging). - pub fn clear(&mut self) -> usize { - let n = self.entries.len(); - self.entries.clear(); - n - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - pub fn sweep_expired(&mut self) { - let now_mono = Instant::now(); - let now_wall = SystemTime::now(); - self.entries.retain(|_, e| e.is_valid_at(now_mono, now_wall)); - } - - pub fn ttl_secs(&self) -> u64 { - self.ttl.as_secs() - } - - /// Diagnostics (`diag@vt`): currently-valid entries for `context` ONLY — - /// same dual-clock predicate as [`is_authorized`]. Deliberately scoped to - /// one context so a caller (possibly a relayed remote) never learns how - /// many grants other sessions/tabs hold. - pub fn live_len(&self, context: CacheContext) -> usize { - self.live_len_at(context, Instant::now(), SystemTime::now()) - } +// --- Reuse policy ----------------------------------------------------------- - /// Clock-injectable core of [`live_len`] (see [`is_authorized_at`]). - fn live_len_at( - &self, - context: CacheContext, - now_mono: Instant, - now_wall: SystemTime, - ) -> usize { - self.entries - .iter() - .filter(|((c, _), e)| *c == context && e.is_valid_at(now_mono, now_wall)) - .count() +/// Human-readable duration for the prompt reuse line ("8h", "90m", "45s"). +fn reuse_ttl_label(secs: u64) -> String { + if secs % 3600 == 0 { + format!("{}h", secs / 3600) + } else if secs % 60 == 0 { + format!("{}m", secs / 60) + } else { + format!("{}s", secs) } } /// True when `timeout` has elapsed since the last activity on EITHER clock — -/// the same dual-clock rule as [`CacheExpiry`] (which documents why), applied -/// to the idle timeout: without the wall clock a laptop that naps often could +/// the same dual-clock rule as authorization grants, applied to the idle +/// timeout: without the wall clock a laptop that naps often could /// stay "active" for days and never drop its keys. fn idle_exceeded( last_mono: Instant, @@ -277,22 +120,18 @@ fn idle_exceeded( now_wall: SystemTime, timeout: Duration, ) -> bool { - let deadline = CacheExpiry { - mono: last_mono + timeout, - wall: last_wall + timeout, - }; - !deadline.is_valid_at(now_mono, now_wall) + last_mono + .checked_add(timeout) + .is_some_and(|deadline| now_mono >= deadline) + || last_wall + .checked_add(timeout) + .is_some_and(|deadline| now_wall >= deadline) } // --- Cache watcher (screen lock + sleep/wake invalidation) --- /// How often the cache watcher samples screen-lock state and clock skew. const WATCHER_TICK: Duration = Duration::from_secs(5); -/// Wall clock advancing this much further than the monotonic clock between -/// two watcher ticks means the system slept (mono pauses during sleep). -/// Large enough to also absorb small NTP steps without spurious clears. -const WATCHER_SLEEP_DIVERGENCE: Duration = Duration::from_secs(30); - /// Decide whether the auth caches must be flushed for this watcher tick. /// /// Two triggers, both "the human walked away" signals that must revoke @@ -301,10 +140,11 @@ const WATCHER_SLEEP_DIVERGENCE: Duration = Duration::from_secs(30); /// -switched, or logged out). Only the *transition* clears, so a grant /// issued after unlock isn't immediately eaten by the steady locked /// state of some other display. -/// 2. Sleep detected: wall time advanced ≥ [`WATCHER_SLEEP_DIVERGENCE`] +/// 2. Sleep detected: wall time advanced by the shared sleep-divergence +/// threshold /// more than monotonic time since the previous tick. `wall_delta` is /// `None` when the wall clock stepped backwards — not a sleep signal; -/// the dual-clock TTL ([`CacheExpiry`]) still bounds those entries. +/// the authorization engine's dual-clock TTL still bounds those entries. fn watcher_should_clear( was_interactive: bool, is_interactive: bool, @@ -314,10 +154,29 @@ fn watcher_should_clear( if was_interactive && !is_interactive { return true; } - match wall_delta { - Some(wall) => wall.saturating_sub(mono_delta) >= WATCHER_SLEEP_DIVERGENCE, - None => false, - } + sleep_diverged(mono_delta, wall_delta) +} + +/// Wipe the decrypted-key map from RAM and mark `idle_cleared` so the next +/// interactive request silently reloads (docs/app-bundle.md §10). The single +/// source of the "wipe → later silent reload" handshake, shared by the idle +/// sweeper and the screen-lock/wake watcher so the flag can't be forgotten in +/// one of them. Returns the number of keys cleared. NOTE: the `ssh-add -x` +/// lock finalizer clears keys WITHOUT this flag on purpose — it relies on the +/// `locked` gate (not `idle_cleared`) to refuse reload — so it does not use +/// this helper. +async fn clear_keys_for_reload( + keys: &Arc>>, + idle_cleared: &Arc>, +) -> usize { + let mut guard = keys.write().await; + let count = guard.len(); + if count > 0 { + guard.clear(); + drop(guard); + *idle_cleared.write().await = true; + } + count } // --- macOS process introspection --- @@ -401,9 +260,8 @@ mod proc_info { } /// Get the controlling TTY device number for a process. Returns None - /// for processes with no controlling terminal (`tdev == 0`) so the - /// caller can treat them as uncacheable — otherwise every daemon - /// without a TTY would share a single cache slot keyed on 0. + /// for processes with no controlling terminal (`tdev == 0`). Diag-only + /// since scopes V2; behavior must never depend on it. pub fn get_tty_dev(pid: i32) -> Option { let (_, tdev, _) = get_proc_bsdinfo(pid)?; if tdev == 0 { @@ -413,6 +271,58 @@ mod proc_info { } } + const PROC_PIDVNODEPATHINFO: libc::c_int = 9; + + /// Layout mirror of `struct vnode_info_path` from ``: + /// `struct vnode_info` (a 136-byte `vinfo_stat` + type/pad/fsid = 152 + /// bytes, opaque here) followed by a MAXPATHLEN path buffer. + #[repr(C)] + struct VnodeInfoPath { + vip_vi: [u8; 152], + vip_path: [u8; MAXPATHLEN as usize], + } + + #[repr(C)] + struct ProcVnodePathInfo { + pvi_cdir: VnodeInfoPath, + pvi_rdir: VnodeInfoPath, + } + + /// NUL-terminated kernel path buffer → PathBuf. `None` on a missing + /// NUL, an empty path, or non-UTF-8 bytes (callers degrade to Fresh). + pub fn nul_terminated_path(buf: &[u8]) -> Option { + let len = buf.iter().position(|&b| b == 0)?; + if len == 0 { + return None; + } + let s = std::str::from_utf8(&buf[..len]).ok()?; + Some(std::path::PathBuf::from(s)) + } + + /// Kernel-derived current working directory of `pid` + /// (`proc_pidinfo(PROC_PIDVNODEPATHINFO)` → `pvi_cdir.vip_path`). Same + /// trust level as `get_proc_path`; `None` on any failure. The strict + /// `ret == size` check makes a layout mismatch fail closed instead of + /// reading a truncated struct. + pub fn get_cwd(pid: i32) -> Option { + const _: () = assert!(std::mem::size_of::() == 2 * (152 + 1024)); + let mut info: ProcVnodePathInfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDVNODEPATHINFO, + 0, + &mut info as *mut _ as *mut libc::c_void, + size, + ) + }; + if ret != size { + return None; + } + nul_terminated_path(&info.pvi_cdir.vip_path) + } + /// Get the process start time (seconds since epoch). pub fn get_start_tvsec(pid: i32) -> Option { get_proc_bsdinfo(pid).map(|(_, _, s)| s) @@ -462,53 +372,6 @@ mod proc_info { crate::ssh_sign::parse_procargs2(&buf) } - /// Get the session leader PID (POSIX sid) for `pid`. - /// - /// Under macOS terminal stacks `forkpty()` calls `setsid()` in the child - /// before exec, so the resulting shell IS the session leader of its own - /// session. `getsid()` therefore returns: - /// - Terminal.app/iTerm direct shell → that shell's PID - /// - tmux/screen pane shell → the pane's shell PID (the - /// multiplexer server has its own - /// separate session) - /// - ssh-spawned remote shell → that shell's PID - /// - nested shells (no setsid) → the outer shell's PID - pub fn get_sid(pid: i32) -> Option { - let sid = unsafe { libc::getsid(pid) }; - if sid > 0 { - Some(sid) - } else { - None - } - } - - /// Walk the process tree upward to find a `.app/Contents/` ancestor. - /// Returns `None` when there is no app ancestor (tmux panes — the tmux - /// server re-parents to launchd — ssh logins, bare console sessions). - /// The caller must treat that as uncacheable: the previous fallback of - /// keying on the short-lived `peer_pid` itself produced a context that - /// could never hit again but still inserted a dead entry per call. - pub fn find_app_pid(peer_pid: i32) -> Option { - let mut current_pid = peer_pid; - // Limit traversal to prevent infinite loops - for _ in 0..64 { - if current_pid <= 1 { - break; - } - if let Some(path) = get_proc_path(current_pid) { - if path.contains(".app/Contents/") { - return Some(current_pid); - } - } - match get_proc_bsdinfo(current_pid) { - Some((ppid, _, _)) if ppid > 0 && ppid as i32 != current_pid => { - current_pid = ppid as i32; - } - _ => break, - } - } - None - } } // --- SSH Agent --- @@ -519,9 +382,10 @@ use crate::core::sanitize_for_display as sanitize_prompt; use crate::core::sanitize_for_display_multiline as sanitize_prompt_multiline; /// Per-line cap and total-line cap for the multi-line `command` body the CLI -/// sends. The CLI builds something like `op: inject\nfile: …\ncmd: …\nreason: …` -/// so 6 lines is enough headroom; further lines from a hostile peer are -/// silently dropped so the dialog can't be pushed off-screen. +/// sends. The CLI builds something like `file: …\ncmd: …\nreason: …` (older +/// clients prepend `op: inject`), so 6 lines is enough headroom; further +/// lines from a hostile peer are silently dropped so the dialog can't be +/// pushed off-screen. const PROMPT_COMMAND_MAX_LINES: usize = 6; const PROMPT_COMMAND_MAX_LINE_LEN: usize = 120; @@ -747,79 +611,26 @@ const RUN_ENV_PASSTHROUGH: &[&str] = &[ "LC_ALL", "LC_CTYPE", "DISPLAY", ]; -/// Default idle timeout: 30 minutes. -pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 30 * 60; -/// Default auth cache duration for sign: 2 minutes. -pub const DEFAULT_AUTH_CACHE_DURATION_SECS: u64 = 120; -/// Default auth cache duration for decrypt@vt: 30 seconds. -/// Shorter than sign because a cached decrypt grant releases per-record DEK -/// material, whose blast radius is wider than a single-challenge signature. -pub const DEFAULT_DECRYPT_AUTH_CACHE_DURATION_SECS: u64 = 30; - -/// Configuration for one of the agent's auth caches. Carries (mode, ttl) -/// together so the sign and decrypt caches can't have their fields -/// accidentally swapped at any of the call layers. -#[derive(Debug, Clone, Copy)] -pub struct AuthCacheConfig { - pub mode: AuthCacheMode, - pub ttl_secs: u64, -} - -/// Derive the decrypt-cache lookup string for a single v2 item. -/// -/// Domain-tagged so a digest from this hash can never collide with a digest -/// from any other use of SHA-256 in the codebase. `host` and `pwd` are -/// length-prefixed so the boundaries between fields are unambiguous. +/// Default idle timeout: 2 hours. /// -/// `host` and `pwd` come from the client-supplied request and are NOT -/// trusted for security — they only partition the cache, so different -/// hostnames / working directories don't share entries. This mirrors the -/// advisory `pwd` component of the Worker-side DEK cache: a compromised peer -/// can lie to force a miss (DoS) or to reuse another directory's still-cached -/// grant on the same host, but it never widens the hard context boundary -/// (the peer process tree). The pwd component is what scopes `global`-mode -/// grants to one project tree for orchestrated callers. -fn decrypt_cache_key( - t: crate::core::SecretType, - salt: &[u8; SALT_LEN], - host: &str, - pwd: &str, -) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(b"vt-decrypt-cache-v2"); - h.update([t.as_byte()]); - h.update(salt); - h.update((host.len() as u32).to_le_bytes()); - h.update(host.as_bytes()); - h.update((pwd.len() as u32).to_le_bytes()); - h.update(pwd.as_bytes()); - hex_string(&h.finalize()) -} - -/// Derive the sign-cache lookup string: key fingerprint + client-reported -/// working directory. Same advisory-`pwd` rationale as [`decrypt_cache_key`]. -/// Plain SSH `sign` requests (agent protocol, no `ClientMeta`) pass an empty -/// pwd and so share one slot per fingerprint, unchanged from before; `sign@vt` -/// requests carry `meta.pwd` and are partitioned by it. -fn sign_cache_key(fingerprint: &str, pwd: &str) -> String { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(b"vt-sign-cache-v1"); - h.update((fingerprint.len() as u32).to_le_bytes()); - h.update(fingerprint.as_bytes()); - h.update((pwd.len() as u32).to_le_bytes()); - h.update(pwd.as_bytes()); - hex_string(&h.finalize()) -} - -fn hex_string(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(s, "{:02x}", b); - } - s +/// Idle timeout overlaps in purpose with screen-lock / sleep invalidation — +/// both are "the human walked away" backstops — and lock/sleep fire far +/// faster (seconds, via the cache watcher). On a Mac with prompt auto-lock +/// the idle timeout is largely redundant, so a longer default favors fewer +/// surprise re-prompts after a quiet spell while lock/sleep remain the fast +/// guard. It is still a real backstop for the unlocked-but-unattended +/// window; hosts without reliable auto-lock should lower it via +/// `--timeout` / `[agent].timeout`. +pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 2 * 60 * 60; + +/// Cache durations for the two reusable operations, carried together with +/// named fields so sign and decrypt cannot be transposed at any call layer — +/// they carry deliberately different blast radii (a cached decrypt grant +/// releases per-record DEK material). `0` = the engine's `Fresh` policy. +#[derive(Debug, Clone, Copy)] +pub struct AuthCacheTtls { + pub sign_secs: u64, + pub decrypt_secs: u64, } /// Load all SSH keys from the keychain store into a HashMap. The store and @@ -983,13 +794,16 @@ pub struct VtSshAgentFactory { /// Last request time on both clocks — see [`idle_exceeded`] for why the /// monotonic clock alone can't drive the idle timeout. last_activity: Arc>, - locked: Arc>, + locked: Arc, + /// Serializes SSH-agent lock/unlock state transitions. The synchronous + /// atomic remains the live authorization validator's fast state source. + lock_transition: Arc>, lock_passphrase: Arc>>, idle_cleared: Arc>, - sign_auth_cache: Arc>, - decrypt_auth_cache: Arc>, - sign_cache_mode: AuthCacheMode, - decrypt_cache_mode: AuthCacheMode, + authorization: Arc, + /// 0 = Fresh (always prompt); named fields prevent sign/decrypt + /// transposition (see [`AuthCacheTtls`]). + cache_ttls: AuthCacheTtls, /// When true, `decrypt@vt` rejects `Legacy` items (v0/v1 URLs). v2 envelope /// items continue to work. Lets users who have fully migrated harden the /// agent so the "agent emits plaintext over wire" path can never be @@ -997,236 +811,602 @@ pub struct VtSshAgentFactory { disable_legacy_decrypt: bool, /// run@vt allowlist. Empty = feature disabled. run_allow: Arc, - /// Global serializer for ALL human auth prompts; see - /// [`VtSshSession::authenticate_serialized`] for the rationale. - prompt_sem: Arc, /// Fire-and-forget audit push config. Cloned per session like `run_allow`. /// Disabled config = audit push is a no-op. audit_push: Arc, + /// Fire a system notification when a grant reuse satisfies sign/decrypt + /// without a prompt (docs/app-bundle.md §3). Default on. + notify_cache_hits: bool, + /// 32-byte spawn token read from `--ui-token-fd` at startup; gates + /// `ui-status@vt`. `None` (CLI-started agent) refuses every request. + ui_token: Option<[u8; 32]>, + /// Configured idle timeout (seconds) — reported over `ui-status@vt` so + /// the shell can display it (docs/app-bundle.md §10). + idle_timeout_secs: u64, } impl VtSshAgentFactory { fn new( keys: HashMap, - sign_cache: AuthCacheConfig, - decrypt_cache: AuthCacheConfig, + cache_ttls: AuthCacheTtls, disable_legacy_decrypt: bool, run_allow: RunAllowlist, audit_push: Arc, + notify_cache_hits: bool, + ui_token: Option<[u8; 32]>, + idle_timeout_secs: u64, ) -> Self { + let locked = Arc::new(AtomicBool::new(false)); + let authorization = new_engine(Arc::clone(&locked)); Self { keys: Arc::new(RwLock::new(keys)), last_activity: Arc::new(RwLock::new((Instant::now(), SystemTime::now()))), - locked: Arc::new(RwLock::new(false)), + locked, + lock_transition: Arc::new(Mutex::new(())), lock_passphrase: Arc::new(RwLock::new(None)), idle_cleared: Arc::new(RwLock::new(false)), - sign_auth_cache: Arc::new(RwLock::new(AuthCache::new(sign_cache.ttl_secs))), - decrypt_auth_cache: Arc::new(RwLock::new(AuthCache::new(decrypt_cache.ttl_secs))), - sign_cache_mode: sign_cache.mode, - decrypt_cache_mode: decrypt_cache.mode, + authorization, + cache_ttls, disable_legacy_decrypt, run_allow: Arc::new(run_allow), - prompt_sem: Arc::new(Semaphore::new(1)), audit_push, + notify_cache_hits, + ui_token, + idle_timeout_secs, } } } -/// Resolve the auth-cache context for this session. -/// -/// Returns `None` when the session must not be cached (no peer PID, -/// `tdev == 0` for per-session, or missing proc info). The context is -/// captured once at session creation — not recomputed per request — so a -/// cache lookup can never race proc-tree state between connection and the -/// actual `sign` call. -/// -/// `PerSession` keys on the **session leader's** PID and start time, not the -/// peer process's. The peer process (e.g. `gh`, `vt read`) is short-lived and -/// has a unique start_tvsec per invocation, so anchoring on it would prevent -/// the cache from ever hitting across separate CLI calls. The session leader -/// (the shell at the head of the pty — see `proc_info::get_sid`) lives for the -/// full terminal session, giving the cache a stable anchor. `tdev != 0` is -/// still required so daemons without a controlling terminal stay uncacheable. -/// -/// `Global` is the escape hatch for orchestrated callers (AI agents / CI / -/// make) whose spawned commands have **no controlling terminal and a fresh -/// session per call** — under per-session/per-app they resolve to `None` and -/// can never hit. It maps every peer to one fixed context; the only remaining -/// boundary is the TTL (plus the lock/wake/idle flushes). +// --- Peer classification (activity scopes V2) -------------------------------- +// +// The caller-topology cache modes are gone. Grants are keyed by activity: +// raw SSH signs by session-bind-verified destination (BindState, below), +// local vt peers by kernel-derived workspace, relay peers per connection. +// See docs/authorization-scopes-v2.md. + +/// A workspace the peer is operating in: the nearest `.git`-containing +/// ancestor of its kernel-derived cwd. `subject` is the root directory's +/// `(st_dev, st_ino)`; `root` is the canonical path captured from the SAME +/// file descriptor (fstat + F_GETPATH), so the pair cannot be split by a +/// rename between two lookups. The digest binds both. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Workspace { + subject: SubjectId, + /// Stored as `String` because it is only ever built from validated UTF-8 + /// and feeds the grant digest — a lossy conversion fallback here would + /// silently bind an empty root path. + root: String, +} + +impl Workspace { + fn root_str(&self) -> &str { + &self.root + } + + /// Client-reported `pwd` is display metadata, but if it is present and + /// does not lie inside this workspace the request degrades to Fresh — a + /// consistency check against confused callers, not a security boundary. + fn contains_claimed_pwd(&self, pwd: &str) -> bool { + pwd.is_empty() || std::path::Path::new(pwd).starts_with(std::path::Path::new(&self.root)) + } +} + +/// The caller's kernel-derived parent process, used as the activity identity +/// when the caller's cwd is a broad shared directory: "this application +/// instance keeps making the same request" (e.g. an app probing `gh` through +/// the hook from cwd `/`). `subject` is the parent's `(pid, start_tvsec)`, +/// so grants die when the app exits; `exe` is the parent's kernel-verified +/// executable path (`proc_pidpath`), bound into the digest — never the +/// client-claimed `ppid_cmd` string. +#[derive(Debug, Clone, PartialEq, Eq)] +struct AppIdentity { + subject: SubjectId, + exe: String, +} + +impl AppIdentity { + /// Display name: executable basename (the digest uses the full path). + fn name(&self) -> &str { + self.exe.rsplit('/').next().unwrap_or(&self.exe) + } +} + +/// The scope basis a resolved connection routes to. The three arms use +/// distinct digest domains, so a directory that gains or loses a `.git` +/// entry — or an app that later runs from a repository — starts a new grant +/// family instead of silently continuing the old grants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScopedBasis<'a> { + Git(&'a Workspace), + Cwd(&'a Workspace), + App(&'a AppIdentity), +} + +/// How the connection's workspace resolution ended, kept for diag. +#[derive(Debug, Clone, PartialEq, Eq)] +enum WorkspaceResolution { + /// Nearest `.git` ancestor of the kernel cwd. + Resolved(Workspace), + /// No usable `.git` ancestor: the kernel cwd directory itself, provided + /// it is not a broad shared bucket (see `cwd_fallback_acceptable`). + CwdFallback(Workspace), + /// Cwd is a broad shared bucket ($HOME, `/`, temp roots): the caller's + /// kernel-derived parent application. + AppFallback(AppIdentity), + /// Cwd too broad to scope AND no usable parent process (parent is + /// launchd / lookup failed): Fresh. + NoRoot, + /// No peer pid or proc/cwd lookup failed (also used for relay peers, + /// which never use workspace scopes). + Unavailable, +} + +impl WorkspaceResolution { + fn scoped(&self) -> Option> { + match self { + WorkspaceResolution::Resolved(ws) => Some(ScopedBasis::Git(ws)), + WorkspaceResolution::CwdFallback(ws) => Some(ScopedBasis::Cwd(ws)), + WorkspaceResolution::AppFallback(app) => Some(ScopedBasis::App(app)), + _ => None, + } + } +} + +/// Ascend from `start` to the nearest directory containing a `.git` entry +/// (dir or file — worktrees use a file). Depth-capped as a syscall bound on +/// pathological trees. +fn find_git_root(start: &std::path::Path) -> Option { + let mut dir = start; + for _ in 0..64 { + if dir.join(".git").exists() { + return Some(dir.to_path_buf()); + } + dir = dir.parent()?; + } + None +} + +/// A `.git` root is an acceptable workspace boundary only when it does not +/// pool unrelated work into one bucket: /// -/// **Forwarded-agent narrowing:** when the peer is the local OpenSSH client -/// (`ssh -A`, incl. a ControlMaster master carrying forwarded agent channels), -/// every mode — including `Global` — anchors the context on that ssh process -/// itself (`(pid, start)`), not on its terminal session or app ancestor. -/// Grants made over one ssh connection stay confined to that connection: the -/// remote host can reuse its own approvals within the TTL (the point of -/// caching while forwarded), but it can never ride grants issued by other -/// local tabs or other remote hosts, and the context dies with the ssh -/// process. An ssh process can coincide with a session leader only for its -/// own session, so the `(pid, start)` tuple can't collide with an unrelated -/// per-session context. +/// - a dotfiles repository AT `$HOME` (`git init ~`, yadm) would make every +/// caller anywhere under the home directory share one grant scope — the +/// exact broad bucket the `cwd_fallback_acceptable` exclusions exist to +/// prevent; +/// - symmetrically, when the cwd lives under `$HOME`, a root found ABOVE +/// `$HOME` (e.g. a stray `/Users/.git`) is rejected rather than pooling +/// every user directory. /// -/// Accepted consequence: the narrowing keys on the peer *process*, so it also -/// applies to plain outbound `ssh host` signs (the agent cannot distinguish -/// ssh authenticating itself from ssh relaying a forwarded request — both -/// arrive from the same process). Repeated one-shot ssh invocations therefore -/// no longer share a grant within the TTL; connections multiplexed through a -/// ControlMaster master do (one long-lived process). -fn resolve_cache_context( - peer_pid: Option, - mode: AuthCacheMode, - peer_is_vt_relay: bool, -) -> Option { - classify_cache_context(peer_pid, mode, peer_is_vt_relay).context +/// Both degrade to the narrower cwd fallback (or Fresh), never to a wider +/// scope. +fn workspace_root_acceptable( + root: &std::path::Path, + cwd: &std::path::Path, + home: Option<&std::path::Path>, +) -> bool { + let Some(home) = home else { return true }; + if root == home { + return false; + } + if cwd.starts_with(home) && !root.starts_with(home) { + return false; + } + true } -/// A resolved cache context together with the mode it was resolved under and -/// WHY it resolved that way — the `basis` feeds `diag@vt` so an operator can -/// see which rule classified their connection without reading this file. -/// [`resolve_cache_context`] is the `.context` projection; behavior must -/// never depend on `basis` or `mode`. Carrying `mode` here (rather than as -/// parallel session fields) makes a sign/decrypt pairing mix-up in diag -/// reporting unrepresentable. -/// -/// [`ContextBasis`] lives in `crate::core` so the agent's wire tags and the -/// CLI's human explanations are one compile-checked mapping. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ContextResolution { - pub mode: AuthCacheMode, - pub context: Option, - pub basis: ContextBasis, -} - -/// Core of [`resolve_cache_context`], returning the basis alongside the -/// context. No `?` early-returns: each fallible lookup assigns its basis -/// explicitly so diag can report which branch failed. The precedence order -/// (mode-none → peer pid → vt-relay → TTY gate → ssh narrowing → mode arm) -/// is load-bearing and documented on [`resolve_cache_context`]; preserve it. -fn classify_cache_context( - peer_pid: Option, - mode: AuthCacheMode, - peer_is_vt_relay: bool, -) -> ContextResolution { - let resolved = |context: Option, basis: ContextBasis| ContextResolution { - mode, - context, - basis, +/// Capture the workspace identity through one file descriptor: +/// `open(O_RDONLY|O_DIRECTORY)` → `fstat` → `fcntl(F_GETPATH)`. Deriving the +/// `(dev, ino)` subject and the canonical path from the same fd removes the +/// rename race between two separate path lookups. +fn workspace_identity(root: &std::path::Path) -> Option { + use std::os::unix::ffi::OsStrExt; + let c_root = std::ffi::CString::new(root.as_os_str().as_bytes()).ok()?; + let fd = unsafe { libc::open(c_root.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY) }; + if fd < 0 { + return None; + } + // Everything below must close `fd` on every path. + let result = (|| { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return None; + } + let mut buf = [0u8; libc::PATH_MAX as usize]; + if unsafe { libc::fcntl(fd, libc::F_GETPATH, buf.as_mut_ptr()) } == -1 { + return None; + } + let path = proc_info::nul_terminated_path(&buf)?; + Some(Workspace { + subject: (st.st_dev as u64, st.st_ino), + root: path.to_str()?.to_string(), + }) + })(); + unsafe { libc::close(fd) }; + result +} + +/// The per-user Darwin temp root (`confstr(_CS_DARWIN_USER_TEMP_DIR)`), +/// canonicalized and cached. Queried from the OS, never from a peer's +/// `$TMPDIR`. `None` when the query fails. +fn darwin_user_temp_dir() -> Option<&'static PathBuf> { + static DIR: std::sync::OnceLock> = std::sync::OnceLock::new(); + DIR.get_or_init(|| { + let mut buf = [0u8; libc::PATH_MAX as usize]; + let len = unsafe { + libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, buf.as_mut_ptr().cast(), buf.len()) + }; + if len == 0 || len > buf.len() { + return None; + } + let path = proc_info::nul_terminated_path(&buf)?; + // Canonicalize so it compares against F_GETPATH-derived paths. + std::fs::canonicalize(path).ok() + }) + .as_ref() +} + +/// A cwd with no `.git` ancestor may serve as its own grant scope, but only +/// when it is not a directory that many unrelated activities share: `$HOME` +/// and its ancestors (`/`, `/Users`) are the launch cwd of practically every +/// interactive process, and the shared temp roots pool ephemeral work. +/// Subdirectories of these (e.g. `/private/tmp/scratch`) remain acceptable — +/// they name one activity. `cwd` must already be canonical (F_GETPATH), so +/// `/tmp` arrives as `/private/tmp`. +fn cwd_fallback_acceptable(cwd: &std::path::Path, home: Option<&std::path::Path>) -> bool { + if let Some(home) = home { + if cwd == home || home.starts_with(cwd) { + return false; + } + } + const SHARED_ROOTS: &[&str] = &[ + "/tmp", + "/private/tmp", + "/var/tmp", + "/private/var/tmp", + "/Volumes", + ]; + if SHARED_ROOTS.iter().any(|r| cwd == std::path::Path::new(r)) { + return false; + } + if darwin_user_temp_dir().is_some_and(|t| cwd == t) { + return false; + } + true +} + +/// The peer's parent process as an activity identity. `None` when the parent +/// is launchd/init (`ppid <= 1` — a daemon orphan or a peer whose parent +/// already exited; scoping to launchd would pool every daemon on the box) +/// or when a kernel lookup fails. +fn parent_app_identity(peer_pid: i32) -> Option { + let (ppid, _, _) = proc_info::get_proc_bsdinfo(peer_pid)?; + let ppid = i32::try_from(ppid).ok()?; + if ppid <= 1 { + return None; + } + let start = proc_info::get_start_tvsec(ppid)?; + let exe = proc_info::get_proc_path(ppid)?; + Some(AppIdentity { + subject: (ppid as u64, start), + exe, + }) +} + +/// Resolve the peer's workspace once per connection (vt CLI connections are +/// per-command; a process cwd does not change mid-connection in practice). +/// Preference order: nearest acceptable `.git` root, else the cwd directory +/// itself, else the parent application (each a distinct grant family), else +/// Fresh. +fn resolve_workspace(peer_pid: Option) -> WorkspaceResolution { + let Some(pid) = peer_pid else { + return WorkspaceResolution::Unavailable; }; - // The `(pid, start_time)` anchor shared by every process-keyed branch; - // a failed start-time lookup is uniformly ProcLookupFailed → uncacheable. - let anchored = |pid: i32, basis: ContextBasis| match proc_info::get_start_tvsec(pid) { - Some(start) => resolved(Some((pid as u64, start)), basis), - None => resolved(None, ContextBasis::ProcLookupFailed), + let Some(cwd) = proc_info::get_cwd(pid) else { + return WorkspaceResolution::Unavailable; }; - if mode == AuthCacheMode::None { - return resolved(None, ContextBasis::ModeNone); + let home = dirs::home_dir(); + if let Some(root) = find_git_root(&cwd) { + if workspace_root_acceptable(&root, &cwd, home.as_deref()) { + return match workspace_identity(&root) { + Some(ws) => WorkspaceResolution::Resolved(ws), + None => WorkspaceResolution::Unavailable, + }; + } } - let Some(pid) = peer_pid else { - return resolved(None, ContextBasis::NoPeerPid); + // No usable git root: fall back to the cwd itself. Acceptability is + // checked on the fd-derived canonical path, so /tmp aliases and symlinked + // cwds cannot dodge the shared-root exclusions. + let Some(ws) = workspace_identity(&cwd) else { + return WorkspaceResolution::Unavailable; }; - // vt-relay narrowing (`vt ssh connect --forward-real-agent`): when the peer - // is the vt relay forwarding vt extensions from a remote host, give it the - // SAME per-connection `(pid, start)` context a forwarded `ssh` gets — in - // EVERY mode (incl. Global) and with NO TTY requirement (the relay may be - // scripted/TTY-less; the remote side always is). Deliberately AHEAD of the - // TTY gate so a relay without a controlling terminal still narrows to its - // own connection rather than falling through to a coarse shared context. - // `peer_is_vt_relay` is resolved once by the caller (kernel argv → - // `is_vt_relay_invocation`) and shared with the prompt origin marker, so - // the two can never disagree; a spoofed argv only narrows the caller to - // its own context, never widens it. - if peer_is_vt_relay { - return anchored(pid, ContextBasis::VtRelay); - } - // Per-session / per-app require the peer to have a controlling terminal. - // launchd-managed daemons and other non-interactive peers always prompt - // and never enter the cache — for these modes caching only makes sense - // for explicit human-driven terminal sessions. Deliberately ahead of the - // ssh narrowing below, so a TTY-less process running a binary named `ssh` - // (cron/launchd, or a renamed tool) can't use that branch to bypass the - // gate under these modes. - if matches!(mode, AuthCacheMode::PerSession | AuthCacheMode::PerApp) - && proc_info::get_tty_dev(pid).is_none() - { - return resolved(None, ContextBasis::NoTty); - } - if proc_info::get_proc_path(pid) - .as_deref() - .is_some_and(is_ssh_client_path) - { - return anchored(pid, ContextBasis::SshClient); - } - match mode { - // Handled above; kept so the match stays total without a wildcard. - AuthCacheMode::None => resolved(None, ContextBasis::ModeNone), - AuthCacheMode::PerSession => match proc_info::get_sid(pid) { - Some(sid) => anchored(sid, ContextBasis::SessionLeader), - None => resolved(None, ContextBasis::ProcLookupFailed), - }, - AuthCacheMode::PerApp => match proc_info::find_app_pid(pid) { - Some(app_pid) => anchored(app_pid, ContextBasis::AppAncestor), - None => resolved(None, ContextBasis::NoAppAncestor), - }, - // (0, 0) can never collide with a real context: sid/app_pid are - // always > 0. - AuthCacheMode::Global => resolved(Some((0, 0)), ContextBasis::Global), + if cwd_fallback_acceptable(std::path::Path::new(ws.root_str()), home.as_deref()) { + return WorkspaceResolution::CwdFallback(ws); + } + // Broad shared cwd (daemons launch from `/`, terminals from `$HOME`): + // identify the activity by the calling application instead. + match parent_app_identity(pid) { + Some(app) => WorkspaceResolution::AppFallback(app), + None => WorkspaceResolution::NoRoot, } } /// True when `path` (the peer's canonical executable path from /// `proc_pidpath`) is an OpenSSH client binary, matched by basename so any /// install location (system, homebrew, nix) qualifies. A renamed copy evades -/// the match — acceptable: the goal is correct grant scoping for honest -/// forwarding setups, not containing local malware, which already reaches -/// the socket directly. +/// the match — acceptable: it lands in the unbound-non-ssh workspace arm, +/// which stays within the documented same-UID concession +/// (docs/authorization-scopes-v2.md §3.3). fn is_ssh_client_path(path: &str) -> bool { path.rsplit('/').next() == Some("ssh") } -/// Assemble one cache's `diag@vt` report. `live_entries` counts only the -/// caller's own context (0 when uncacheable) — never the whole cache. -fn diag_cache_report(cache: &AuthCache, resolution: ContextResolution) -> DiagCacheReport { - DiagCacheReport { - mode: resolution.mode.to_string(), - ttl_secs: cache.ttl_secs(), - live_entries: resolution.context.map_or(0, |c| cache.live_len(c)), - context_basis: resolution.basis.as_wire().to_string(), +// --- session-bind@openssh.com state machine ----------------------------------- + +/// Local cap on recorded session ids per connection — a DoS defense (bounded +/// memory), not a claimed protocol contract. +const MAX_SESSION_BINDS: usize = 16; + +/// Destination binding state of one agent connection, driven by verified +/// `session-bind@openssh.com` messages (OpenSSH ≥ 8.9 sends one per hop). +/// See docs/authorization-scopes-v2.md §3.2. +#[derive(Debug)] +enum BindState { + Unbound, + Bound { + /// Exact wire-encoded `KeyData` bytes of the FIRST bound host key — + /// the destination digest input. + hostkey_wire: Vec, + /// Parsed copy of the same key, display-only (fingerprint / + /// known_hosts lookup for the prompt). + hostkey: KeyData, + /// Once true the connection may carry traffic from beyond the first + /// hop (agent forwarding) and is never destination-cacheable again. + forwarding: bool, + session_ids: Vec>, + }, + /// A bind failed verification or consistency checks; sticky for the + /// connection lifetime. All raw signs degrade to Fresh. + Tainted, +} + +impl BindState { + /// Destination host key wire bytes when — and only when — the connection + /// is bound to exactly one destination and has never been marked + /// forwarding. + fn destination(&self) -> Option<(&[u8], &KeyData)> { + match self { + BindState::Bound { + hostkey_wire, + hostkey, + forwarding: false, + .. + } => Some((hostkey_wire, hostkey)), + _ => None, + } + } + + /// Apply one `session-bind` message. `Err(())` means the bind was + /// refused; the caller replies with an agent failure. Refusal mirrors + /// OpenSSH: an **unverifiable** bind (bad signature, or a host key this + /// build cannot decode/verify — certificates, curves without an enabled + /// feature) is refused WITHOUT poisoning the recorded state, so benign + /// cert/p521 infrastructure merely gets no destination caching instead of + /// an attack-flavored sticky Tainted. Only **consistency violations** + /// (duplicate session id under a different key, forwarding→auth + /// downgrade) taint, because they indicate the peer is playing games with + /// state we must remember. + fn apply(&mut self, bind: &SessionBind) -> std::result::Result<(), ()> { + use ssh_agent_lib::ssh_encoding::Encode; + + if bind.verify_signature().is_err() { + return Err(()); + } + let mut wire = Vec::new(); + if bind.host_key.encode(&mut wire).is_err() { + return Err(()); + } + match self { + BindState::Tainted => Err(()), + BindState::Unbound => { + *self = BindState::Bound { + hostkey_wire: wire, + hostkey: bind.host_key.clone(), + forwarding: bind.is_forwarding, + session_ids: vec![bind.session_id.clone()], + }; + Ok(()) + } + BindState::Bound { + hostkey_wire, + forwarding, + session_ids, + .. + } => { + let same_key = *hostkey_wire == wire; + if session_ids.iter().any(|id| id == &bind.session_id) { + // Re-binding a recorded session id: refuse a different + // host key and a forwarding→auth downgrade. + if !same_key || (*forwarding && !bind.is_forwarding) { + *self = BindState::Tainted; + return Err(()); + } + *forwarding = *forwarding || bind.is_forwarding; + return Ok(()); + } + if session_ids.len() >= MAX_SESSION_BINDS { + // Local DoS cap: refuse the excess bind but keep the + // recorded state intact (OpenSSH behavior). + return Err(()); + } + session_ids.push(bind.session_id.clone()); + // A second destination or an explicit forwarding bind means + // requests can originate beyond the first hop. Sticky. + if !same_key || bind.is_forwarding { + *forwarding = true; + } + Ok(()) + } + } + } +} + +/// Best-effort display name for a bound destination: scan plain-name +/// `~/.ssh/known_hosts` entries (via ssh-key's parser) for `hostkey` and +/// return the first host name. Cosmetic only — the grant is keyed on the +/// host key bytes, never on this name. +fn known_hosts_name(hostkey: &KeyData) -> Option { + let path = dirs::home_dir()?.join(".ssh").join("known_hosts"); + let content = std::fs::read_to_string(path).ok()?; + known_hosts_name_in(&content, hostkey) +} + +/// Human label for a destination-bound sign grant: known_hosts name when +/// resolvable, always the host key fingerprint. Display-only. +fn destination_label(hostkey: &KeyData) -> String { + match known_hosts_name(hostkey) { + Some(name) => format!("{} ({})", name, fingerprint_str(hostkey)), + None => fingerprint_str(hostkey), + } +} + +/// Sign scope for a resolved basis — one place holds the basis→family +/// mapping shared by the raw-sign and `sign@vt` arms. +fn sign_scope_for_basis(basis: ScopedBasis<'_>, fingerprint: &str) -> GrantScope { + match basis { + ScopedBasis::Git(ws) => GrantScope::sign_workspace(ws.subject, ws.root_str(), fingerprint), + ScopedBasis::Cwd(ws) => GrantScope::sign_cwd(ws.subject, ws.root_str(), fingerprint), + ScopedBasis::App(app) => GrantScope::sign_app(app.subject, &app.exe, fingerprint), } } +/// Decrypt scope for a resolved basis, per `(type, salt)` record. +fn decrypt_scope_for_basis(basis: ScopedBasis<'_>, secret_type: u8, salt: &[u8]) -> GrantScope { + match basis { + ScopedBasis::Git(ws) => { + GrantScope::decrypt_workspace(ws.subject, ws.root_str(), secret_type, salt) + } + ScopedBasis::Cwd(ws) => { + GrantScope::decrypt_cwd(ws.subject, ws.root_str(), secret_type, salt) + } + ScopedBasis::App(app) => { + GrantScope::decrypt_app(app.subject, &app.exe, secret_type, salt) + } + } +} + +/// Contract a `$HOME` prefix to `~` for prompt display. Display-only — +/// grants and digests always bind the canonical absolute path. +fn contract_home(path: &str) -> String { + if let Some(home) = dirs::home_dir() { + let home = home.to_string_lossy(); + if let Some(rest) = path.strip_prefix(home.as_ref()) { + if rest.is_empty() { + return "~".to_string(); + } + if rest.starts_with('/') { + return format!("~{}", rest); + } + } + } + path.to_string() +} + +/// Human label for a reuse line. The wording distinguishes the scope +/// families so the prompt states exactly what the approval covers: the +/// workspace root is the common case and stays unmarked (a bare, +/// home-contracted path always means "the whole repository"), while the +/// narrower fallback families keep an explicit prefix so "this exact +/// directory" / "this app" can never read as a repository scope. +fn scoped_label(basis: ScopedBasis<'_>) -> String { + match basis { + ScopedBasis::Git(ws) => contract_home(ws.root_str()), + ScopedBasis::Cwd(ws) => format!("directory {}", contract_home(ws.root_str())), + ScopedBasis::App(app) => format!("app {}", app.name()), + } +} + +/// Append the §6 prompt transparency line: present exactly when an approval +/// can create a reusable grant. The label is agent-derived (kernel workspace +/// path / verified host key) but sanitized like every other prompt field. +fn append_reuse_line(message: &mut String, label: &Option, ttl_secs: u64) { + if let Some(label) = label { + message.push_str("\nreuse: "); + message.push_str(&sanitize_prompt(label, 160)); + message.push_str(" · "); + message.push_str(&reuse_ttl_label(ttl_secs)); + } +} + +fn known_hosts_name_in(content: &str, hostkey: &KeyData) -> Option { + use ssh_key::known_hosts::{HostPatterns, KnownHosts}; + KnownHosts::new(content) + .filter_map(std::result::Result::ok) + // @revoked / @cert-authority entries do not name a host's own key. + .filter(|entry| entry.marker().is_none()) + .filter(|entry| entry.public_key().key_data() == hostkey) + // Hashed (`|1|…`) entries are irreversible: skip them and keep + // looking for a plain-name entry of the same key. + .find_map(|entry| match entry.host_patterns() { + HostPatterns::Patterns(patterns) => patterns.first().cloned(), + HostPatterns::HashedName { .. } => None, + }) +} + impl Agent for VtSshAgentFactory { fn new_session(&mut self, socket: &tokio::net::UnixStream) -> impl Session { let peer_pid = get_peer_pid(socket); if let Some(pid) = peer_pid { tracing::debug!("New session from PID {}", pid); } - // One kernel-argv fetch per connection, shared by the cache narrowing - // and the prompt origin marker so they can never classify the peer - // differently. + // One kernel-argv fetch per connection, shared by the scope + // classification and the prompt origin marker so they can never + // classify the peer differently. let peer_is_vt_relay = peer_pid .and_then(proc_info::get_proc_argv) .map(|argv| crate::ssh_sign::is_vt_relay_invocation(&argv)) .unwrap_or(false); - let sign_cache_resolution = - classify_cache_context(peer_pid, self.sign_cache_mode, peer_is_vt_relay); - let decrypt_cache_resolution = - classify_cache_context(peer_pid, self.decrypt_cache_mode, peer_is_vt_relay); + let peer_path = peer_pid.and_then(proc_info::get_proc_path); + let peer_is_ssh_client = peer_path.as_deref().is_some_and(is_ssh_client_path); + let peer_exe = peer_path + .as_deref() + .map(|p| p.rsplit('/').next().unwrap_or(p).to_string()); + // Relay AND plain-ssh peers are confined to a `(pid, start_tvsec)` + // connection subject: both can carry traffic that originated on a + // remote host (forwarded agent socket), so neither may ever reach the + // workspace arm. Local vt peers get a workspace resolution instead. + // All resolved once per connection so a scope lookup can never race + // proc-tree state against the request. + let confined_to_connection = peer_is_vt_relay || peer_is_ssh_client; + let connection_subject = if confined_to_connection { + peer_pid.and_then(|pid| { + proc_info::get_start_tvsec(pid).map(|start| (pid as u64, start)) + }) + } else { + None + }; VtSshSession { keys: Arc::clone(&self.keys), last_activity: Arc::clone(&self.last_activity), locked: Arc::clone(&self.locked), + lock_transition: Arc::clone(&self.lock_transition), lock_passphrase: Arc::clone(&self.lock_passphrase), idle_cleared: Arc::clone(&self.idle_cleared), - sign_auth_cache: Arc::clone(&self.sign_auth_cache), - decrypt_auth_cache: Arc::clone(&self.decrypt_auth_cache), + authorization: Arc::clone(&self.authorization), + cache_ttls: self.cache_ttls, run_allow: Arc::clone(&self.run_allow), - prompt_sem: Arc::clone(&self.prompt_sem), audit_push: Arc::clone(&self.audit_push), peer_pid, + peer_exe, peer_is_vt_relay, - sign_cache_resolution, - decrypt_cache_resolution, + peer_is_ssh_client, + connection_subject, + workspace: std::sync::OnceLock::new(), + bind_state: BindState::Unbound, + destination_label: None, disable_legacy_decrypt: self.disable_legacy_decrypt, + notify_cache_hits: self.notify_cache_hits, + ui_token: self.ui_token, + idle_timeout_secs: self.idle_timeout_secs, } } } @@ -1236,107 +1416,53 @@ impl Agent for VtSshAgentFactory { struct VtSshSession { keys: Arc>>, last_activity: Arc>, - locked: Arc>, + locked: Arc, + lock_transition: Arc>, lock_passphrase: Arc>>, idle_cleared: Arc>, - sign_auth_cache: Arc>, - decrypt_auth_cache: Arc>, + authorization: Arc, + cache_ttls: AuthCacheTtls, /// Cloned per session for cheap reads; the underlying allowlist is /// constant for the lifetime of the agent process. run_allow: Arc, - /// Shared across all sessions; see [`VtSshSession::authenticate_serialized`]. - prompt_sem: Arc, /// Shared fire-and-forget audit push config (disabled = no-op). audit_push: Arc, peer_pid: Option, + /// Peer executable basename (kernel `proc_pidpath`), captured once for + /// prompts and diag. + peer_exe: Option, /// Peer is a `vt ssh connect --forward-real-agent` relay (kernel-argv /// check, resolved once at session creation): its requests originated on /// a remote host and every prompt carries the relay-origin marker. peer_is_vt_relay: bool, - /// Resolved once at session creation. `.context == None` = always prompt - /// for sign; `.mode`/`.basis` are diag-only. - sign_cache_resolution: ContextResolution, - /// Resolved once at session creation. `.context == None` = always prompt - /// for decrypt; `.mode`/`.basis` are diag-only. - decrypt_cache_resolution: ContextResolution, + /// Peer executable basename is `ssh` (kernel `proc_pidpath`). An ssh + /// peer can carry forwarded remote traffic, so it is confined per + /// connection for vt extensions and its raw signs are cacheable only via + /// a non-forwarding session-bind. + peer_is_ssh_client: bool, + /// `(pid, start_tvsec)` confinement subject for relay and ssh peers; + /// `None` for local vt peers or when the proc lookup failed (the + /// connection then degrades to Fresh via the scope constructors). + connection_subject: Option, + /// Workspace resolution for local peers (Unavailable for relay/ssh). + /// Resolved lazily on first use — `resolve_workspace` does filesystem + /// I/O on a peer-controlled cwd, and `new_session` runs on the accept + /// loop, where a hung network mount would stall every client. Still + /// once per connection (never re-resolved per request). + workspace: std::sync::OnceLock, + /// Destination binding driven by `session-bind@openssh.com` (§3.2 of + /// docs/authorization-scopes-v2.md). Mutated by `extension()`. + bind_state: BindState, + /// Display label for the bound destination, computed once per bind so a + /// sign burst does not re-read known_hosts. + destination_label: Option, disable_legacy_decrypt: bool, -} - -/// Outcome of a `sign` authorization, carrying enough to audit it. Replaces -/// the old `bool` so the emit point in `Session::sign` can distinguish a -/// silent cache hit from a fresh approval and from a denial. `Unavailable` -/// keeps the [`UnavailableReason`] so `sign@vt` can surface the structured -/// ErrKind (SessionLocked/NoGuiSession) instead of flattening to Generic. -enum AuthDecision { - CacheHit, - Approved, - Rejected, - Unavailable(UnavailableReason), -} - -impl AuthDecision { - /// True when signing should proceed. - fn is_ok(&self) -> bool { - matches!(self, AuthDecision::CacheHit | AuthDecision::Approved) - } - fn outcome(&self) -> &'static str { - match self { - AuthDecision::CacheHit => "cache_hit", - AuthDecision::Approved => "approved", - AuthDecision::Rejected => "rejected", - AuthDecision::Unavailable(_) => "unavailable", - } - } -} - -/// Outcome of a `decrypt@vt` batch authorization. Carries the structured -/// `ErrKind` on the failure arm so the existing client-facing error mapping -/// (`SessionLocked`/`NoGuiSession` → the right `DETAIL_*`) is preserved -/// (NEW-1): a bare `Unavailable` would collapse that distinction. -enum DecryptDecision { - CacheHit, - Approved, - Rejected, - Err(ErrKind), -} - -impl DecryptDecision { - fn outcome(&self) -> &'static str { - match self { - DecryptDecision::CacheHit => "cache_hit", - DecryptDecision::Approved => "approved", - DecryptDecision::Rejected => "rejected", - DecryptDecision::Err(_) => "unavailable", - } - } -} - -/// Run the blocking auth chain on the blocking pool. No serialization — -/// callers must already hold the prompt permit (or deliberately not need -/// one). Join error means the auth chain panicked; fail closed. -async fn authenticate_on_blocking_pool(reason: &str) -> AuthOutcome { - let msg = reason.to_string(); - match tokio::task::spawn_blocking(move || authenticate(&msg)).await { - Ok(outcome) => outcome, - Err(e) => { - tracing::error!("auth prompt task failed: {}", e); - AuthOutcome::Unavailable(UnavailableReason::NotInteractive) - } - } -} - -/// Map a single `authenticate()` outcome (the always-prompt, no-cache path) -/// onto a `DecryptDecision`, preserving the structured `ErrKind` for the -/// failure arms. `Rejected` is its own variant; a `None` from -/// `outcome_to_err_strict` (future enum variant) fails closed to `Generic`. -fn decrypt_decision_from_authenticate(outcome: AuthOutcome) -> DecryptDecision { - match outcome { - AuthOutcome::Success(_) => DecryptDecision::Approved, - AuthOutcome::Rejected => DecryptDecision::Rejected, - AuthOutcome::Unavailable(reason) => DecryptDecision::Err( - outcome_to_err_strict(AuthOutcome::Unavailable(reason)).unwrap_or(ErrKind::Generic), - ), - } + /// Cache-hit transparency notifications (docs/app-bundle.md §3). + notify_cache_hits: bool, + /// Spawn token gating `ui-status@vt`; `None` refuses every request. + ui_token: Option<[u8; 32]>, + /// Configured idle timeout (seconds), reported over `ui-status@vt`. + idle_timeout_secs: u64, } impl VtSshSession { @@ -1356,21 +1482,379 @@ impl VtSshSession { } } - /// Build an `AgentAuditEntry` from the post-decision context and hand it to - /// the fire-and-forget pusher. No-op when audit push is disabled. Holds NO - /// cache lock; never blocks (spawn_push returns immediately). - #[allow(clippy::too_many_arguments)] - fn emit_audit( - &self, - op_kind: &str, - outcome: &str, - host: &str, - meta: &crate::core::ClientMeta, - command: &str, - reason: &str, - salts: usize, - latency_ms: u64, - ) { + /// Append the kernel-verified caller line. Unlike the client-reported + /// `via:` (`meta.ppid_cmd`), `peer_exe` comes from `proc_pidpath` on the + /// socket peer and cannot be forged by the request body — but the + /// basename is still attacker-chosen (a local process names its own + /// binary), so it is sanitized like every other prompt field. Placed with + /// the agent-derived truth lines, before the client-reported body/meta. + /// + /// Exception display: the overwhelmingly common caller is the vt CLI + /// itself, so `caller: vt` carries no information and is suppressed — + /// the line appears only for other basenames (`ssh -A` forwarded + /// traffic, `ssh-keygen`, renamed binaries). Suppressing on the name + /// hides nothing a rename could not already hide: the line is display + /// signal, not a boundary. Audit rows keep `peer_exe` unconditionally. + fn append_caller_line(&self, message: &mut String) { + if let Some(exe) = self.peer_exe.as_deref() { + if !exe.is_empty() && exe != "vt" { + message.push_str("\ncaller: "); + message.push_str(&sanitize_prompt(exe, 80)); + } + } + } + + /// Append the raw-sign destination truth line from the session-bind + /// state (docs/approval-transparency.md §A1). `reuse_names_destination` + /// is true when the reuse line already carries the destination label (a + /// destination-reusable scope — the only reusable arm a bound non-relay + /// peer can reach), in which case the plain line would be a duplicate. A + /// forwarding-capable bind is always flagged: the signed request may + /// originate beyond hop one. A tainted bind failed verification outright. + fn append_destination_line(&self, message: &mut String, reuse_names_destination: bool) { + match &self.bind_state { + BindState::Bound { + forwarding: false, + hostkey, + .. + } => { + if !reuse_names_destination { + let label = self + .destination_label + .clone() + .unwrap_or_else(|| destination_label(hostkey)); + message.push_str("\ndest: "); + message.push_str(&sanitize_prompt(&label, 160)); + } + } + BindState::Bound { + forwarding: true, + hostkey, + .. + } => { + let label = self + .destination_label + .clone() + .unwrap_or_else(|| destination_label(hostkey)); + message.push_str("\ndest: "); + message.push_str(&sanitize_prompt(&label, 160)); + message.push_str(" (forwarding — may serve a relayed request)"); + } + BindState::Tainted => { + message.push_str("\nwarning: session-bind verification failed"); + } + BindState::Unbound => {} + } + } + + /// The verified destination for audit rows: the bound, non-forwarding + /// session-bind label; empty otherwise. Mirrors `append_destination_line` + /// but never reports a forwarding-capable or tainted bind as a + /// destination. + fn audit_destination(&self) -> String { + match &self.bind_state { + BindState::Bound { + forwarding: false, + hostkey, + .. + } => self + .destination_label + .clone() + .unwrap_or_else(|| destination_label(hostkey)), + _ => String::new(), + } + } + + // ---- Activity-scope construction (docs/authorization-scopes-v2.md) ---- + // + // Each helper returns the scope(s) plus a human reuse label. The label is + // `Some` exactly when an approval can create a reusable grant, and is + // built from the same data the scope digest binds — the prompt must state + // what is being granted (§6 transparency invariant). + + /// The per-connection confinement arm shared by relay and plain-ssh + /// peers: `Some(label)` exactly when the connection subject resolved. + fn connection_label(&self) -> Option { + self.connection_subject.map(|_| { + if self.peer_is_vt_relay { + "this relay connection".to_string() + } else { + "this forwarded ssh connection".to_string() + } + }) + } + + /// True when this connection may carry traffic that originated on a + /// remote host (vt relay or plain `ssh -A`): vt extensions must then be + /// confined per connection and must never reach the workspace arm. + fn confined_to_connection(&self) -> bool { + self.peer_is_vt_relay || self.peer_is_ssh_client + } + + /// Lazy once-per-connection workspace resolution (see the field doc). + /// Confined connections never resolve one. + fn workspace_resolution(&self) -> &WorkspaceResolution { + self.workspace.get_or_init(|| { + if self.confined_to_connection() { + WorkspaceResolution::Unavailable + } else { + resolve_workspace(self.peer_pid) + } + }) + } + + /// The scope basis, with the §4 consistency check applied to the + /// directory-shaped arms: a claimed `pwd` outside the git/cwd root + /// degrades to Fresh. The app arm carries no root to check against — + /// its claimed pwd stays display-only, like the connection arms. + fn reusable_scope(&self, claimed_pwd: &str) -> Option> { + self.workspace_resolution().scoped().filter(|basis| match basis { + ScopedBasis::Git(ws) | ScopedBasis::Cwd(ws) => { + ws.contains_claimed_pwd(claimed_pwd) + } + ScopedBasis::App(_) => true, + }) + } + + /// Scope for a raw `SIGN_REQUEST`. + fn raw_sign_scope(&self, fingerprint: &str) -> (GrantScope, Option) { + if self.cache_ttls.sign_secs == 0 { + return (GrantScope::fresh(Operation::Sign), None); + } + if self.peer_is_vt_relay { + return ( + GrantScope::sign(self.connection_subject, fingerprint, ""), + self.connection_label(), + ); + } + match &self.bind_state { + BindState::Bound { + hostkey_wire, + hostkey, + forwarding: false, + .. + } => ( + GrantScope::sign_destination(hostkey_wire, fingerprint), + // Precomputed at bind time; the on-demand fallback is + // unreachable in production (extension() sets the label on + // every successful bind) but keeps the reuse line + // self-contained for direct-construction tests. + self.destination_label + .clone() + .or_else(|| Some(destination_label(hostkey))), + ), + // Forwarding-capable: traffic may originate beyond hop one. + BindState::Bound { .. } | BindState::Tainted => { + (GrantScope::fresh(Operation::Sign), None) + } + // No bind and the peer is ssh: authentication and forwarding are + // indistinguishable — Fresh. + BindState::Unbound if self.peer_is_ssh_client => { + (GrantScope::fresh(Operation::Sign), None) + } + // No bind, non-ssh local caller (ssh-keygen -Y sign etc.): + // workspace / cwd / parent-app scope. + BindState::Unbound => match self.workspace_resolution().scoped() { + Some(basis) => ( + sign_scope_for_basis(basis, fingerprint), + Some(scoped_label(basis)), + ), + None => (GrantScope::fresh(Operation::Sign), None), + }, + } + } + + /// Scope for `sign@vt` (local vt ⇒ workspace, relay/ssh ⇒ per-connection). + fn sign_vt_scope(&self, fingerprint: &str, claimed_pwd: &str) -> (GrantScope, Option) { + if self.cache_ttls.sign_secs == 0 { + return (GrantScope::fresh(Operation::Sign), None); + } + if self.confined_to_connection() { + return ( + GrantScope::sign(self.connection_subject, fingerprint, claimed_pwd), + self.connection_label(), + ); + } + match self.reusable_scope(claimed_pwd) { + Some(basis) => ( + sign_scope_for_basis(basis, fingerprint), + Some(scoped_label(basis)), + ), + None => (GrantScope::fresh(Operation::Sign), None), + } + } + + fn connection_basis(&self) -> ContextBasis { + match self.connection_subject { + Some(_) if self.peer_is_vt_relay => ContextBasis::RelayConnection, + Some(_) => ContextBasis::SshConnection, + None => ContextBasis::ProcLookupFailed, + } + } + + /// diag basis for the sign scope classification of THIS connection. + fn sign_basis(&self) -> ContextBasis { + if self.cache_ttls.sign_secs == 0 { + return ContextBasis::Disabled; + } + if self.peer_is_vt_relay { + return self.connection_basis(); + } + match &self.bind_state { + BindState::Bound { + forwarding: false, .. + } => ContextBasis::SessionBind, + BindState::Bound { .. } => ContextBasis::Forwarding, + BindState::Tainted => ContextBasis::Tainted, + BindState::Unbound if self.peer_is_ssh_client => ContextBasis::UnboundSsh, + BindState::Unbound => self.workspace_basis(), + } + } + + /// diag basis for the vt-extension (sign@vt / decrypt) classification of + /// THIS connection. + fn decrypt_basis(&self) -> ContextBasis { + if self.cache_ttls.decrypt_secs == 0 { + return ContextBasis::Disabled; + } + if self.confined_to_connection() { + return self.connection_basis(); + } + self.workspace_basis() + } + + fn workspace_basis(&self) -> ContextBasis { + match self.workspace_resolution() { + WorkspaceResolution::Resolved(_) => ContextBasis::Workspace, + WorkspaceResolution::CwdFallback(_) => ContextBasis::CwdWorkspace, + WorkspaceResolution::AppFallback(_) => ContextBasis::ParentApp, + WorkspaceResolution::NoRoot => ContextBasis::NoWorkspaceRoot, + WorkspaceResolution::Unavailable => match self.peer_pid { + None => ContextBasis::NoPeerPid, + Some(_) => ContextBasis::ProcLookupFailed, + }, + } + } + + /// Grants this connection's own classification could reuse — the single + /// basis→(family, subject) mapping behind both diag counts. The family + /// filter matters when a directory gains or loses a `.git` entry: the + /// workspace and cwd families then share a `(dev, ino)` subject, and a + /// caller must not count the other family's grants. `decrypt_basis` + /// never yields `SessionBind`, so one shared mapping is safe. + async fn live_grants(&self, basis: ContextBasis, operation: Operation) -> usize { + let scoped = match basis { + ContextBasis::SessionBind => Some(( + ScopeFamily::Destination, + crate::core::authorization::DESTINATION_SUBJECT, + )), + ContextBasis::Workspace | ContextBasis::CwdWorkspace | ContextBasis::ParentApp => { + self.workspace_resolution().scoped().map(|basis| match basis { + ScopedBasis::Git(ws) => (ScopeFamily::Workspace, ws.subject), + ScopedBasis::Cwd(ws) => (ScopeFamily::CwdFallback, ws.subject), + ScopedBasis::App(app) => (ScopeFamily::ParentApp, app.subject), + }) + } + ContextBasis::RelayConnection | ContextBasis::SshConnection => self + .connection_subject + .map(|subject| (ScopeFamily::Connection, subject)), + _ => None, + }; + match scoped { + Some((family, subject)) => { + self.authorization.live_len(operation, family, subject).await + } + None => 0, + } + } + + /// Scopes for a pure-v2 decrypt batch (one per `(type, salt)` record). + fn decrypt_scopes( + &self, + v2_inputs: &[(crate::core::SecretType, [u8; SALT_LEN])], + claimed_host: &str, + claimed_pwd: &str, + ) -> (Vec, Option) { + let fresh = || vec![GrantScope::fresh(Operation::Decrypt)]; + if self.cache_ttls.decrypt_secs == 0 { + return (fresh(), None); + } + if self.confined_to_connection() { + return ( + v2_inputs + .iter() + .map(|(t, salt)| { + GrantScope::decrypt_v2( + self.connection_subject, + t.as_byte(), + salt, + claimed_host, + claimed_pwd, + ) + }) + .collect(), + self.connection_label(), + ); + } + match self.reusable_scope(claimed_pwd) { + Some(basis) => ( + v2_inputs + .iter() + .map(|(t, salt)| decrypt_scope_for_basis(basis, t.as_byte(), salt)) + .collect(), + Some(scoped_label(basis)), + ), + None => (fresh(), None), + } + } + + /// Agent-derived audit context shared by every row: kernel peer identity + /// and relay provenance. Operation-specific fields (key, destination, + /// scope) start empty — see `audit_ctx_scoped` and the sign handlers. + fn audit_ctx(&self) -> AgentAuditContext { + AgentAuditContext { + peer_exe: self.peer_exe.clone().unwrap_or_default(), + relayed: self.peer_is_vt_relay, + ..AgentAuditContext::default() + } + } + + /// Audit context for an operation that can mint (or hit) a reusable + /// grant: records the scope family, the exact label the prompt's reuse + /// line displayed, and the effective TTL. `family` and `label` are `Some` + /// together by construction (both derive from the same scope helper); a + /// Fresh request leaves all three fields empty/zero. + fn audit_ctx_scoped( + &self, + family: Option, + label: &Option, + ttl_secs: u64, + ) -> AgentAuditContext { + let mut ctx = self.audit_ctx(); + if let (Some(family), Some(label)) = (family, label.as_deref()) { + ctx.scope_family = family.as_wire().to_string(); + ctx.scope_label = label.to_string(); + ctx.grant_ttl_s = ttl_secs; + } + ctx + } + + /// Build an `AgentAuditEntry` from the post-decision context and hand it to + /// the fire-and-forget pusher. No-op when audit push is disabled. Holds NO + /// cache lock; never blocks (spawn_push returns immediately). + #[allow(clippy::too_many_arguments)] + fn emit_audit( + &self, + op_kind: &str, + outcome: &str, + host: &str, + meta: &crate::core::ClientMeta, + command: &str, + reason: &str, + salts: usize, + latency_ms: u64, + agent_ctx: AgentAuditContext, + ) { if !self.audit_push.enabled { return; } @@ -1385,13 +1869,15 @@ impl VtSshSession { salts, latency_ms, self.audit_push.agent_id(), + agent_ctx, ); audit::spawn_push(Arc::clone(&self.audit_push), entry); } - /// Ensure keys are loaded. If they were cleared by the idle sweeper, - /// silently reload from keychain. Touch ID is enforced per sign/extension - /// request via `check_or_prompt_auth()` using the normal cache rules. + /// Ensure keys are loaded. If they were cleared by the idle sweeper or by + /// the screen-lock/sleep watcher (docs/app-bundle.md §10), silently reload + /// from keychain. The unified authorization engine still gates every + /// operation before a loaded key can be used. async fn ensure_keys_loaded(&self) -> Result<(), AgentError> { let keys = self.keys.read().await; if !keys.is_empty() { @@ -1399,16 +1885,33 @@ impl VtSshSession { } drop(keys); - // Check if keys were cleared by idle timeout (vs just being empty) + // Check if keys were cleared by idle timeout / lock (vs just empty). let idle = *self.idle_cleared.read().await; if !idle { return Ok(()); } - tracing::info!("Keys cleared by idle timeout, reloading from keychain"); + // Do NOT repopulate RAM while the screen is locked (§10, C). A sign + // here is rejected by the validator anyway; reloading would undo the + // lock-wipe. Checked before the keychain I/O to skip a pointless read, + // and RE-checked after it (below) because the screen can lock during + // the read. `idle_cleared` is left set so a later interactive call + // reloads. + if !super::security::session_interactive_now() { + return Ok(()); + } + + tracing::info!("Keys cleared (idle/lock), reloading from keychain"); let loaded = load_all_keys().map_err(agent_err)?; tracing::info!("Reloaded {} SSH keys", loaded.len()); let mut keys = self.keys.write().await; + // A lock transition (agent lock OR screen lock) may have started while + // Keychain I/O was in progress. Re-check both while holding the same + // key-map guard used by lock() so loaded private keys can never be + // installed after a clear. + if self.locked.load(Ordering::Acquire) || !super::security::session_interactive_now() { + return Ok(()); + } *keys = loaded; // Reset idle_cleared flag @@ -1423,247 +1926,24 @@ impl VtSshSession { *last = (Instant::now(), SystemTime::now()); } - /// Run the blocking auth chain (`authenticate`) for `reason`, serialized - /// through the global one-permit prompt semaphore and moved off the async - /// workers via `spawn_blocking`. - /// - /// Serialization: every accepted socket runs in its own tokio task, so - /// without the permit a peer holding VT_AUTH (or any process reaching a - /// forwarded socket) could stack N concurrent dialogs at the user — - /// prompt-fatigue that trains reflexive approval. `spawn_blocking`: - /// `authenticate` blocks on a human for up to ~30s; run inline it would - /// pin one runtime worker per concurrent prompt and, at worker-count - /// prompts, stall the whole agent including unrelated sessions. - /// - /// The cached paths (`check_or_prompt_auth`, - /// `check_or_prompt_decrypt_batch`) don't use this wrapper: they acquire - /// the permit themselves so they can re-check the cache after the queue - /// wait. Use this only where no cache is involved. - async fn authenticate_serialized(&self, reason: &str) -> AuthOutcome { - // The semaphore is never closed, so acquire only fails if that ever - // changes; fail closed as "cannot prompt" rather than a user reject. - let Ok(_permit) = self.prompt_sem.acquire().await else { - return AuthOutcome::Unavailable(UnavailableReason::NotInteractive); - }; - authenticate_on_blocking_pool(reason).await - } - - /// Check auth cache or prompt the user. Returns an [`AuthDecision`] - /// distinguishing a silent cache hit from a fresh approval / denial so the - /// `Session::sign` call site can audit the outcome. - /// - /// Used for `sign` (SSH authentication) and `sign@vt` (`vt ssh connect`), - /// which share one cache — both authorize "sign with this key". `auth@vt` - /// always prompts because forwarded agents share one local process. - /// `decrypt@vt` has its own cache (see `check_or_prompt_decrypt_batch`). - /// - /// All three success methods (Biometric, FIDO2, Password) are cached: - /// FIDO2 (YubiKey touch) is treated as equivalent to Touch ID for - /// authorization purposes — a deliberate policy choice. - async fn check_or_prompt_auth( - &self, - fingerprint: &str, - pwd: &str, - auth_message: &str, - ) -> AuthDecision { - // If we couldn't resolve a cache context at session creation (no - // peer PID, no TTY, missing proc info), always prompt. - let Some(context) = self.sign_cache_resolution.context else { - return match self.authenticate_serialized(auth_message).await { - AuthOutcome::Success(_) => AuthDecision::Approved, - AuthOutcome::Rejected => AuthDecision::Rejected, - AuthOutcome::Unavailable(r) => AuthDecision::Unavailable(r), - }; - }; - let key = sign_cache_key(fingerprint, pwd); - - // Fast path: cache hit without touching the prompt queue. - if self.sign_cache_hit(context, &key, fingerprint, "").await { - return AuthDecision::CacheHit; - } - - // Queue for the prompt, then RE-CHECK the cache: a racing request for - // the same key may have been approved (and granted — grants happen - // while the permit is still held) during the wait, so an N-request - // burst costs one dialog instead of N. - let Ok(_permit) = self.prompt_sem.acquire().await else { - return AuthDecision::Unavailable(UnavailableReason::NotInteractive); - }; - if self.sign_cache_hit(context, &key, fingerprint, " after prompt-queue wait").await { - return AuthDecision::CacheHit; - } - - // Prompt (permit held, no cache locks held) - let method = match authenticate_on_blocking_pool(auth_message).await { - AuthOutcome::Success(m) => m, - AuthOutcome::Rejected => return AuthDecision::Rejected, - AuthOutcome::Unavailable(reason) => { - tracing::warn!( - "Auth unavailable for fingerprint={} reason={:?}", - fingerprint, - reason - ); - return AuthDecision::Unavailable(reason); - } - }; - - if method.is_cacheable() { - // Granted while the permit is still held, so queued waiters are - // guaranteed to observe this entry on their re-check. - let mut cache = self.sign_auth_cache.write().await; - cache.grant(context, &key); - tracing::debug!( - "Sign auth cache grant for context={:?} fingerprint={} method={:?}", - context, - fingerprint, - method - ); - } - - AuthDecision::Approved - } - - /// Cache-aware authorization for a `decrypt@vt` batch. - /// - /// Any legacy item in the batch disables caching for the whole batch — - /// legacy items release plaintext, and the invariant "legacy-containing - /// batch always prompts" is load-bearing for that decision. Otherwise: - /// full hit skips Touch ID; partial hit prompts once and then grants the - /// whole batch (strict-TTL — see the grant loop below). - /// - /// Returns a [`DecryptDecision`]: `CacheHit`/`Approved` allow the decrypt, - /// `Rejected`/`Err(kind)` block it (the caller maps those to the structured - /// `ExtResponse::Err` envelope). Both failure arms `return` before any - /// `cache.grant` call, preserving the invariant that failure paths never - /// extend or create cache entries. - async fn check_or_prompt_decrypt_batch( + /// Fire the cache-hit transparency notification for a committed permit, + /// if enabled. MUST be called only AFTER `permit.commit()` (or + /// `commit_authorization`) has returned — while a permit is live its + /// security read guard blocks revocation, and this must not add + /// unbounded-latency work there. The notify itself is fire-and-forget + /// (docs/app-bundle.md §3). Single-sourced so both sign paths keep the + /// ordering invariant identical. + fn fire_cache_hit_note( &self, - v2_items: &[(crate::core::SecretType, [u8; SALT_LEN])], - has_legacy: bool, - host: &str, - pwd: &str, - auth_message: &str, - ) -> DecryptDecision { - // Cacheable iff there's a resolved context AND the batch is non-empty - // pure-v2; everything else falls through to the always-prompt path. - let context = match self.decrypt_cache_resolution.context { - Some(c) if !has_legacy && !v2_items.is_empty() => c, - // No cache eligibility: prompt once. Preserve the structured - // ErrKind via `outcome_to_err_strict` so the client-facing error - // (SessionLocked/NoGuiSession) is not flattened (NEW-1). Fail - // closed: a `None` for a non-Success outcome (future enum variant) - // becomes an Err rather than a silent allow. - _ => { - return decrypt_decision_from_authenticate( - self.authenticate_serialized(auth_message).await, - ) - } - }; - - let keys: Vec = v2_items - .iter() - .map(|(t, salt)| decrypt_cache_key(*t, salt, host, pwd)) - .collect(); - - // Fast path: full hit without touching the prompt queue. - if self.decrypt_miss_count(context, &keys).await == 0 { - tracing::debug!( - "Decrypt auth cache hit for context={:?} ({} v2 items)", - context, - v2_items.len() - ); - return DecryptDecision::CacheHit; - } - - // Queue for the prompt, then RE-CHECK: a racing request covering the - // same records may have been approved (and granted — grants happen - // while the permit is still held) during the wait, so an N-request - // burst costs one dialog instead of N. - let Ok(_permit) = self.prompt_sem.acquire().await else { - return decrypt_decision_from_authenticate(AuthOutcome::Unavailable( - UnavailableReason::NotInteractive, - )); - }; - let miss_count = self.decrypt_miss_count(context, &keys).await; - if miss_count == 0 { - tracing::debug!( - "Decrypt auth cache hit after prompt-queue wait for context={:?} ({} v2 items)", - context, - v2_items.len() - ); - return DecryptDecision::CacheHit; + note: Option<(&'static str, String)>, + reuse_remaining: Option, + ) { + if !self.notify_cache_hits { + return; } - - let method = match authenticate_on_blocking_pool(auth_message).await { - AuthOutcome::Success(m) => m, - AuthOutcome::Rejected => return DecryptDecision::Rejected, - AuthOutcome::Unavailable(reason) => { - tracing::warn!("decrypt@vt unavailable: {:?}", reason); - return DecryptDecision::Err( - outcome_to_err_strict(AuthOutcome::Unavailable(reason)) - .unwrap_or(ErrKind::Generic), - ); - } - }; - - if method.is_cacheable() { - // The user just approved the ENTIRE batch, so grant every key in - // it, not just the previously-missing ones — an entry that was - // valid at the check above but expired while the prompt sat on - // screen would otherwise stay expired despite the fresh approval. - // `grant_at` is strict-TTL: keys still valid at this instant keep - // their original expiry (no sliding refresh). Granted while the - // permit is still held, so queued waiters observe the entries on - // their re-check. - let now_mono = Instant::now(); - let now_wall = SystemTime::now(); - let mut cache = self.decrypt_auth_cache.write().await; - for k in &keys { - cache.grant_at(context, k, now_mono, now_wall); - } - tracing::debug!( - "Decrypt auth cache grant for context={:?} method={:?} cache_misses={}", - context, - method, - miss_count - ); + if let Some((operation, label)) = note { + super::security::notify_cache_hit(operation, &label, reuse_remaining); } - - DecryptDecision::Approved - } - - /// Sign-cache lookup with a hit-path debug log; `phase` distinguishes the - /// fast-path check from the post-queue re-check in the log line. `key` is - /// the [`sign_cache_key`] digest; `fingerprint` is passed alongside only - /// so the log line stays human-readable. - async fn sign_cache_hit( - &self, - context: CacheContext, - key: &str, - fingerprint: &str, - phase: &str, - ) -> bool { - let hit = self.sign_auth_cache.read().await.is_authorized(context, key); - if hit { - tracing::debug!( - "Sign auth cache hit{} for context={:?} fingerprint={}", - phase, - context, - fingerprint - ); - } - hit - } - - /// Count how many of `keys` are NOT currently authorized for `context` — - /// 0 means a full cache hit. One clock capture per call. - async fn decrypt_miss_count(&self, context: CacheContext, keys: &[String]) -> usize { - let now_mono = Instant::now(); - let now_wall = SystemTime::now(); - let cache = self.decrypt_auth_cache.read().await; - keys.iter() - .filter(|k| !cache.is_authorized_at(context, k, now_mono, now_wall)) - .count() } // ---- Structured-envelope dispatch helpers -------------------------------- @@ -1680,7 +1960,7 @@ impl VtSshSession { decrypted: &[u8], store: &KeychainStore, passphrase_cipher: &AesGcmCrypto, - ) -> Result>, (ErrKind, Option<&'static str>)> { + ) -> Result { // v2 envelope: agent allocates a fresh per-record (salt, DEK) pair // for each requested SecretType. The agent NEVER receives plaintext // on this path. The salt is generated server-side (never accepted @@ -1726,8 +2006,9 @@ impl VtSshSession { "", req.types.len(), 0, + self.audit_ctx(), ); - Ok(Zeroizing::new(bytes)) + Ok(HandlerSuccess::without_authorization(Zeroizing::new(bytes))) } async fn handle_decrypt( @@ -1735,7 +2016,7 @@ impl VtSshSession { decrypted: &[u8], store: &KeychainStore, passphrase_cipher: &AesGcmCrypto, - ) -> Result>, (ErrKind, Option<&'static str>)> { + ) -> Result { let req: DecryptReq = serde_json::from_slice(decrypted) .map_err(|_| (ErrKind::BadRequest, Some(DETAIL_BAD_REQUEST_JSON)))?; @@ -1781,6 +2062,16 @@ impl VtSshSession { return Err((ErrKind::LegacyDisabled, Some(DETAIL_LEGACY_DISABLED))); } + // Verify that the stored master key is present and decryptable before + // consulting reusable authorization state or prompting. Drop this + // short-lived copy immediately; the operation reloads it only after a + // permit is obtained, so raw key material is not held across a human + // prompt. Validation is deterministic over the already-loaded store + // and passphrase cipher, making the later load a non-fallible + // precondition in normal operation while retaining defensive mapping. + validate_mac_key_material(store, passphrase_cipher) + .map_err(|_| (ErrKind::NotInitialized, Some(DETAIL_NOT_INITIALIZED)))?; + let who = who_at_host(&req.meta.user, &req.host); let n = req.items.len(); let mut local_auth_message = header_with_who( @@ -1789,6 +2080,43 @@ impl VtSshSession { &who, ); self.append_relay_origin(&mut local_auth_message); + self.append_caller_line(&mut local_auth_message); + // Pure-v2 batches use one atomic scope per record and require an all-of + // hit. Any legacy member makes the entire request explicitly Fresh. + // The reuse line is agent-derived truth and is appended BEFORE the + // client-reported body/meta below, for the same reason as the relay + // origin marker: a hostile caller must not be able to pad the one + // line that says the tap creates a standing grant off-screen. + let (scopes, reuse, reuse_label) = if legacy_count > 0 { + ( + vec![GrantScope::fresh(Operation::Decrypt)], + ReusePolicy::Fresh, + None, + ) + } else { + let (scopes, reuse_label) = + self.decrypt_scopes(&v2_inputs, &req.host, &req.meta.pwd); + let display = reuse_label.clone().unwrap_or_default(); + let scopes: Vec = scopes + .into_iter() + .map(|scope| scope.with_display(display.clone())) + .collect(); + append_reuse_line( + &mut local_auth_message, + &reuse_label, + self.cache_ttls.decrypt_secs, + ); + ( + scopes, + ReusePolicy::from_ttl_secs(self.cache_ttls.decrypt_secs), + reuse_label, + ) + }; + let audit_ctx = self.audit_ctx_scoped( + scopes.first().and_then(GrantScope::family), + &reuse_label, + self.cache_ttls.decrypt_secs, + ); let body = sanitize_prompt_multiline( &req.command, PROMPT_COMMAND_MAX_LINE_LEN, @@ -1799,43 +2127,40 @@ impl VtSshSession { local_auth_message.push_str(&body); } append_meta_lines(&mut local_auth_message, &req.meta); - // Cache-aware authorization. Pure v2 batches may skip the prompt on - // a full hit; any legacy item disables caching for the entire batch. - // Failure paths inside `check_or_prompt_decrypt_batch` never grant - // cache entries (verified by inspection of the helper). - let t0 = Instant::now(); - let decision = self - .check_or_prompt_decrypt_batch( - &v2_inputs, - legacy_count > 0, - &req.host, - &req.meta.pwd, - &local_auth_message, - ) - .await; - // Cache hits never prompted → 0 latency; otherwise prompt→decision. - let latency_ms = if matches!(decision, DecryptDecision::CacheHit) { - 0 - } else { - t0.elapsed().as_millis() as u64 - }; - self.emit_audit( - "decrypt", - decision.outcome(), - &req.host, - &req.meta, - &req.command, - "", - req.items.len(), - latency_ms, - ); - match decision { - DecryptDecision::CacheHit | DecryptDecision::Approved => {} - DecryptDecision::Rejected => { - return Err((ErrKind::AuthRejected, auth_outcome_detail(ErrKind::AuthRejected))) + let permit = match self + .authorization + .authorize(AuthorizationRequest::new(scopes, reuse, local_auth_message)) + .await + { + Ok(permit) => { + self.emit_audit( + "decrypt", + permit.decision().audit_outcome(), + &req.host, + &req.meta, + &req.command, + "", + req.items.len(), + permit.latency_ms(), + audit_ctx, + ); + permit } - DecryptDecision::Err(kind) => return Err((kind, auth_outcome_detail(kind))), - } + Err(failure) => { + self.emit_audit( + "decrypt", + failure.decision().audit_outcome(), + &req.host, + &req.meta, + &req.command, + "", + req.items.len(), + failure.latency_ms(), + audit_ctx, + ); + return Err(authorization_failure_wire(&failure)); + } + }; let (mac_cipher, mac_key) = load_mac_cipher(store, passphrase_cipher) .map_err(|_| (ErrKind::NotInitialized, Some(DETAIL_NOT_INITIALIZED)))?; let mut result: Vec = Vec::with_capacity(req.items.len()); @@ -1868,8 +2193,10 @@ impl VtSshSession { } } drop(mac_key); - let bytes = serde_json::to_vec(&result) - .map_err(|_| (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)))?; + let bytes = Zeroizing::new( + serde_json::to_vec(&result) + .map_err(|_| (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)))?, + ); // Scrub DEKs inside the response Vec before drop. `bytes` already // carries them (still wiped via `Zeroizing` below). for item in result.iter_mut() { @@ -1877,13 +2204,14 @@ impl VtSshSession { dek.zeroize(); } } - Ok(Zeroizing::new(bytes)) + let note = cache_hit_note_for(&permit, "decrypt", &reuse_label); + Ok(HandlerSuccess::authorized(bytes, permit).with_cache_hit_note(note)) } async fn handle_auth( &self, decrypted: &[u8], - ) -> Result>, (ErrKind, Option<&'static str>)> { + ) -> Result { let req: AuthReq = serde_json::from_slice(decrypted) .map_err(|_| (ErrKind::BadRequest, Some(DETAIL_BAD_REQUEST_JSON)))?; @@ -1894,6 +2222,7 @@ impl VtSshSession { let who = who_at_host(&req.meta.user, &req.host); let mut auth_message = header_with_who("auth", "on", &who); self.append_relay_origin(&mut auth_message); + self.append_caller_line(&mut auth_message); let reason = sanitize_prompt(&req.reason, 100); if !reason.is_empty() { auth_message.push_str("\nreason: "); @@ -1901,45 +2230,49 @@ impl VtSshSession { } append_meta_lines(&mut auth_message, &req.meta); - // Always prompt Touch ID — no auth caching for auth@vt. Over - // forwarded agents, all remote sessions share the same local - // process, so caching would approve all sudo from any session. - let t0 = Instant::now(); - let outcome = self.authenticate_serialized(&auth_message).await; - let latency_ms = t0.elapsed().as_millis() as u64; - let outcome_str = match &outcome { - AuthOutcome::Success(_) => "approved", - AuthOutcome::Rejected => "rejected", - AuthOutcome::Unavailable(_) => "unavailable", - }; - self.emit_audit( - "auth", - outcome_str, - &req.host, - &req.meta, - "", - &req.reason, - 0, - latency_ms, - ); - match outcome { - AuthOutcome::Success(_) => {} - AuthOutcome::Rejected => { - return Err((ErrKind::AuthRejected, Some(DETAIL_AUTH_REJECTED))); + let permit = match self + .authorization + .authorize(AuthorizationRequest::fresh( + GrantScope::fresh(Operation::Auth), + auth_message, + )) + .await + { + Ok(permit) => { + self.emit_audit( + "auth", + permit.decision().audit_outcome(), + &req.host, + &req.meta, + "", + &req.reason, + 0, + permit.latency_ms(), + self.audit_ctx(), + ); + permit } - AuthOutcome::Unavailable(reason) => { - tracing::warn!("auth@vt unavailable: {:?}", reason); - // Fail closed on a future-variant miss in `outcome_to_err`. - let kind = outcome_to_err_strict(AuthOutcome::Unavailable(reason)) - .unwrap_or(ErrKind::Generic); - return Err((kind, auth_outcome_detail(kind))); + Err(failure) => { + self.emit_audit( + "auth", + failure.decision().audit_outcome(), + &req.host, + &req.meta, + "", + &req.reason, + 0, + failure.latency_ms(), + self.audit_ctx(), + ); + return Err(authorization_failure_wire(&failure)); } - } + }; let result = AuthRes { approved: true }; - Ok(Zeroizing::new(serde_json::to_vec(&result).map_err(|_| { + let bytes = Zeroizing::new(serde_json::to_vec(&result).map_err(|_| { (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)) - })?)) + })?); + Ok(HandlerSuccess::authorized(bytes, permit)) } /// `diag@vt`: read-only diagnostics for `vt doctor`. No Touch ID (it @@ -1951,29 +2284,36 @@ impl VtSshSession { async fn handle_diag( &self, decrypted: &[u8], - ) -> Result>, (ErrKind, Option<&'static str>)> { + ) -> Result { let _req: DiagReq = serde_json::from_slice(decrypted) .map_err(|_| (ErrKind::BadRequest, Some(DETAIL_BAD_REQUEST_JSON)))?; - let peer_path = self.peer_pid.and_then(proc_info::get_proc_path); let peer = DiagPeerReport { pid: self.peer_pid, - exe: peer_path - .as_deref() - .map(|p| p.rsplit('/').next().unwrap_or(p).to_string()), + exe: self.peer_exe.clone(), has_tty: self .peer_pid .is_some_and(|pid| proc_info::get_tty_dev(pid).is_some()), - is_ssh_client: peer_path.as_deref().is_some_and(is_ssh_client_path), + is_ssh_client: self.peer_is_ssh_client, is_vt_relay: self.peer_is_vt_relay, }; - let sign_cache = { - let cache = self.sign_auth_cache.read().await; - diag_cache_report(&cache, self.sign_cache_resolution) + // live_entries counts only grants THIS connection's own scope + // classification could reuse — never a whole-store count, and never + // grants a differently-classified caller would need. A caller whose + // basis says "never cached" therefore always reports 0. + let sign_basis = self.sign_basis(); + let sign_live = self.live_grants(sign_basis, Operation::Sign).await; + let decrypt_basis = self.decrypt_basis(); + let decrypt_live = self.live_grants(decrypt_basis, Operation::Decrypt).await; + let sign_cache = DiagCacheReport { + ttl_secs: self.cache_ttls.sign_secs, + live_entries: sign_live, + context_basis: sign_basis.as_wire().to_string(), }; - let decrypt_cache = { - let cache = self.decrypt_auth_cache.read().await; - diag_cache_report(&cache, self.decrypt_cache_resolution) + let decrypt_cache = DiagCacheReport { + ttl_secs: self.cache_ttls.decrypt_secs, + live_entries: decrypt_live, + context_basis: decrypt_basis.as_wire().to_string(), }; let result = DiagRes { agent_version: env!("VT_VERSION").to_string(), @@ -1983,9 +2323,71 @@ impl VtSshSession { run_allow_len: self.run_allow.len(), audit_push: self.audit_push.enabled, }; - Ok(Zeroizing::new(serde_json::to_vec(&result).map_err(|_| { - (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)) - })?)) + Ok(HandlerSuccess::without_authorization(Zeroizing::new( + serde_json::to_vec(&result) + .map_err(|_| (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)))?, + ))) + } + + /// `ui-status@vt` (docs/app-bundle.md §5): plaintext, token-gated + /// status/revoke channel for the VT.app shell. Runs BEFORE the lock + /// check and the VT_AUTH cipher path, never touches the idle clock, is + /// never cached and never audit-pushed. The only whole-store grant + /// visibility in the agent — every failure mode is an unstructured + /// `AgentError::Failure` so a prober without the token cannot even + /// distinguish "agent without token" from "unknown extension". + async fn handle_ui_status( + &self, + extension: &Extension, + ) -> Result, AgentError> { + use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; + use subtle::ConstantTimeEq; + + let req: UiStatusReq = + serde_json::from_slice(extension.details.as_ref()).map_err(|_| AgentError::Failure)?; + // Constant-time token compare, same idiom as `unlock()`. A + // CLI-started agent has no token and refuses every request. + let authorized = match ( + &self.ui_token, + BASE64_URL_SAFE_NO_PAD.decode(&req.token), + ) { + (Some(expected), Ok(candidate)) if candidate.len() == 32 => { + expected.ct_eq(candidate.as_slice()).into() + } + _ => false, + }; + if !authorized { + return Err(AgentError::Failure); + } + + let revoked = match req.action.as_str() { + crate::core::UI_STATUS_ACTION_STATUS => None, + // Authority-reducing only: reuses the linearized revoker, so the + // epoch advances even when the store is empty and an in-flight + // prompt cannot recreate a revoked grant. May wait while a live + // permit holds the security gate — the shell shows "waiting for + // the in-flight approval". + crate::core::UI_STATUS_ACTION_REVOKE_ALL => { + Some(self.authorization.invalidate_all().await) + } + _ => return Err(AgentError::Failure), + }; + let res = UiStatusRes { + agent_version: env!("VT_VERSION").to_string(), + locked: self.locked.load(Ordering::Acquire), + sign_ttl_secs: self.cache_ttls.sign_secs, + decrypt_ttl_secs: self.cache_ttls.decrypt_secs, + idle_timeout_secs: self.idle_timeout_secs, + run_allow_len: self.run_allow.len(), + audit_push: self.audit_push.enabled, + revoked, + grants: self.authorization.snapshot().await, + }; + let bytes = serde_json::to_vec(&res).map_err(|e| agent_err(e.into()))?; + Ok(Some(Extension { + name: extension.name.clone(), + details: Unparsed::from(bytes), + })) } /// Touch-ID-gated local command launcher. Every call prompts — no auth @@ -1999,7 +2401,7 @@ impl VtSshSession { async fn handle_run( &self, decrypted: &[u8], - ) -> Result>, (ErrKind, Option<&'static str>)> { + ) -> Result { let req: RunReq = serde_json::from_slice(decrypted) .map_err(|_| (ErrKind::BadRequest, Some(DETAIL_BAD_REQUEST_JSON)))?; @@ -2039,6 +2441,11 @@ impl VtSshSession { let argv_for_prompt = sanitize_prompt(&argv_joined, RUN_PROMPT_ARGV_MAX); let exe_display = sanitize_prompt(&resolved.display().to_string(), 160); let mut auth_message = header_with_who("run on this Mac", "from", &who); + // The vt relay refuses run@vt, so the relay marker is a dead path + // today — kept for uniformity with the other extension prompts as + // cheap insurance against a future relay-filter change. + self.append_relay_origin(&mut auth_message); + self.append_caller_line(&mut auth_message); auth_message.push_str("\nexe: "); auth_message.push_str(&exe_display); auth_message.push_str("\nargv: "); @@ -2051,43 +2458,47 @@ impl VtSshSession { } append_meta_lines(&mut auth_message, &req.meta); - // NEVER cache. run@vt is treated like auth@vt — every call prompts - // because a forwarded agent socket is shared across all remote - // sessions, and any cache here would let one session's approval - // be reused by another to run arbitrary allowlisted programs. - // Prompt serialization (one dialog at a time) is inside - // `authenticate_serialized`, shared with every other prompting path. - // run@vt: exe + argv joined as the audit `command` (reuses the prompt - // builders above). All emit points carry it. + // Validation, allowlist resolution, and canonicalization above all run + // before authorization. run@vt uses the shared engine but an explicit + // Fresh policy, so every invocation still requires a human approval. let run_command = format!("exe: {}\nargv: {}", exe_display, argv_for_prompt); let run_reason = req.reason.as_deref().unwrap_or(""); - let t0 = Instant::now(); - let outcome = self.authenticate_serialized(&auth_message).await; - let latency_ms = t0.elapsed().as_millis() as u64; - match outcome { - AuthOutcome::Success(_) => {} - AuthOutcome::Rejected => { - self.emit_audit( - "run", "rejected", &req.host, &req.meta, &run_command, run_reason, 0, - latency_ms, - ); - return Err((ErrKind::AuthRejected, Some(DETAIL_AUTH_REJECTED))); - } - AuthOutcome::Unavailable(reason) => { - tracing::warn!("run@vt unavailable: {:?}", reason); - let kind = outcome_to_err_strict(AuthOutcome::Unavailable(reason)) - .unwrap_or(ErrKind::Generic); + let permit = match self + .authorization + .authorize(AuthorizationRequest::fresh( + GrantScope::fresh(Operation::Run), + auth_message, + )) + .await + { + Ok(permit) => permit, + Err(failure) => { self.emit_audit( - "run", "unavailable", &req.host, &req.meta, &run_command, run_reason, 0, - latency_ms, + "run", + failure.decision().audit_outcome(), + &req.host, + &req.meta, + &run_command, + run_reason, + 0, + failure.latency_ms(), + self.audit_ctx(), ); - return Err((kind, auth_outcome_detail(kind))); + return Err(authorization_failure_wire(&failure)); } - } + }; // Q5: emit `approved` at the human tap, BEFORE the spawn attempt, so a // denied launch (below) is distinguishable from a failed one (two rows). self.emit_audit( - "run", "approved", &req.host, &req.meta, &run_command, run_reason, 0, latency_ms, + "run", + permit.decision().audit_outcome(), + &req.host, + &req.meta, + &run_command, + run_reason, + 0, + permit.latency_ms(), + self.audit_ctx(), ); // Spawn detached. `setsid` makes the child a new session leader so it @@ -2103,7 +2514,15 @@ impl VtSshSession { tracing::warn!("run@vt spawn failed: {}", e); // Second row: the launch was approved but failed to spawn. self.emit_audit( - "run", "spawn_failed", &req.host, &req.meta, &run_command, run_reason, 0, 0, + "run", + "spawn_failed", + &req.host, + &req.meta, + &run_command, + run_reason, + 0, + 0, + self.audit_ctx(), ); return Err((ErrKind::Generic, Some(DETAIL_RUN_SPAWN_FAILED))); } @@ -2116,9 +2535,10 @@ impl VtSshSession { ); let result = RunRes { pid }; - Ok(Zeroizing::new(serde_json::to_vec(&result).map_err(|_| { + let bytes = Zeroizing::new(serde_json::to_vec(&result).map_err(|_| { (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)) - })?)) + })?); + Ok(HandlerSuccess::authorized(bytes, permit)) } /// `sign@vt`: VT_AUTH-gated signing with a Keychain-held key, displaying vt @@ -2127,15 +2547,15 @@ impl VtSshSession { /// auth-cipher envelope and carries human context. The private key never /// leaves the agent. /// - /// Shares the sign auth cache with the standard `SIGN_REQUEST` path (same - /// (context, fingerprint) key — both authorize "sign with this key"), so a - /// multi-host `vt ssh connect` fan-out costs one Touch ID within the TTL - /// instead of one per host. v1 always prompted; superseded by the cache - /// opt-in — mode `none` (default) restores per-request prompts. + /// Uses the same `Operation::Sign` grant store as standard `SIGN_REQUEST`. + /// Local callers get a kernel-verified workspace scope (one approval + /// covers a same-project multi-host fan-out); relay callers stay confined + /// to their connection. Duration `0` (the default) keeps per-request + /// prompts. See docs/authorization-scopes-v2.md §3.4. async fn handle_sign_vt( &self, decrypted: &[u8], - ) -> Result>, (ErrKind, Option<&'static str>)> { + ) -> Result { use ssh_agent_lib::ssh_encoding::Decode; let req: SignReq = serde_json::from_slice(decrypted) @@ -2170,6 +2590,7 @@ impl VtSshSession { let who = who_at_host(&req.meta.user, &req.host); let mut auth_message = header_with_who("ssh-sign", "for", &who); self.append_relay_origin(&mut auth_message); + self.append_caller_line(&mut auth_message); // sign@vt can name ANY agent key, so the prompt must say which one // (comment, else SHA256 fingerprint — same label rule as // `Session::sign`). This line is agent-derived truth (the requested @@ -2183,6 +2604,17 @@ impl VtSshSession { } else { auth_message.push_str(&sanitize_prompt(privkey.comment(), 80)); } + // Reuse line before the client-reported body/meta — same padding + // rationale as the relay origin marker. + let (scope, reuse_label) = self.sign_vt_scope(&fp_str, &req.meta.pwd); + let scope = scope.with_display(reuse_label.clone().unwrap_or_default()); + append_reuse_line(&mut auth_message, &reuse_label, self.cache_ttls.sign_secs); + let audit_ctx = { + let mut ctx = + self.audit_ctx_scoped(scope.family(), &reuse_label, self.cache_ttls.sign_secs); + ctx.key_fp = fp_str.clone(); + ctx + }; let body = sanitize_prompt_multiline( &req.command, PROMPT_COMMAND_MAX_LINE_LEN, @@ -2194,42 +2626,44 @@ impl VtSshSession { } append_meta_lines(&mut auth_message, &req.meta); - // Cache-aware authorization (same cache + double-check semantics as - // `Session::sign`). Distinguish reject vs unavailable so the client - // gets the right ErrKind (AuthRejected => no fallback, G3). - let t0 = Instant::now(); - let decision = self - .check_or_prompt_auth(&fp_str, &req.meta.pwd, &auth_message) - .await; - let latency_ms = if matches!(decision, AuthDecision::CacheHit) { - 0 - } else { - t0.elapsed().as_millis() as u64 - }; - // Audit the decision (op_kind="ssh-sign", same as the CF ceremony sign - // path) — without this, agent-side `vt ssh connect` signing left no - // audit row, a visibility gap vs every other handler. - self.emit_audit( - "ssh-sign", - decision.outcome(), - &req.host, - &req.meta, - &req.command, - "", - 0, - latency_ms, - ); - match decision { - AuthDecision::CacheHit | AuthDecision::Approved => {} - AuthDecision::Rejected => { - return Err((ErrKind::AuthRejected, Some(DETAIL_AUTH_REJECTED))); + let permit = match self + .authorization + .authorize(AuthorizationRequest::new( + vec![scope], + ReusePolicy::from_ttl_secs(self.cache_ttls.sign_secs), + auth_message, + )) + .await + { + Ok(permit) => { + self.emit_audit( + "ssh-sign", + permit.decision().audit_outcome(), + &req.host, + &req.meta, + &req.command, + "", + 0, + permit.latency_ms(), + audit_ctx, + ); + permit } - AuthDecision::Unavailable(reason) => { - let kind = outcome_to_err_strict(AuthOutcome::Unavailable(reason)) - .unwrap_or(ErrKind::Generic); - return Err((kind, auth_outcome_detail(kind))); + Err(failure) => { + self.emit_audit( + "ssh-sign", + failure.decision().audit_outcome(), + &req.host, + &req.meta, + &req.command, + "", + 0, + failure.latency_ms(), + audit_ctx, + ); + return Err(authorization_failure_wire(&failure)); } - } + }; let sig = sign_data_with_privkey(&privkey, &req.data, req.flags) .map_err(|_| (ErrKind::Generic, Some(DETAIL_SIGN_FAILED)))?; @@ -2237,12 +2671,33 @@ impl VtSshSession { algorithm: sig.algorithm().to_string(), signature: sig.as_bytes().to_vec(), }; - serde_json::to_vec(&res) + let bytes = serde_json::to_vec(&res) .map(Zeroizing::new) - .map_err(|_| (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE))) + .map_err(|_| (ErrKind::Generic, Some(DETAIL_INTERNAL_SERIALIZE)))?; + let note = cache_hit_note_for(&permit, "sign", &reuse_label); + Ok(HandlerSuccess::authorized(bytes, permit).with_cache_hit_note(note)) } } +/// Finish an agent lock transition independently of the requesting +/// connection. Dropping the returned JoinHandle detaches rather than cancels +/// the task, and the owned transition guard prevents a concurrent unlock from +/// publishing `locked = false` before keys and grants are cleared. +fn spawn_lock_finalizer( + transition: tokio::sync::OwnedMutexGuard<()>, + keys: Arc>>, + authorization: Arc, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut keys = keys.write().await; + keys.clear(); + drop(keys); + + authorization.invalidate_all().await; + drop(transition); + }) +} + /// Spawn `exe` with `args` as a detached background process. Returns the /// child PID for logging. The child: /// - inherits a freshly-built environment containing only `RUN_ENV_PASSTHROUGH` @@ -2328,6 +2783,8 @@ const DETAIL_LEGACY_DISABLED: &str = "legacy decryption disabled (--no-legacy-de const DETAIL_AUTH_REJECTED: &str = "authentication was declined"; const DETAIL_SCREEN_LOCKED: &str = "screen is locked"; const DETAIL_NO_GUI: &str = "no active GUI session"; +const DETAIL_AUTH_INVALIDATED: &str = "authorization state changed; retry"; +const DETAIL_AUTH_INVALID_TTL: &str = "authorization cache duration is too large"; const DETAIL_INTERNAL_SERIALIZE: &str = "agent failed to serialize response"; // run@vt-specific failure reasons. All strings are static program info // (no host/argv/user data) and therefore safe to surface to a remote peer. @@ -2374,6 +2831,79 @@ fn auth_outcome_detail(kind: ErrKind) -> Option<&'static str> { } } +type WireFailure = (ErrKind, Option<&'static str>); + +struct HandlerSuccess { + bytes: Zeroizing>, + authorization: Option, + /// `(operation, scope display)` when the permit's decision was a cache + /// hit. The dispatcher fires the transparency notification from this + /// AFTER `commit()` — never while the permit (and its security read + /// guard) is live (docs/app-bundle.md §3). + cache_hit_note: Option<(&'static str, String)>, +} + +impl HandlerSuccess { + fn authorized(bytes: Zeroizing>, authorization: AuthorizationPermit) -> Self { + Self { + bytes, + authorization: Some(authorization), + cache_hit_note: None, + } + } + + fn without_authorization(bytes: Zeroizing>) -> Self { + Self { + bytes, + authorization: None, + cache_hit_note: None, + } + } + + fn with_cache_hit_note(mut self, note: Option<(&'static str, String)>) -> Self { + self.cache_hit_note = note; + self + } +} + +/// Build the dispatcher's post-commit notification note for a permit whose +/// decision was `CacheHit`. `None` label (a fresh basis can't hit — defensive) +/// degrades to the family-free "cached scope". +fn cache_hit_note_for( + permit: &AuthorizationPermit, + operation: &'static str, + reuse_label: &Option, +) -> Option<(&'static str, String)> { + (permit.decision() == Decision::CacheHit).then(|| { + ( + operation, + reuse_label.clone().unwrap_or_else(|| "cached scope".to_string()), + ) + }) +} + +fn authorization_failure_wire(failure: &AuthorizationFailure) -> WireFailure { + match failure.decision() { + Decision::Rejected => (ErrKind::AuthRejected, Some(DETAIL_AUTH_REJECTED)), + Decision::Unavailable(reason) => { + let kind = outcome_to_err_strict(AuthOutcome::Unavailable(reason)) + .unwrap_or(ErrKind::Generic); + (kind, auth_outcome_detail(kind)) + } + Decision::Invalidated => (ErrKind::Transient, Some(DETAIL_AUTH_INVALIDATED)), + Decision::CacheHit | Decision::Approved(_) => { + (ErrKind::Generic, Some(DETAIL_AUTH_INVALIDATED)) + } + } +} + +async fn commit_authorization(permit: AuthorizationPermit) -> Result<(), WireFailure> { + permit.commit().await.map_err(|error| match error { + CommitError::Invalidated => (ErrKind::Transient, Some(DETAIL_AUTH_INVALIDATED)), + CommitError::InvalidTtl => (ErrKind::Generic, Some(DETAIL_AUTH_INVALID_TTL)), + }) +} + // ---- Envelope serialization helpers ----------------------------------------- /// Concrete shape used to serialize an `err` envelope. Mirrors the JSON @@ -2390,16 +2920,17 @@ struct ErrEnvelope { #[async_trait] impl Session for VtSshSession { async fn request_identities(&mut self) -> Result, AgentError> { - let locked = self.locked.read().await; - if *locked { + if self.locked.load(Ordering::Acquire) { return Ok(Vec::new()); } - drop(locked); // Reload keys from keychain if cleared by idle timeout. // Listing public keys is not security-sensitive; Touch ID is // enforced on sign/extension requests. self.ensure_keys_loaded().await?; + if self.locked.load(Ordering::Acquire) { + return Ok(Vec::new()); + } let keys = self.keys.read().await; let identities = keys @@ -2413,11 +2944,9 @@ impl Session for VtSshSession { } async fn sign(&mut self, request: SignRequest) -> Result { - let locked = self.locked.read().await; - if *locked { + if self.locked.load(Ordering::Acquire) { return Err(AgentError::Failure); } - drop(locked); self.ensure_keys_loaded().await?; self.touch_activity().await; @@ -2425,7 +2954,7 @@ impl Session for VtSshSession { let fp_str = fingerprint_str(&request.pubkey); // Clone the key out and drop the read-lock BEFORE the Touch ID prompt. - // Holding the `keys` read-guard across check_or_prompt_auth (which can + // Holding the `keys` read-guard across authorization (which can // block on a human for up to ~30s) would starve every writer // (add/remove/unlock) for the prompt's duration — a non-VT_AUTH peer can // trigger SIGN_REQUESTs to weaponize this. (Mirrors handle_sign_vt.) @@ -2434,54 +2963,131 @@ impl Session for VtSshSession { keys.get(&fp_str).ok_or(AgentError::Failure)?.clone() }; let comment = privkey.comment(); - let proc_name = self - .peer_pid - .and_then(proc_info::get_proc_path) - .and_then(|p| p.rsplit('/').next().map(String::from)) - .unwrap_or_default(); + // Sanitized like the sign@vt key line: the comment is user-set at add + // time, so it may not inject fake prompt lines. let key_label = if comment.is_empty() { fp_str.clone() } else { - comment.to_string() + sanitize_prompt(comment, 80) }; - let auth_message = if proc_name.is_empty() { - format!("sign: {}", key_label) - } else { - format!("sign: {} ({})", key_label, proc_name) + // Same layout as the sign@vt prompt (header, then key/caller/dest/ + // reuse truth lines) so the two sign paths read as one UI — raw signs + // just carry no client meta. The caller line replaces the old + // `sign: key (proc)` parenthetical. + let mut auth_message = format!("ssh-sign\nkey: {}", key_label); + self.append_caller_line(&mut auth_message); + let (scope, reuse_label) = self.raw_sign_scope(&fp_str); + // The reuse-line label doubles as the grant's display string + // (ui-status snapshots, cache-hit notifications). + let scope = scope.with_display(reuse_label.clone().unwrap_or_default()); + // Destination truth line BEFORE the reuse line: with the default + // TTL of 0 the reuse line never appears, and the verified + // session-bind destination must still be shown (a non-relay bound + // peer's reuse label IS the destination label, so `reuse_label` + // being present means the line below would be a duplicate). + self.append_destination_line(&mut auth_message, reuse_label.is_some()); + append_reuse_line(&mut auth_message, &reuse_label, self.cache_ttls.sign_secs); + let audit_ctx = { + let mut ctx = + self.audit_ctx_scoped(scope.family(), &reuse_label, self.cache_ttls.sign_secs); + ctx.key_fp = fp_str.clone(); + ctx.dest = self.audit_destination(); + ctx }; - // Check auth cache or prompt Touch ID. Plain agent-protocol signs - // carry no ClientMeta, so the pwd key component is empty. - let t0 = Instant::now(); - let decision = self.check_or_prompt_auth(&fp_str, "", &auth_message).await; - let latency_ms = if matches!(decision, AuthDecision::CacheHit) { - 0 - } else { - t0.elapsed().as_millis() as u64 + let permit = match self + .authorization + .authorize(AuthorizationRequest::new( + vec![scope], + ReusePolicy::from_ttl_secs(self.cache_ttls.sign_secs), + auth_message.clone(), + )) + .await + { + Ok(permit) => { + self.emit_audit( + "sign", + permit.decision().audit_outcome(), + "", + &crate::core::ClientMeta::default(), + &auth_message, + "", + 0, + permit.latency_ms(), + audit_ctx, + ); + permit + } + Err(failure) => { + self.emit_audit( + "sign", + failure.decision().audit_outcome(), + "", + &crate::core::ClientMeta::default(), + &auth_message, + "", + 0, + failure.latency_ms(), + audit_ctx, + ); + return Err(AgentError::Failure); + } }; - // Session::sign is the raw SSH auth path (op_kind='sign', distinct from - // auth@vt) — it carries no vt ClientMeta, so use the prompt label as the - // audit `command` and leave meta empty. - self.emit_audit( - "sign", - decision.outcome(), - "", - &crate::core::ClientMeta::default(), - &auth_message, - "", - 0, - latency_ms, - ); - if !decision.is_ok() { - return Err(AgentError::Failure); - } - sign_data_with_privkey(&privkey, &request.data, request.flags) + let signature = sign_data_with_privkey(&privkey, &request.data, request.flags)?; + let cache_hit_note = cache_hit_note_for(&permit, "sign", &reuse_label); + let reuse_remaining = permit.reuse_remaining(); + permit + .commit() + .await + .map_err(|_| AgentError::Failure)?; + self.fire_cache_hit_note(cache_hit_note, reuse_remaining); + Ok(signature) } async fn extension(&mut self, extension: Extension) -> Result, AgentError> { - let locked = self.locked.read().await; - if *locked { + // `session-bind@openssh.com` is a standard OpenSSH extension: plain + // SSH-wire bytes, never VT_AUTH-encrypted. It MUST be intercepted + // before the lock check and the keychain/auth-cipher path below — + // binding grants nothing by itself, must work on connections opened + // while the agent is locked, and would otherwise fail to decrypt so + // destination caching would silently never engage. It also must not + // touch the idle clock — it precedes any human-gated operation. A + // bind that fails to decode (host certificate, unsupported curve) is + // refused without changing state, mirroring `BindState::apply`. See + // docs/authorization-scopes-v2.md §3.1. + if extension.name.as_str() == "session-bind@openssh.com" { + let outcome = match extension.parse_message::() { + Ok(Some(bind)) => { + let applied = self.bind_state.apply(&bind); + // The label is display-only; compute it once per bind + // (≤ MAX_SESSION_BINDS per connection) instead of + // re-reading known_hosts on every sign request. + self.destination_label = self + .bind_state + .destination() + .map(|(_, hostkey)| destination_label(hostkey)); + applied + } + _ => Err(()), + }; + return match outcome { + Ok(()) => Ok(None), // plain SSH_AGENT_SUCCESS + Err(()) => Err(AgentError::Failure), + }; + } + // `ui-status@vt` is the shell's token-gated plaintext channel + // (docs/app-bundle.md §5). Like session-bind it precedes the lock + // check (a locked agent must still report `locked: true`), the + // keychain/auth-cipher path (the shell holds no VT_AUTH), and the + // idle-clock touch (it is meant to be polled; keeping the agent + // "active" would defeat the idle revocation). A missing or wrong + // token fails unstructured — indistinguishable from an unknown + // extension. + if extension.name.as_str() == EXT_UI_STATUS { + return self.handle_ui_status(&extension).await; + } + if self.locked.load(Ordering::Acquire) { // The lock check fires before keychain ciphers are derived, so // we have no `auth_cipher` to encrypt a structured envelope // with. Surface as an unstructured SSH-wire failure — clients @@ -2489,7 +3095,6 @@ impl Session for VtSshSession { // run `ssh-add -X`. See docs/structured-errors.md. return Err(AgentError::Failure); } - drop(locked); // Only handle vt custom protocol extensions; ignore standard SSH extensions if !matches!( @@ -2538,7 +3143,7 @@ impl Session for VtSshSession { // never returns `AgentError` for vt-level failures; only true // transport-layer failures (e.g. an internal serialize call) bubble // up as unstructured. - let dispatch: Result>, (ErrKind, Option<&'static str>)> = + let dispatch: Result = match extension.name.as_str() { EXT_ENCRYPT => { self.handle_encrypt(&decrypted, &store, &passphrase_cipher) @@ -2559,21 +3164,63 @@ impl Session for VtSshSession { // serialized inner body (which carries DEKs for encrypt/decrypt) is // only ever held in `Zeroizing` buffers — no intermediate // `serde_json::Value` allocation that wouldn't be wiped on drop. - let envelope_bytes: Zeroizing> = match dispatch { - Ok(inner_bytes) => Zeroizing::new(wrap_ok_envelope(&inner_bytes)), - Err((kind, detail)) => Zeroizing::new( - serde_json::to_vec(&ErrEnvelope { - v: WIRE_VERSION, - status: "err", - kind, - detail, - }) - .map_err(|e| agent_err(e.into()))?, + let (envelope_bytes, authorization, cache_hit_note): ( + Zeroizing>, + Option, + Option<(&'static str, String)>, + ) = match dispatch { + Ok(success) => ( + Zeroizing::new(wrap_ok_envelope(&success.bytes)), + success.authorization, + success.cache_hit_note, + ), + Err((kind, detail)) => ( + Zeroizing::new( + serde_json::to_vec(&ErrEnvelope { + v: WIRE_VERSION, + status: "err", + kind, + detail, + }) + .map_err(|e| agent_err(e.into()))?, + ), + None, + None, ), }; - // Encrypt envelope with auth cipher. - let encrypted_response = auth_cipher.encrypt(&envelope_bytes).map_err(agent_err)?; + // Encrypt the successful response before committing a pending grant. + // If envelope encryption fails, HandlerSuccess drops its non-cloneable + // permit and no approval becomes reusable. + // + // The commit-failure arm below is defensive: while this permit is + // alive its security read guard blocks epoch advancement, so + // `CommitError::Invalidated` cannot fire today. If a future change + // releases the guard before this point, note that the operation has + // already executed — a client retrying the resulting Transient error + // would re-run a non-idempotent handler (run@vt spawns twice). + let mut encrypted_response = auth_cipher.encrypt(&envelope_bytes).map_err(agent_err)?; + if let Some(permit) = authorization { + // Captured before commit consumes the permit; used only after the + // guard is released — a blocking notify while the permit is live + // would stall revocation (docs/app-bundle.md §3). + let reuse_remaining = permit.reuse_remaining(); + if let Err((kind, detail)) = commit_authorization(permit).await { + tracing::warn!("authorization commit invalidated after operation success"); + let error_envelope = Zeroizing::new( + serde_json::to_vec(&ErrEnvelope { + v: WIRE_VERSION, + status: "err", + kind, + detail, + }) + .map_err(|e| agent_err(e.into()))?, + ); + encrypted_response = auth_cipher.encrypt(&error_envelope).map_err(agent_err)?; + } else { + self.fire_cache_hit_note(cache_hit_note, reuse_remaining); + } + } Ok(Some(Extension { name: extension.name, @@ -2582,11 +3229,9 @@ impl Session for VtSshSession { } async fn add_identity(&mut self, identity: AddIdentity) -> Result<(), AgentError> { - let locked = self.locked.read().await; - if *locked { + if self.locked.load(Ordering::Acquire) { return Err(AgentError::Failure); } - drop(locked); match identity.credential { Credential::Key { privkey, comment } => { @@ -2687,21 +3332,28 @@ impl Session for VtSshSession { } async fn lock(&mut self, passphrase: String) -> Result<(), AgentError> { - let mut locked = self.locked.write().await; - if *locked { + let transition = Arc::clone(&self.lock_transition).lock_owned().await; + if self.locked.load(Ordering::Acquire) { return Err(AgentError::Failure); } - *locked = true; let mut lp = self.lock_passphrase.write().await; *lp = Some(hash_lock_passphrase(&passphrase)); + // No await between publishing the locked state and spawning the + // finalizer: cancellation can only happen once the finalizer owns the + // transition guard and is guaranteed to keep running. + self.locked.store(true, Ordering::Release); + drop(lp); - // Clear keys from memory on lock - let mut keys = self.keys.write().await; - keys.clear(); - - // Clear both auth caches on lock. - self.sign_auth_cache.write().await.clear(); - self.decrypt_auth_cache.write().await.clear(); + // Wait for outstanding permits, then advance the epoch even if no + // grant exists. The finalizer keeps running if this connection drops. + let finalizer = spawn_lock_finalizer( + transition, + Arc::clone(&self.keys), + Arc::clone(&self.authorization), + ); + finalizer + .await + .map_err(|error| agent_err(anyhow::anyhow!("lock finalizer failed: {error}")))?; tracing::info!("Agent locked"); Ok(()) @@ -2711,8 +3363,8 @@ impl Session for VtSshSession { use subtle::ConstantTimeEq; use zeroize::Zeroize; - let mut locked = self.locked.write().await; - if !*locked { + let _transition = self.lock_transition.lock().await; + if !self.locked.load(Ordering::Acquire) { return Err(AgentError::Failure); } let candidate = hash_lock_passphrase(&passphrase); @@ -2725,13 +3377,6 @@ impl Session for VtSshSession { if !matches { return Err(AgentError::Failure); } - *locked = false; - let mut lp = self.lock_passphrase.write().await; - if let Some(ref mut hash) = *lp { - hash.zeroize(); - } - *lp = None; - // Reload keys after unlock match load_all_keys() { Ok(loaded) => { @@ -2743,6 +3388,20 @@ impl Session for VtSshSession { } } + // A cancelled lock request may have published the locked bit before + // its caller disappeared. Revoke again before unlocking so no grant + // from before that transition can ever become live again. + self.authorization.invalidate_all().await; + + // Keep the passphrase intact across every await above. If this future + // is cancelled, the agent remains locked and a later unlock can retry. + let mut lp = self.lock_passphrase.write().await; + if let Some(ref mut hash) = *lp { + hash.zeroize(); + } + *lp = None; + self.locked.store(false, Ordering::Release); + tracing::info!("Agent unlocked"); Ok(()) } @@ -2756,14 +3415,28 @@ impl Session for VtSshSession { pub async fn run_ssh_agent( print_env: bool, idle_timeout_secs: u64, - sign_cache: AuthCacheConfig, - decrypt_cache: AuthCacheConfig, + cache_ttls: AuthCacheTtls, disable_legacy_decrypt: bool, run_allow: RunAllowlist, audit_push: Arc, + notify_cache_hits: bool, + ui_token: Option<[u8; 32]>, ) -> Result<()> { let idle_timeout = Duration::from_secs(idle_timeout_secs); + // Transparent wrap v1->v2 upgrade (docs/app-bundle.md §2): flock-guarded, + // touches only encrypted_passphrase + wrap_v. Failure is non-fatal here — + // a store bound to a moved binary path surfaces the same error with a + // clearer remedy below when keys are loaded. + match super::security::upgrade_wrap_v2_if_needed() { + Ok(true) => tracing::info!("master-key wrap upgraded to v2 (path-independent)"), + Ok(false) => {} + Err(e) => tracing::warn!( + "master-key wrap v2 upgrade did not run: {e:#} — run `vt secret rebind` \ + (with --old-bin-path if the binary moved)" + ), + } + // Load keys (cipher is loaded and dropped inside load_all_keys) let keys = load_all_keys()?; tracing::info!("Loaded {} SSH keys", keys.len()); @@ -2791,11 +3464,13 @@ pub async fn run_ssh_agent( let audit_enabled = audit_push.enabled; let factory = VtSshAgentFactory::new( keys, - sign_cache, - decrypt_cache, + cache_ttls, disable_legacy_decrypt, run_allow, audit_push, + notify_cache_hits, + ui_token, + idle_timeout_secs, ); if audit_enabled { tracing::info!("Agent audit push enabled"); @@ -2811,14 +3486,13 @@ pub async fn run_ssh_agent( // Spawn idle sweeper that clears keys from memory after inactivity. // Judged on both clocks (see `idle_exceeded`) so time asleep counts. - // Clearing keys also clears both auth caches: "idle long enough to drop + // Clearing keys also invalidates unified authorization grants: "idle long enough to drop // keys" implies the human is gone, so standing grants must not survive // the silent keychain reload that serves the next request. let sweeper_keys = Arc::clone(&factory.keys); let sweeper_last = Arc::clone(&factory.last_activity); let sweeper_idle_cleared = Arc::clone(&factory.idle_cleared); - let sweeper_sign_cache = Arc::clone(&factory.sign_auth_cache); - let sweeper_decrypt_cache = Arc::clone(&factory.decrypt_auth_cache); + let sweeper_authorization = Arc::clone(&factory.authorization); let sweeper_timeout = idle_timeout; tokio::spawn(async move { let check_interval = Duration::from_secs(60).min(sweeper_timeout); @@ -2835,86 +3509,70 @@ pub async fn run_ssh_agent( // Grants must not outlive the idle window even when no SSH // keys are loaded (decrypt-only agents), so the cache flush // is unconditional — not tied to the keys branch below. - let dropped = sweeper_sign_cache.write().await.clear() - + sweeper_decrypt_cache.write().await.clear(); + let dropped = sweeper_authorization.invalidate_all().await; if dropped > 0 { tracing::info!("Idle timeout, dropped {} auth cache grants", dropped); } - let mut keys = sweeper_keys.write().await; - if !keys.is_empty() { - let count = keys.len(); - keys.clear(); - let mut idle_cleared = sweeper_idle_cleared.write().await; - *idle_cleared = true; + let cleared = clear_keys_for_reload(&sweeper_keys, &sweeper_idle_cleared).await; + if cleared > 0 { tracing::info!( "Idle timeout ({} min), cleared {} keys from memory", sweeper_timeout.as_secs() / 60, - count + cleared ); } } } }); - // Spawn the cache watcher: flushes both auth caches when the screen - // locks or the system wakes from sleep (sudo-timestamp semantics), and - // sweeps expired entries periodically. See `watcher_should_clear`. - if sign_cache.mode != AuthCacheMode::None || decrypt_cache.mode != AuthCacheMode::None { - let watcher_sign = Arc::clone(&factory.sign_auth_cache); - let watcher_decrypt = Arc::clone(&factory.decrypt_auth_cache); - tokio::spawn(async move { - // `None` while there's nothing cached: the WindowServer poll is - // skipped, so no lock-state history exists. On the first tick - // with entries the unknown history defaults to "was interactive" - // — grants can only be created under an interactive screen - // (`authenticate` pre-checks session state), so finding the - // screen locked with live entries means it locked after the - // grant and the transition must fire. - let mut was_interactive: Option = None; - let mut prev_mono = Instant::now(); - let mut prev_wall = SystemTime::now(); - loop { - tokio::time::sleep(WATCHER_TICK).await; - let now_mono = Instant::now(); - let now_wall = SystemTime::now(); - let empty = watcher_sign.read().await.is_empty() - && watcher_decrypt.read().await.is_empty(); - if empty { - // Nothing to flush — with 30-120s TTLs this is the common - // case, so skip the CGSession poll entirely. - was_interactive = None; - } else { - let is_interactive = super::security::session_interactive_now(); - if watcher_should_clear( - was_interactive.unwrap_or(true), - is_interactive, - now_mono.saturating_duration_since(prev_mono), - now_wall.duration_since(prev_wall).ok(), - ) { - let dropped = watcher_sign.write().await.clear() - + watcher_decrypt.write().await.clear(); - tracing::info!( - "Auth caches cleared on screen lock / wake ({} grants dropped)", - dropped - ); - } else { - watcher_sign.write().await.sweep_expired(); - watcher_decrypt.write().await.sweep_expired(); - } - was_interactive = Some(is_interactive); - } - prev_mono = now_mono; - prev_wall = now_wall; + // Poll even while the grant store is empty. A human prompt deliberately + // creates no grant until its protected operation succeeds, so using store + // emptiness as a watcher shortcut would miss lock/wake invalidation while + // that prompt is in flight. + let watcher_authorization = Arc::clone(&factory.authorization); + let watcher_keys = Arc::clone(&factory.keys); + let watcher_idle_cleared = Arc::clone(&factory.idle_cleared); + tokio::spawn(async move { + let mut was_interactive = super::security::session_interactive_now(); + let mut prev_mono = Instant::now(); + let mut prev_wall = SystemTime::now(); + loop { + tokio::time::sleep(WATCHER_TICK).await; + let now_mono = Instant::now(); + let now_wall = SystemTime::now(); + let is_interactive = super::security::session_interactive_now(); + if watcher_should_clear( + was_interactive, + is_interactive, + now_mono.saturating_duration_since(prev_mono), + now_wall.duration_since(prev_wall).ok(), + ) { + let dropped = watcher_authorization.invalidate_all().await; + // §10 (C): lock/sleep must also wipe decrypted SSH keys from + // RAM, not just grants — screen lock does not otherwise clear + // them and (with a long idle timeout) they would linger. + // `ensure_keys_loaded` reloads silently on the next use once + // the screen is interactive again. + let cleared = clear_keys_for_reload(&watcher_keys, &watcher_idle_cleared).await; + tracing::info!( + "Screen lock / wake: {} grants dropped, {} keys cleared from memory", + dropped, + cleared + ); + } else { + watcher_authorization.sweep_expired().await; } - }); - tracing::info!( - "Auth cache: sign(mode={}, ttl={}s) decrypt(mode={}, ttl={}s); auth@vt never cached", - sign_cache.mode, - sign_cache.ttl_secs, - decrypt_cache.mode, - decrypt_cache.ttl_secs, - ); - } + was_interactive = is_interactive; + prev_mono = now_mono; + prev_wall = now_wall; + } + }); + tracing::info!( + "Authorization: sign(ttl={}s) decrypt(ttl={}s) auth=fresh run=fresh (0 = always prompt; \ + scopes: destination/workspace/connection — see docs/authorization-scopes-v2.md)", + cache_ttls.sign_secs, + cache_ttls.decrypt_secs, + ); let listener = tokio::net::UnixListener::bind(&socket_path)?; tracing::info!( @@ -2951,20 +3609,22 @@ pub async fn run_ssh_agent( /// Standalone entry point: runs the agent with env output. pub async fn start_ssh_agent( idle_timeout_secs: u64, - sign_cache: AuthCacheConfig, - decrypt_cache: AuthCacheConfig, + cache_ttls: AuthCacheTtls, disable_legacy_decrypt: bool, run_allow: RunAllowlist, audit_push: Arc, + notify_cache_hits: bool, + ui_token: Option<[u8; 32]>, ) -> Result<()> { run_ssh_agent( true, idle_timeout_secs, - sign_cache, - decrypt_cache, + cache_ttls, disable_legacy_decrypt, run_allow, audit_push, + notify_cache_hits, + ui_token, ) .await } @@ -2972,8 +3632,83 @@ pub async fn start_ssh_agent( #[cfg(test)] mod tests { use super::*; + use crate::core::authorization::{ + AuthorizationAuthenticator, AuthorizationValidator, ValidationError, + }; use crate::core::session::AuthMethod; + struct TestAuthenticator; + + #[async_trait] + impl AuthorizationAuthenticator for TestAuthenticator { + async fn authenticate( + &self, + _prompt: &str, + _revocation_pending: Arc, + ) -> AuthOutcome { + AuthOutcome::Success(AuthMethod::Biometric) + } + } + + struct TestValidator; + + impl AuthorizationValidator for TestValidator { + fn validate( + &self, + _revocation_pending: &AtomicBool, + ) -> std::result::Result<(), ValidationError> { + Ok(()) + } + } + + #[tokio::test] + async fn detached_lock_finalizer_holds_transition_and_clears_grants() { + let authorization = + AuthorizationEngine::new(Arc::new(TestAuthenticator), Arc::new(TestValidator)); + let subject = (1, 2); + authorization + .authorize(AuthorizationRequest::new( + vec![GrantScope::sign(Some(subject), "cached", "")], + ReusePolicy::strict_ttl_secs(30), + "seed", + )) + .await + .unwrap() + .commit() + .await + .unwrap(); + let active = authorization + .authorize(AuthorizationRequest::new( + vec![GrantScope::sign(Some(subject), "active", "")], + ReusePolicy::strict_ttl_secs(30), + "active", + )) + .await + .unwrap(); + + let transition = Arc::new(Mutex::new(())); + let guard = Arc::clone(&transition).lock_owned().await; + let keys = Arc::new(RwLock::new(HashMap::::new())); + let finalizer = + spawn_lock_finalizer(guard, keys, Arc::clone(&authorization)); + // Model cancellation of the connection waiting for lock() to finish. + // Tokio detaches the finalizer, which must retain the owned guard. + drop(finalizer); + assert!(transition.try_lock().is_err()); + + active.commit().await.unwrap(); + let transition_guard = tokio::time::timeout(Duration::from_secs(1), transition.lock()) + .await + .expect("detached lock finalizer did not complete"); + drop(transition_guard); + assert_eq!( + authorization + .live_len(Operation::Sign, ScopeFamily::Connection, subject) + .await, + 0 + ); + } + // sign@vt's signing core: an Ed25519 PrivateKey signs `data` and the // signature verifies under its own public key, tagged `ssh-ed25519`. This is // the same code path the standard `Session::sign` now uses (shared helper), @@ -3149,312 +3884,818 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(entries.is_empty()); } - // --- AuthCacheMode tests --- + // --- Activity-scope classification tests (V2) --- + + fn test_session(sign_ttl: u64, decrypt_ttl: u64) -> VtSshSession { + let locked = Arc::new(AtomicBool::new(false)); + VtSshSession { + keys: Arc::new(RwLock::new(HashMap::new())), + last_activity: Arc::new(RwLock::new((Instant::now(), SystemTime::now()))), + locked: Arc::clone(&locked), + lock_transition: Arc::new(Mutex::new(())), + lock_passphrase: Arc::new(RwLock::new(None)), + idle_cleared: Arc::new(RwLock::new(false)), + authorization: new_engine(locked), + cache_ttls: AuthCacheTtls { + sign_secs: sign_ttl, + decrypt_secs: decrypt_ttl, + }, + run_allow: Arc::new(RunAllowlist::parse("").unwrap()), + audit_push: Arc::new(AuditPushConfig::disabled()), + peer_pid: Some(std::process::id() as i32), + peer_exe: Some("test".to_string()), + peer_is_vt_relay: false, + peer_is_ssh_client: false, + connection_subject: None, + // Pre-set: the lazy resolver would otherwise resolve the test + // process's real repo workspace. + workspace: std::sync::OnceLock::from(WorkspaceResolution::NoRoot), + bind_state: BindState::Unbound, + destination_label: None, + disable_legacy_decrypt: false, + notify_cache_hits: false, + ui_token: None, + idle_timeout_secs: 1800, + } + } + + fn test_workspace() -> Workspace { + Workspace { + subject: (7, 42), + root: "/repo".to_string(), + } + } + + fn test_bind(privkey: &PrivateKey, session_id: &[u8], forwarding: bool) -> SessionBind { + let signature = sign_data_with_privkey(privkey, session_id, 0).expect("sign session id"); + SessionBind { + host_key: privkey.public_key().key_data().clone(), + session_id: session_id.to_vec(), + signature, + is_forwarding: forwarding, + } + } + + fn test_hostkey() -> PrivateKey { + PrivateKey::random(&mut rand::rngs::OsRng, Algorithm::Ed25519).expect("gen key") + } #[test] - fn test_auth_cache_mode_from_str() { - assert_eq!( - AuthCacheMode::from_str("none").unwrap(), - AuthCacheMode::None - ); - assert_eq!( - AuthCacheMode::from_str("global").unwrap(), - AuthCacheMode::Global - ); - assert_eq!( - AuthCacheMode::from_str("per-session").unwrap(), - AuthCacheMode::PerSession - ); - assert_eq!( - AuthCacheMode::from_str("per_session").unwrap(), - AuthCacheMode::PerSession - ); - assert_eq!( - AuthCacheMode::from_str("session").unwrap(), - AuthCacheMode::PerSession - ); - assert_eq!( - AuthCacheMode::from_str("per-app").unwrap(), - AuthCacheMode::PerApp - ); - assert_eq!( - AuthCacheMode::from_str("per_app").unwrap(), - AuthCacheMode::PerApp - ); - assert_eq!( - AuthCacheMode::from_str("app").unwrap(), - AuthCacheMode::PerApp - ); + fn bind_state_valid_bind_exposes_destination() { + let host = test_hostkey(); + let mut state = BindState::Unbound; + assert!(state.apply(&test_bind(&host, b"sid-1", false)).is_ok()); + let (wire, key) = state.destination().expect("destination-bound"); + assert!(!wire.is_empty()); + assert_eq!(fingerprint_str(key), fingerprint_str(host.public_key().key_data())); + // A second session id under the SAME key (re-KEX) keeps the binding. + assert!(state.apply(&test_bind(&host, b"sid-2", false)).is_ok()); + assert!(state.destination().is_some()); } #[test] - fn test_auth_cache_mode_from_str_case_insensitive() { - assert_eq!( - AuthCacheMode::from_str("None").unwrap(), - AuthCacheMode::None - ); - assert_eq!( - AuthCacheMode::from_str("NONE").unwrap(), - AuthCacheMode::None - ); - assert_eq!( - AuthCacheMode::from_str("Per-Session").unwrap(), - AuthCacheMode::PerSession - ); - assert_eq!( - AuthCacheMode::from_str("PER-APP").unwrap(), - AuthCacheMode::PerApp - ); + fn bind_state_bad_signature_refused_without_poisoning() { + let host = test_hostkey(); + let mut bind = test_bind(&host, b"sid-1", false); + bind.session_id = b"sid-other".to_vec(); // signature no longer matches + let mut state = BindState::Unbound; + assert!(state.apply(&bind).is_err()); + // Unverifiable binds (bad signature, cert/unsupported-curve host + // keys) are refused but do NOT poison the state: a later genuine + // bind still works (OpenSSH behavior). + assert!(matches!(state, BindState::Unbound)); + assert!(state.apply(&test_bind(&host, b"sid-2", false)).is_ok()); + assert!(state.destination().is_some()); } #[test] - fn test_auth_cache_mode_from_str_invalid() { - assert!(AuthCacheMode::from_str("invalid").is_err()); - assert!(AuthCacheMode::from_str("").is_err()); - assert!(AuthCacheMode::from_str("per").is_err()); + fn bind_state_forwarding_never_destination_cacheable() { + let host = test_hostkey(); + // Forwarding on the first bind. + let mut state = BindState::Unbound; + assert!(state.apply(&test_bind(&host, b"sid-1", true)).is_ok()); + assert!(state.destination().is_none()); + // Forwarding after an auth bind: sticky. + let mut state = BindState::Unbound; + assert!(state.apply(&test_bind(&host, b"sid-1", false)).is_ok()); + assert!(state.apply(&test_bind(&host, b"sid-2", true)).is_ok()); + assert!(state.destination().is_none()); } #[test] - fn test_auth_cache_mode_display() { - assert_eq!(AuthCacheMode::None.to_string(), "none"); - assert_eq!(AuthCacheMode::PerSession.to_string(), "per-session"); - assert_eq!(AuthCacheMode::PerApp.to_string(), "per-app"); + fn bind_state_second_destination_marks_forwarding() { + let (h1, h2) = (test_hostkey(), test_hostkey()); + let mut state = BindState::Unbound; + assert!(state.apply(&test_bind(&h1, b"sid-1", false)).is_ok()); + assert!(state.apply(&test_bind(&h2, b"sid-2", false)).is_ok()); + assert!(state.destination().is_none()); } - // --- AuthCache tests --- + #[test] + fn bind_state_duplicate_session_id_conflicts_taint() { + let (h1, h2) = (test_hostkey(), test_hostkey()); + // Same session id under a different key. + let mut state = BindState::Unbound; + assert!(state.apply(&test_bind(&h1, b"sid-1", false)).is_ok()); + assert!(state.apply(&test_bind(&h2, b"sid-1", false)).is_err()); + assert!(matches!(state, BindState::Tainted)); + // Forwarding→auth downgrade for the same session id. + let mut state = BindState::Unbound; + assert!(state.apply(&test_bind(&h1, b"sid-1", true)).is_ok()); + assert!(state.apply(&test_bind(&h1, b"sid-1", false)).is_err()); + assert!(matches!(state, BindState::Tainted)); + } #[test] - fn test_auth_cache_grant_and_hit() { - let mut cache = AuthCache::new(300); - let ctx = (1u64, 100u64); - assert!(!cache.is_authorized(ctx, "fp1")); + fn bind_state_caps_recorded_session_ids_without_poisoning() { + let host = test_hostkey(); + let mut state = BindState::Unbound; + for i in 0..MAX_SESSION_BINDS { + let sid = format!("sid-{i}"); + assert!(state.apply(&test_bind(&host, sid.as_bytes(), false)).is_ok()); + } + // The excess bind is refused but the established binding survives. + assert!(state.apply(&test_bind(&host, b"sid-overflow", false)).is_err()); + assert!(state.destination().is_some()); + } - cache.grant(ctx, "fp1"); - assert!(cache.is_authorized(ctx, "fp1")); + #[test] + fn destination_line_shown_when_bound_and_fresh() { + // docs/approval-transparency.md §A1: with the default TTL of 0 the + // reuse line never appears, so the verified destination must get its + // own truth line. + let mut s = test_session(0, 0); + s.peer_is_ssh_client = true; + s.bind_state + .apply(&test_bind(&test_hostkey(), b"sid", false)) + .unwrap(); + let mut msg = String::from("sign: k"); + s.append_destination_line(&mut msg, false); + assert!(msg.contains("\ndest: SHA256:"), "missing dest line: {msg}"); + assert!(!msg.contains("forwarding"), "non-forwarding bind flagged: {msg}"); } #[test] - fn test_resolve_cache_context_global_needs_no_tty() { - // Global must resolve for ANY identified peer — including TTY-less - // orchestrated callers (AI agents / CI) whose fresh-session spawns - // make per-session/per-app permanently miss. Our own test process - // works regardless of whether the runner has a controlling terminal. - let me = std::process::id() as i32; - assert_eq!( - resolve_cache_context(Some(me), AuthCacheMode::Global, false), - Some((0, 0)) - ); - // No peer PID → still uncacheable even in global mode. - assert_eq!(resolve_cache_context(None, AuthCacheMode::Global, false), None); - // None mode never caches. - assert_eq!(resolve_cache_context(Some(me), AuthCacheMode::None, false), None); - } - - #[test] - fn test_classify_cache_context_basis() { - let me = std::process::id() as i32; - // Precedence: ModeNone wins even with no peer pid. - let r = classify_cache_context(None, AuthCacheMode::None, false); - assert_eq!((r.context, r.basis), (None, ContextBasis::ModeNone)); - let r = classify_cache_context(None, AuthCacheMode::Global, false); - assert_eq!((r.context, r.basis), (None, ContextBasis::NoPeerPid)); - // Our own test binary is not `ssh`, so global resolves via the - // Global arm — and resolve_cache_context must agree (thin wrapper). - let r = classify_cache_context(Some(me), AuthCacheMode::Global, false); - assert_eq!((r.context, r.basis), (Some((0, 0)), ContextBasis::Global)); - assert_eq!( - resolve_cache_context(Some(me), AuthCacheMode::Global, false), - r.context - ); - // vt-relay narrowing beats every mode, keyed on the peer process. - let r = classify_cache_context(Some(me), AuthCacheMode::Global, true); - assert_eq!(r.basis, ContextBasis::VtRelay); - assert_eq!(r.mode, AuthCacheMode::Global); - let ctx = r.context.expect("own pid must have a start time"); - assert_eq!(ctx.0, me as u64); - // (Wire-tag stability is pinned in core.rs tests, next to the enum.) + fn destination_line_skipped_when_reuse_line_names_it() { + let mut s = test_session(300, 0); + s.peer_is_ssh_client = true; + s.bind_state + .apply(&test_bind(&test_hostkey(), b"sid", false)) + .unwrap(); + let (_, reuse_label) = s.raw_sign_scope("fp"); + assert!(reuse_label.is_some(), "bound non-forwarding peer must be reusable"); + let mut msg = String::from("sign: k"); + s.append_destination_line(&mut msg, reuse_label.is_some()); + assert_eq!(msg, "sign: k", "dest line must not duplicate the reuse label: {msg}"); } #[test] - fn test_auth_cache_live_len_scoped_to_context() { - let mut cache = AuthCache::new(300); - let mine = (1u64, 100u64); - let other = (2u64, 100u64); - cache.grant(mine, "fp1"); - cache.grant(mine, "fp2"); - cache.grant(other, "fp3"); - // Scoped: never counts another context's grants. - assert_eq!(cache.live_len(mine), 2); - assert_eq!(cache.live_len(other), 1); - assert_eq!(cache.live_len((3u64, 100u64)), 0); - // Dual-clock expiry: entries drop out when EITHER clock passes TTL, - // same predicate as is_authorized (wall advanced, mono frozen). - let now_mono = Instant::now(); - let now_wall = SystemTime::now(); - assert_eq!(cache.live_len_at(mine, now_mono, now_wall), 2); - let wall_late = now_wall + Duration::from_secs(301); - assert_eq!(cache.live_len_at(mine, now_mono, wall_late), 0); + fn destination_line_flags_forwarding_and_taint() { + // Forwarding-capable bind: destination shown WITH the forwarding flag. + let mut s = test_session(0, 0); + s.peer_is_ssh_client = true; + s.bind_state + .apply(&test_bind(&test_hostkey(), b"sid", true)) + .unwrap(); + let mut msg = String::new(); + s.append_destination_line(&mut msg, false); + assert!(msg.contains("\ndest: SHA256:"), "forwarding bind must still name hop one: {msg}"); + assert!(msg.contains("(forwarding"), "missing forwarding flag: {msg}"); + // Tainted bind: explicit warning, no destination claim. + let mut s = test_session(0, 0); + s.bind_state = BindState::Tainted; + let mut msg = String::new(); + s.append_destination_line(&mut msg, false); + assert_eq!(msg, "\nwarning: session-bind verification failed"); + // Unbound: nothing. + let s = test_session(0, 0); + let mut msg = String::new(); + s.append_destination_line(&mut msg, false); + assert!(msg.is_empty()); } - // (Strict-TTL idempotence and expired-entry replacement are covered - // deterministically by the clock-injected `_at` tests below — no - // sleep-based duplicates.) + #[test] + fn caller_line_is_kernel_exe_and_sanitized() { + let mut s = test_session(0, 0); + s.peer_exe = Some("git\nremote".to_string()); + let mut msg = String::from("h"); + s.append_caller_line(&mut msg); + assert!(msg.starts_with("h\ncaller: "), "missing caller line: {msg}"); + // The control character must not survive to inject a fake line. + assert_eq!(msg.matches('\n').count(), 1, "unsanitized caller exe: {msg}"); + // Unknown peer exe → no line. + let mut s = test_session(0, 0); + s.peer_exe = None; + let mut msg = String::from("h"); + s.append_caller_line(&mut msg); + assert_eq!(msg, "h"); + // The vt CLI itself is the no-information common case → suppressed + // (exception display; audit still records peer_exe unconditionally). + let mut s = test_session(0, 0); + s.peer_exe = Some("vt".to_string()); + let mut msg = String::from("h"); + s.append_caller_line(&mut msg); + assert_eq!(msg, "h"); + } #[test] - fn test_auth_cache_different_context_misses() { - let mut cache = AuthCache::new(300); - let ctx1 = (1u64, 100u64); - let ctx2 = (2u64, 100u64); - cache.grant(ctx1, "fp1"); + fn contract_home_display_only() { + let home = dirs::home_dir().expect("home").to_string_lossy().into_owned(); + assert_eq!(contract_home(&format!("{home}/code/vt")), "~/code/vt"); + assert_eq!(contract_home(&home), "~"); + // A sibling path sharing the prefix without a separator must NOT + // contract (e.g. /Users/qiqi2 vs /Users/qiqi). + assert_eq!(contract_home(&format!("{home}2/x")), format!("{home}2/x")); + assert_eq!(contract_home("/repo"), "/repo"); + } - // Same fingerprint, different context - assert!(!cache.is_authorized(ctx2, "fp1")); - // Same context, different fingerprint - assert!(!cache.is_authorized(ctx1, "fp2")); + #[test] + fn audit_ctx_reports_scope_and_destination() { + // Workspace-scoped sign: family/label/ttl populated. + let mut s = test_session(300, 0); + s.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + let (scope, label) = s.sign_vt_scope("fp", "/repo/sub"); + let ctx = s.audit_ctx_scoped(scope.family(), &label, 300); + assert_eq!(ctx.scope_family, "workspace"); + assert_eq!(ctx.scope_label, "/repo"); + assert_eq!(ctx.grant_ttl_s, 300); + assert_eq!(ctx.peer_exe, "test"); + assert!(!ctx.relayed); + // Fresh (ttl 0): scope fields stay empty even though a basis exists. + let (scope, label) = test_session(0, 0).sign_vt_scope("fp", ""); + let ctx = s.audit_ctx_scoped(scope.family(), &label, 0); + assert_eq!(ctx.scope_family, ""); + assert_eq!(ctx.scope_label, ""); + assert_eq!(ctx.grant_ttl_s, 0); + // audit_destination: only a bound, non-forwarding session-bind counts. + let mut s = test_session(0, 0); + s.bind_state + .apply(&test_bind(&test_hostkey(), b"sid", false)) + .unwrap(); + assert!(s.audit_destination().starts_with("SHA256:")); + let mut s = test_session(0, 0); + s.bind_state + .apply(&test_bind(&test_hostkey(), b"sid", true)) + .unwrap(); + assert_eq!(s.audit_destination(), ""); + assert_eq!(test_session(0, 0).audit_destination(), ""); } #[test] - fn test_auth_cache_start_time_distinguishes_reused_pid() { - // Same PID, different start time → different context (PID reuse) - let mut cache = AuthCache::new(300); - let orig = (1234u64, 1_700_000_000u64); - let reused = (1234u64, 1_700_000_500u64); - cache.grant(orig, "fp1"); + fn reuse_label_present_iff_scope_reusable() { + // §6 transparency invariant: the prompt reuse line exists exactly + // when the approval can create a standing grant — across every + // classification arm. + let inputs = vec![(crate::core::SecretType::RAW, [9u8; SALT_LEN])]; + let mut sessions = Vec::new(); + // workspace-resolved local caller + let mut s = test_session(300, 300); + s.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + sessions.push(s); + // destination-bound ssh caller + let mut s = test_session(300, 300); + s.peer_is_ssh_client = true; + s.bind_state + .apply(&test_bind(&test_hostkey(), b"sid", false)) + .unwrap(); + sessions.push(s); + // confined relay caller + let mut s = test_session(300, 300); + s.peer_is_vt_relay = true; + s.connection_subject = Some((123, 456)); + sessions.push(s); + // disabled + no-workspace-root callers + sessions.push(test_session(0, 0)); + sessions.push(test_session(300, 300)); + + for s in &sessions { + for pwd in ["", "/repo/sub", "/elsewhere"] { + let (scope, label) = s.sign_vt_scope("fp", pwd); + assert_eq!(scope.is_reusable(), label.is_some()); + let (scope, label) = s.raw_sign_scope("fp"); + assert_eq!(scope.is_reusable(), label.is_some()); + let (scopes, label) = s.decrypt_scopes(&inputs, "h", pwd); + assert_eq!( + scopes.iter().all(GrantScope::is_reusable), + label.is_some() + ); + } + } + } - assert!(cache.is_authorized(orig, "fp1")); - assert!(!cache.is_authorized(reused, "fp1")); + #[test] + fn raw_sign_scope_classification() { + // Duration 0: Fresh, no reuse line, regardless of state. + let mut s = test_session(0, 0); + s.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + assert!(s.raw_sign_scope("fp").1.is_none()); + assert_eq!(s.sign_basis(), ContextBasis::Disabled); + + // Unbound ssh peer: Fresh (auth vs forwarding indistinguishable). + let mut s = test_session(300, 0); + s.peer_is_ssh_client = true; + assert!(s.raw_sign_scope("fp").1.is_none()); + assert_eq!(s.sign_basis(), ContextBasis::UnboundSsh); + + // Unbound non-ssh with a workspace: workspace scope + label (bare + // path = workspace root; narrower families keep their prefix). + let mut s = test_session(300, 0); + s.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + let (_, label) = s.raw_sign_scope("fp"); + assert_eq!(label.as_deref(), Some("/repo")); + assert_eq!(s.sign_basis(), ContextBasis::Workspace); + + // No workspace root: Fresh. + let s = test_session(300, 0); + assert!(s.raw_sign_scope("fp").1.is_none()); + assert_eq!(s.sign_basis(), ContextBasis::NoWorkspaceRoot); + + // Destination-bound: destination label (fingerprint at minimum). + let mut s = test_session(300, 0); + s.peer_is_ssh_client = true; + let host = test_hostkey(); + assert!(s.bind_state.apply(&test_bind(&host, b"sid", false)).is_ok()); + let (_, label) = s.raw_sign_scope("fp"); + assert!(label + .expect("destination-bound must offer reuse") + .contains(&fingerprint_str(host.public_key().key_data()))); + assert_eq!(s.sign_basis(), ContextBasis::SessionBind); + + // Tainted: Fresh. + let mut s = test_session(300, 0); + s.bind_state = BindState::Tainted; + assert!(s.raw_sign_scope("fp").1.is_none()); + assert_eq!(s.sign_basis(), ContextBasis::Tainted); + + // Relay: confined per connection. + let mut s = test_session(300, 0); + s.peer_is_vt_relay = true; + s.connection_subject = Some((123, 456)); + assert_eq!( + s.raw_sign_scope("fp").1.as_deref(), + Some("this relay connection") + ); + assert_eq!(s.sign_basis(), ContextBasis::RelayConnection); } #[test] - fn test_auth_cache_clear() { - let mut cache = AuthCache::new(300); - let ctx1 = (1u64, 100u64); - let ctx2 = (2u64, 100u64); - cache.grant(ctx1, "fp1"); - cache.grant(ctx2, "fp2"); - assert!(cache.is_authorized(ctx1, "fp1")); + fn sign_vt_and_decrypt_scopes_respect_workspace_and_pwd() { + let mut s = test_session(300, 300); + s.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + // Claimed pwd inside the workspace: reusable. + assert!(s.sign_vt_scope("fp", "/repo/sub").1.is_some()); + let inputs = vec![(crate::core::SecretType::RAW, [9u8; SALT_LEN])]; + assert!(s.decrypt_scopes(&inputs, "h", "/repo").1.is_some()); + // Claimed pwd outside the workspace: consistency check → Fresh. + assert!(s.sign_vt_scope("fp", "/elsewhere").1.is_none()); + assert!(s.decrypt_scopes(&inputs, "h", "/elsewhere").1.is_none()); + // Empty pwd (raw-style caller): allowed. + assert!(s.sign_vt_scope("fp", "").1.is_some()); + // Relay: per-connection scope, not workspace. + let mut s = test_session(300, 300); + s.peer_is_vt_relay = true; + s.connection_subject = Some((123, 456)); + assert_eq!( + s.decrypt_scopes(&inputs, "h", "/x").1.as_deref(), + Some("this relay connection") + ); + assert_eq!(s.decrypt_basis(), ContextBasis::RelayConnection); + } - cache.clear(); - assert!(!cache.is_authorized(ctx1, "fp1")); - assert!(!cache.is_authorized(ctx2, "fp2")); + #[test] + fn ssh_peer_vt_extensions_are_confined_per_connection_never_workspace() { + // An `ssh -A` peer can carry a REMOTE host's vt extensions; even if a + // workspace were somehow resolved it must never be used — otherwise + // the remote rides local workspace grants. + let mut s = test_session(300, 300); + s.peer_is_ssh_client = true; + s.connection_subject = Some((123, 456)); + s.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + let inputs = vec![(crate::core::SecretType::RAW, [9u8; SALT_LEN])]; + assert_eq!( + s.sign_vt_scope("fp", "/repo").1.as_deref(), + Some("this forwarded ssh connection") + ); + assert_eq!( + s.decrypt_scopes(&inputs, "h", "/repo").1.as_deref(), + Some("this forwarded ssh connection") + ); + assert_eq!(s.decrypt_basis(), ContextBasis::SshConnection); + // With no resolvable connection subject the arm degrades to Fresh. + s.connection_subject = None; + assert!(s.sign_vt_scope("fp", "/repo").1.is_none()); + assert!(s.decrypt_scopes(&inputs, "h", "/repo").1.is_none()); + assert_eq!(s.decrypt_basis(), ContextBasis::ProcLookupFailed); } #[test] - fn test_auth_cache_sweep_expired() { - let mut cache = AuthCache::new(0); // 0 second = immediately expired - cache.grant((1u64, 100u64), "fp1"); - cache.grant((2u64, 100u64), "fp2"); + fn workspace_root_acceptability_rejects_home_pooling() { + let home = std::path::Path::new("/Users/x"); + // A dotfiles repo AT $HOME must not become a workspace. + assert!(!workspace_root_acceptable( + home, + std::path::Path::new("/Users/x/Downloads"), + Some(home) + )); + // A root above $HOME for a cwd inside $HOME is rejected too. + assert!(!workspace_root_acceptable( + std::path::Path::new("/Users"), + std::path::Path::new("/Users/x/proj"), + Some(home) + )); + // Ordinary project roots pass, inside or outside $HOME. + assert!(workspace_root_acceptable( + std::path::Path::new("/Users/x/code/vt"), + std::path::Path::new("/Users/x/code/vt/src"), + Some(home) + )); + assert!(workspace_root_acceptable( + std::path::Path::new("/opt/work/repo"), + std::path::Path::new("/opt/work/repo/a"), + Some(home) + )); + } - std::thread::sleep(std::time::Duration::from_millis(10)); - cache.sweep_expired(); - assert!(cache.entries.is_empty()); + #[test] + fn cwd_fallback_rejects_broad_shared_directories() { + let home = std::path::Path::new("/Users/x"); + let p = std::path::Path::new; + // $HOME and its ancestors pool everything launched from them. + assert!(!cwd_fallback_acceptable(home, Some(home))); + assert!(!cwd_fallback_acceptable(p("/Users"), Some(home))); + assert!(!cwd_fallback_acceptable(p("/"), Some(home))); + // Shared temp roots (canonical forms — F_GETPATH resolves /tmp). + assert!(!cwd_fallback_acceptable(p("/private/tmp"), Some(home))); + assert!(!cwd_fallback_acceptable(p("/private/var/tmp"), Some(home))); + assert!(!cwd_fallback_acceptable(p("/Volumes"), Some(home))); + if let Some(t) = darwin_user_temp_dir() { + assert!(!cwd_fallback_acceptable(t, Some(home))); + } + // Subdirectories of the shared roots name one activity: acceptable. + assert!(cwd_fallback_acceptable(p("/private/tmp/scratch"), Some(home))); + assert!(cwd_fallback_acceptable(p("/Users/x/notes"), Some(home))); + assert!(cwd_fallback_acceptable(p("/opt/deploy"), Some(home))); } - // --- Dual-clock expiry tests (sleep / clock-step scenarios) --- - // - // Clock-injected regression tests for the grant-survives-sleep bug; - // see [`CacheExpiry`] for the CLOCK_UPTIME_RAW rationale. + #[test] + fn resolve_workspace_falls_back_to_cwd_without_git_root() { + // End-to-end through the kernel-cwd path: a peer whose cwd has no + // `.git` ancestor lands in the cwd-fallback arm keyed on the + // canonical cwd; the same directory flips to the git family once a + // `.git` marker appears (grant separation between the two families + // is pinned at the digest level by scope_families_are_domain_separated). + let dir = temp_workspace("cwd-fallback"); + let canonical = std::fs::canonicalize(&dir).unwrap(); + if find_git_root(&canonical).is_some() { + // Defensive: a `.git` above the temp dir would invalidate the + // scenario; skip rather than assert a wrong arm. + let _ = std::fs::remove_dir_all(&dir); + return; + } + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .current_dir(&dir) + .spawn() + .expect("spawn peer stand-in"); + let pid = child.id() as i32; + + match resolve_workspace(Some(pid)) { + WorkspaceResolution::CwdFallback(ws) => { + assert_eq!(PathBuf::from(ws.root_str()), canonical); + } + other => panic!("expected cwd fallback, got {other:?}"), + } + std::fs::create_dir_all(dir.join(".git")).unwrap(); + match resolve_workspace(Some(pid)) { + WorkspaceResolution::Resolved(ws) => { + assert_eq!(PathBuf::from(ws.root_str()), canonical); + } + other => panic!("expected git workspace after git init, got {other:?}"), + } + + let _ = child.kill(); + let _ = child.wait(); + let _ = std::fs::remove_dir_all(&dir); + } #[test] - fn test_auth_cache_wall_clock_expires_grant_across_sleep() { - let mut cache = AuthCache::new(120); - let ctx = (1u64, 100u64); - let m0 = Instant::now(); - let w0 = SystemTime::now(); - cache.grant_at(ctx, "fp1", m0, w0); + fn cwd_fallback_scopes_use_directory_label_and_basis() { + // sign@vt / decrypt: cwd fallback behaves like a workspace but with + // the "directory" wording and the cwd-fallback basis. + let mut s = test_session(300, 300); + s.workspace = WorkspaceResolution::CwdFallback(test_workspace()).into(); + let (scope, label) = s.sign_vt_scope("fp", "/repo/sub"); + assert!(scope.is_reusable()); + assert_eq!(label.as_deref(), Some("directory /repo")); + assert_eq!(s.decrypt_basis(), ContextBasis::CwdWorkspace); + // pwd consistency check still applies. + assert!(s.sign_vt_scope("fp", "/elsewhere").1.is_none()); + + let inputs = vec![(crate::core::SecretType::RAW, [9u8; SALT_LEN])]; + assert_eq!( + s.decrypt_scopes(&inputs, "h", "/repo").1.as_deref(), + Some("directory /repo") + ); - // 1s of awake time, but 10 minutes of wall time passed (slept). - let asleep_mono = m0 + Duration::from_secs(1); - let asleep_wall = w0 + Duration::from_secs(600); - assert!( - !cache.is_authorized_at(ctx, "fp1", asleep_mono, asleep_wall), - "grant must not survive a sleep longer than the TTL" + // Raw sign, unbound non-ssh peer: same fallback arm. + let (scope, label) = s.raw_sign_scope("fp"); + assert!(scope.is_reusable()); + assert_eq!(label.as_deref(), Some("directory /repo")); + assert_eq!(s.sign_basis(), ContextBasis::CwdWorkspace); + + // The cwd-fallback scope must differ from the git-workspace scope of + // the same directory identity. + let mut git = test_session(300, 300); + git.workspace = WorkspaceResolution::Resolved(test_workspace()).into(); + assert_eq!(git.sign_vt_scope("fp", "/repo").1.as_deref(), Some("/repo")); + } + + #[test] + fn parent_app_identity_resolves_kernel_parent() { + // A child we spawn has US as its kernel parent: identity must carry + // this process's (pid, start) subject and executable path. + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("spawn child"); + let app = parent_app_identity(child.id() as i32).expect("parent identity"); + let me = std::process::id() as u64; + assert_eq!(app.subject.0, me); + assert_eq!( + app.exe, + proc_info::get_proc_path(me as i32).expect("own exe") ); + assert!(!app.name().contains('/')); + let _ = child.kill(); + let _ = child.wait(); } #[test] - fn test_auth_cache_mono_clock_bounds_stalled_wall_clock() { - // Wall clock stalled or stepped backwards (NTP): the monotonic bound - // still caps total awake time at the TTL. - let mut cache = AuthCache::new(120); - let ctx = (1u64, 100u64); - let m0 = Instant::now(); - let w0 = SystemTime::now(); - cache.grant_at(ctx, "fp1", m0, w0); + fn resolve_workspace_uses_parent_app_for_broad_cwd() { + // A peer whose cwd is an excluded shared root (the per-user Darwin + // temp dir) must resolve to its parent application, not NoRoot. + let dir = std::env::temp_dir(); + let canonical = std::fs::canonicalize(&dir).unwrap(); + if find_git_root(&canonical).is_some() + || cwd_fallback_acceptable(&canonical, dirs::home_dir().as_deref()) + { + // Defensive: scenario requires an excluded, non-git cwd. + return; + } + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .current_dir(&dir) + .spawn() + .expect("spawn peer stand-in"); + match resolve_workspace(Some(child.id() as i32)) { + WorkspaceResolution::AppFallback(app) => { + assert_eq!(app.subject.0, std::process::id() as u64); + } + other => panic!("expected parent-app fallback, got {other:?}"), + } + let _ = child.kill(); + let _ = child.wait(); + } - let late_mono = m0 + Duration::from_secs(121); - let early_wall = w0 + Duration::from_secs(1); + #[test] + fn parent_app_scopes_use_app_label_and_basis() { + let app = AppIdentity { + subject: (77, 4242), + exe: "/Applications/Paseo.app/Contents/MacOS/Paseo Daemon".to_string(), + }; + let mut s = test_session(300, 300); + s.workspace = WorkspaceResolution::AppFallback(app).into(); + // sign@vt / decrypt: reusable, "app" wording, parent-app basis. The + // claimed pwd is display-only in this arm (no root to check against). + let (scope, label) = s.sign_vt_scope("fp", "/anywhere"); + assert!(scope.is_reusable()); + assert_eq!(label.as_deref(), Some("app Paseo Daemon")); + assert_eq!(s.decrypt_basis(), ContextBasis::ParentApp); + let inputs = vec![(crate::core::SecretType::RAW, [9u8; SALT_LEN])]; + assert_eq!( + s.decrypt_scopes(&inputs, "h", "/").1.as_deref(), + Some("app Paseo Daemon") + ); + // Raw sign, unbound non-ssh peer: same fallback arm. + let (scope, label) = s.raw_sign_scope("fp"); + assert!(scope.is_reusable()); + assert_eq!(label.as_deref(), Some("app Paseo Daemon")); + assert_eq!(s.sign_basis(), ContextBasis::ParentApp); + } + + #[tokio::test] + async fn extension_intercepts_plaintext_session_bind_before_auth_cipher() { + // The seam most likely to regress: session-bind is plain SSH wire + // bytes and must be handled before the keychain/auth-cipher path — + // this test passes precisely because no keychain access happens. + let mut session = test_session(300, 0); + session.peer_is_ssh_client = true; + let host = test_hostkey(); + let ext = Extension::new_message(test_bind(&host, b"sid-1", false)) + .expect("encode session-bind"); + let reply = session + .extension(ext) + .await + .expect("bind must succeed without keychain"); + assert!(reply.is_none(), "plain SSH_AGENT_SUCCESS, no envelope"); + assert!(session.bind_state.destination().is_some()); assert!( - !cache.is_authorized_at(ctx, "fp1", late_mono, early_wall), - "a backwards/stalled wall clock must not extend the grant" + session.destination_label.is_some(), + "label precomputed at bind time" ); + assert_eq!(session.sign_basis(), ContextBasis::SessionBind); + + // Malformed bind: refused without poisoning state (an undecodable + // host key — certificates, unsupported curves — is not an attack). + let mut session = test_session(300, 0); + let malformed = Extension { + name: "session-bind@openssh.com".to_string(), + details: Unparsed::from(vec![1u8, 2, 3]), + }; + assert!(session.extension(malformed).await.is_err()); + assert!(matches!(session.bind_state, BindState::Unbound)); } - #[test] - fn test_auth_cache_valid_while_both_clocks_within_ttl() { - let mut cache = AuthCache::new(120); - let ctx = (1u64, 100u64); - let m0 = Instant::now(); - let w0 = SystemTime::now(); - cache.grant_at(ctx, "fp1", m0, w0); + // --- ui-status@vt tests (docs/app-bundle.md §5) --- - assert!(cache.is_authorized_at( - ctx, - "fp1", - m0 + Duration::from_secs(60), - w0 + Duration::from_secs(60), - )); + fn ui_status_req(token_b64: &str, action: &str) -> Extension { + Extension { + name: EXT_UI_STATUS.to_string(), + details: Unparsed::from( + serde_json::to_vec(&UiStatusReq { + token: token_b64.to_string(), + action: action.to_string(), + }) + .unwrap(), + ), + } + } + + #[tokio::test] + async fn ui_status_token_gate_locked_report_and_revoke() { + use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; + let token = [7u8; 32]; + let token_b64 = BASE64_URL_SAFE_NO_PAD.encode(token); + + // CLI-started agent (no token configured): a well-formed request is + // refused — indistinguishable from an unknown extension. + let mut session = test_session(300, 300); + assert!(session + .extension(ui_status_req(&token_b64, "status")) + .await + .is_err()); + + // Wrong token refused; correct token answers WITHOUT keychain access + // (this test has no keychain store — like the session-bind test, it + // passes precisely because the plaintext path never derives ciphers). + let mut session = test_session(300, 300); + session.ui_token = Some(token); + session.authorization = + AuthorizationEngine::new(Arc::new(TestAuthenticator), Arc::new(TestValidator)); + let wrong = BASE64_URL_SAFE_NO_PAD.encode([8u8; 32]); + assert!(session.extension(ui_status_req(&wrong, "status")).await.is_err()); + // Unknown action: token holder still cannot invoke anything but + // status/revoke_all. + assert!(session + .extension(ui_status_req(&token_b64, "approve_all")) + .await + .is_err()); + + let reply = session + .extension(ui_status_req(&token_b64, "status")) + .await + .unwrap() + .expect("status reply"); + assert_eq!(reply.name.as_str(), EXT_UI_STATUS); + let res: UiStatusRes = serde_json::from_slice(reply.details.as_ref()).unwrap(); + assert!(!res.locked); + assert_eq!(res.sign_ttl_secs, 300); + assert_eq!(res.idle_timeout_secs, 1800); // test_session default + assert!(res.grants.is_empty()); + assert!(res.revoked.is_none()); + + // A locked agent still reports status (the UI must be able to show + // the lock) — this rides the pre-lock-check dispatch position. + session.locked.store(true, Ordering::Release); + let reply = session + .extension(ui_status_req(&token_b64, "status")) + .await + .unwrap() + .expect("locked status reply"); + let res: UiStatusRes = serde_json::from_slice(reply.details.as_ref()).unwrap(); + assert!(res.locked); + session.locked.store(false, Ordering::Release); + + // Snapshot carries the scope display label and expiry; revoke_all + // drops the grant and advances the epoch (stale commits refused). + session + .authorization + .authorize(AuthorizationRequest::new( + vec![GrantScope::sign_destination(b"hostkey-wire", "SHA256:k") + .with_display("github.com (ED25519 SHA256:hk)")], + ReusePolicy::strict_ttl_secs(300), + "seed", + )) + .await + .unwrap() + .commit() + .await + .unwrap(); + let reply = session + .extension(ui_status_req(&token_b64, "status")) + .await + .unwrap() + .unwrap(); + let res: UiStatusRes = serde_json::from_slice(reply.details.as_ref()).unwrap(); + assert_eq!(res.grants.len(), 1); + let grant = &res.grants[0]; + assert_eq!(grant.operation, "sign"); + assert_eq!(grant.family, "destination"); + assert_eq!(grant.display, "github.com (ED25519 SHA256:hk)"); + assert_eq!(grant.ttl_secs, 300); + assert!(grant.remaining_secs > 290 && grant.remaining_secs <= 300); + + let reply = session + .extension(ui_status_req(&token_b64, "revoke_all")) + .await + .unwrap() + .unwrap(); + let res: UiStatusRes = serde_json::from_slice(reply.details.as_ref()).unwrap(); + assert_eq!(res.revoked, Some(1)); + assert!(res.grants.is_empty()); + } + + // --- Workspace resolution tests --- + + fn temp_workspace(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vt-authz-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir } #[test] - fn test_auth_cache_strict_ttl_not_extended_by_midway_regrant() { - // A re-grant at TTL/2 (e.g. racing prompt resolved late) must not - // move the original expiry. - let mut cache = AuthCache::new(120); - let ctx = (1u64, 100u64); - let m0 = Instant::now(); - let w0 = SystemTime::now(); - cache.grant_at(ctx, "fp1", m0, w0); - cache.grant_at( - ctx, - "fp1", - m0 + Duration::from_secs(60), - w0 + Duration::from_secs(60), - ); + fn find_git_root_prefers_nearest_marker_dir_or_file() { + let root = temp_workspace("git-root"); + std::fs::create_dir_all(root.join(".git")).unwrap(); // dir marker + let inner = root.join("a"); + let nested = inner.join("b"); + std::fs::create_dir_all(&nested).unwrap(); + assert_eq!(find_git_root(&nested), Some(root.clone())); + // A nearer `.git` FILE (worktree layout) wins over the outer dir. + std::fs::write(inner.join(".git"), "gitdir: elsewhere").unwrap(); + assert_eq!(find_git_root(&nested), Some(inner)); + let _ = std::fs::remove_dir_all(&root); + } - assert!( - !cache.is_authorized_at( - ctx, - "fp1", - m0 + Duration::from_secs(121), - w0 + Duration::from_secs(121), - ), - "midway re-grant must not extend the original expiry" + #[test] + fn workspace_identity_binds_dev_ino_and_canonical_path() { + use std::os::unix::fs::MetadataExt; + let root = temp_workspace("identity"); + let ws = workspace_identity(&root).expect("identity"); + let meta = std::fs::metadata(&root).unwrap(); + assert_eq!(ws.subject, (meta.dev(), meta.ino())); + // F_GETPATH returns the resolved path (e.g. /tmp → /private/tmp). + assert_eq!( + PathBuf::from(&ws.root), + std::fs::canonicalize(&root).unwrap() ); + assert!(ws.contains_claimed_pwd("")); + assert!(ws.contains_claimed_pwd(&format!("{}/sub", ws.root))); + assert!(!ws.contains_claimed_pwd("/somewhere/else")); + let _ = std::fs::remove_dir_all(&root); } #[test] - fn test_auth_cache_regrant_after_wall_expiry_refreshes() { - // Fresh approval after a sleep-induced expiry must produce a live - // entry (models the decrypt batch grant-all-keys path where an entry - // expired while the prompt sat on screen). - let mut cache = AuthCache::new(120); - let ctx = (1u64, 100u64); - let m0 = Instant::now(); - let w0 = SystemTime::now(); - cache.grant_at(ctx, "fp1", m0, w0); - - // Slept past the TTL, then the user re-approved. - let m1 = m0 + Duration::from_secs(1); - let w1 = w0 + Duration::from_secs(600); - assert!(!cache.is_authorized_at(ctx, "fp1", m1, w1)); - cache.grant_at(ctx, "fp1", m1, w1); - assert!(cache.is_authorized_at( - ctx, - "fp1", - m1 + Duration::from_secs(1), - w1 + Duration::from_secs(1), - )); + fn get_cwd_matches_own_process_cwd() { + let pid = std::process::id() as i32; + let kernel = proc_info::get_cwd(pid).expect("own cwd"); + let expected = std::fs::canonicalize(std::env::current_dir().unwrap()).unwrap(); + assert_eq!(kernel, expected); + } + + // --- known_hosts display resolution --- + + #[test] + fn known_hosts_lookup_matches_key_and_skips_hashed_and_marked() { + use base64::Engine as _; + use ssh_agent_lib::ssh_encoding::Encode; + let host = test_hostkey(); + let hostkey = host.public_key().key_data().clone(); + let mut wire = Vec::new(); + hostkey.encode(&mut wire).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(&wire); + let content = format!( + "# comment line\n\ + |1|hashhash|morehash ssh-ed25519 {b64}\n\ + @revoked revoked.example ssh-ed25519 {b64}\n\ + github.com,gh.alias ssh-ed25519 {b64}\n" + ); + assert_eq!( + known_hosts_name_in(&content, &hostkey).as_deref(), + Some("github.com") + ); + assert_eq!(known_hosts_name_in("", &hostkey), None); } // --- idle_exceeded tests --- @@ -3541,84 +4782,6 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(!watcher_should_clear(true, true, mono, None)); } - // --- decrypt_cache_key tests --- - - #[test] - fn test_decrypt_cache_key_deterministic() { - let salt = [0x42u8; SALT_LEN]; - let k1 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host1", "/p"); - let k2 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host1", "/p"); - assert_eq!(k1, k2); - assert_eq!(k1.len(), 64); // SHA-256 hex - } - - #[test] - fn test_decrypt_cache_key_changes_with_type() { - let salt = [0x42u8; SALT_LEN]; - let k_raw = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host1", "/p"); - let k_totp = decrypt_cache_key(crate::core::SecretType::TOTP, &salt, "host1", "/p"); - assert_ne!(k_raw, k_totp); - } - - #[test] - fn test_decrypt_cache_key_changes_with_salt() { - let s1 = [0x42u8; SALT_LEN]; - let s2 = [0x43u8; SALT_LEN]; - let k1 = decrypt_cache_key(crate::core::SecretType::RAW, &s1, "host1", "/p"); - let k2 = decrypt_cache_key(crate::core::SecretType::RAW, &s2, "host1", "/p"); - assert_ne!(k1, k2); - } - - #[test] - fn test_decrypt_cache_key_changes_with_host() { - let salt = [0x42u8; SALT_LEN]; - let k1 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host1", "/p"); - let k2 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host2", "/p"); - assert_ne!(k1, k2); - } - - #[test] - fn test_decrypt_cache_key_changes_with_pwd() { - // The pwd component is what scopes global-mode grants to one project - // tree; same record + host from a different directory must miss. - let salt = [0x42u8; SALT_LEN]; - let k1 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host1", "/proj/a"); - let k2 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "host1", "/proj/b"); - assert_ne!(k1, k2); - } - - #[test] - fn test_decrypt_cache_key_length_prefix_prevents_collision() { - // Length-prefix means "ab"+"c" must hash differently from "a"+"bc" - // even though concatenation matches — there's no way for the host - // string to absorb adjacent context bytes. - let salt = [0x42u8; SALT_LEN]; - let k1 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "ab", ""); - let k2 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "abc", ""); - assert_ne!(k1, k2); - // Field-shift between host and pwd must also be unambiguous. - let k3 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "ab", "c"); - let k4 = decrypt_cache_key(crate::core::SecretType::RAW, &salt, "a", "bc"); - assert_ne!(k3, k4); - } - - // --- sign_cache_key tests --- - - #[test] - fn test_sign_cache_key_partitions_by_pwd_and_fingerprint() { - let k = sign_cache_key("SHA256:abc", "/proj/a"); - assert_eq!(k, sign_cache_key("SHA256:abc", "/proj/a")); - assert_eq!(k.len(), 64); - assert_ne!(k, sign_cache_key("SHA256:abc", "/proj/b")); - assert_ne!(k, sign_cache_key("SHA256:xyz", "/proj/a")); - // Plain agent-protocol signs key on an empty pwd — distinct from any - // real directory, stable across calls. - assert_eq!(sign_cache_key("SHA256:abc", ""), sign_cache_key("SHA256:abc", "")); - assert_ne!(sign_cache_key("SHA256:abc", ""), k); - // Field-shift between fingerprint and pwd must be unambiguous. - assert_ne!(sign_cache_key("ab", "c"), sign_cache_key("a", "bc")); - } - // --- is_ssh_client_path tests --- #[test] @@ -3667,20 +4830,6 @@ d0EI4yKGPuCZ5YkAAAAWdnQtcnNhLXJlZ3Jlc3Npb24tdGVzdAECAwQF assert!(!path.is_empty(), "Path should not be empty"); } - #[test] - #[ignore] - fn test_get_sid_self_returns_session_leader() { - // The current test process inherits its session from `cargo test`, - // so getsid(self) should return a positive PID — typically the - // shell that ran cargo, or cargo's own PID if it called setsid. - let pid = std::process::id() as i32; - let sid = proc_info::get_sid(pid).expect("getsid should succeed"); - assert!(sid > 0, "Session leader PID should be positive"); - // The session leader's start time must be queryable. - let start = proc_info::get_start_tvsec(sid) - .expect("Session leader's start_tvsec should be queryable"); - assert!(start > 0, "Session leader start_tvsec should be positive"); - } // ── Touch-ID prompt helpers ──────────────────────────────────────────── diff --git a/src/server_macos/store.rs b/src/server_macos/store.rs index 9871f29..cf964f0 100644 --- a/src/server_macos/store.rs +++ b/src/server_macos/store.rs @@ -25,9 +25,25 @@ const STORE_NAME: &str = "store"; const LOCK_FILE_NAME: &str = "vt-keychain.lock"; pub const STORE_SCHEMA_VERSION: u32 = 1; +/// Wrap-derivation versions for `encrypted_passphrase` (docs/app-bundle.md §2). +/// v1 mixes the binary path into the wrap key; v2 uses a fixed label so the +/// binary can move (VT.app migration). `STORE_SCHEMA_VERSION` intentionally +/// stays 1: old binaries can still parse a v2 store (they fail the unwrap, +/// not the parse), preserving the export/import escape hatch. +pub const WRAP_V1: u32 = 1; +pub const WRAP_V2: u32 = 2; + +fn default_wrap_v() -> u32 { + WRAP_V1 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeychainStore { pub v: u32, + /// Which derivation wraps `encrypted_passphrase`. Serde default 1 so + /// stores written before this field existed read as wrap v1. + #[serde(default = "default_wrap_v")] + pub wrap_v: u32, /// base64 of 64 bytes: passcode (32B) + auth_token (32B). pub passcode_and_auth_token: String, /// base64 of AES-GCM ciphertext (nonce || ct) wrapping the 32-byte master passphrase. @@ -43,9 +59,12 @@ pub struct KeychainStore { } impl KeychainStore { + /// New stores are always wrap v2 (fixed-label derivation); only + /// `vt secret rebind --to-v1` produces a v1 wrap after this version. pub fn new(passcode_and_auth_token: &[u8], encrypted_passphrase: &[u8]) -> Self { Self { v: STORE_SCHEMA_VERSION, + wrap_v: WRAP_V2, passcode_and_auth_token: BASE64_URL_SAFE_NO_PAD.encode(passcode_and_auth_token), encrypted_passphrase: BASE64_URL_SAFE_NO_PAD.encode(encrypted_passphrase), encrypted_ssh_keys: None, @@ -53,6 +72,11 @@ impl KeychainStore { } } + pub fn set_encrypted_passphrase(&mut self, bytes: &[u8], wrap_v: u32) { + self.encrypted_passphrase = BASE64_URL_SAFE_NO_PAD.encode(bytes); + self.wrap_v = wrap_v; + } + /// Read the store from the keychain. Returns an error if the item does /// not exist or cannot be parsed — callers should treat "not initialized" /// distinctly from "parse failure" by inspecting the error message if diff --git a/src/ssh_sign.rs b/src/ssh_sign.rs index 40d885b..1cbcb8d 100644 --- a/src/ssh_sign.rs +++ b/src/ssh_sign.rs @@ -822,21 +822,22 @@ impl ssh_agent_lib::agent::Session for SignerSession { // ── vt-relay peer detection (cross-platform pure logic) ────────────────────── // -// The macOS `vt ssh agent` narrows the auth cache to a per-connection -// `(pid, start_time)` context when the connecting peer is a forwarding agent. -// A plain forwarded `ssh` is recognised by executable basename -// (`is_ssh_client_path`). With `vt ssh connect --forward-real-agent`, the peer -// reaching the real agent is instead the `vt` relay process, so the agent must -// recognise IT too and give it the identical per-connection scoping — otherwise -// relayed `decrypt@vt` would fall into the coarse ordinary local-caller context -// and the "context dies with the ssh connection" guarantee would be lost. +// The macOS `vt ssh agent` confines grants for connections that can carry +// forwarded remote traffic to a per-connection `(pid, start_time)` subject +// (activity scopes V2 — docs/authorization-scopes-v2.md). A plain forwarded +// `ssh` is recognised by executable basename (`is_ssh_client_path`). With +// `vt ssh connect --forward-real-agent`, the peer reaching the real agent is +// instead the `vt` relay process, so the agent must recognise IT too and give +// it the identical per-connection confinement — otherwise relayed +// `decrypt@vt` would fall into the local-caller workspace scope and the +// "grants die with the connection" guarantee would be lost. // // Detection is by the peer's argv (kernel-derived on macOS via KERN_PROCARGS2 — // same trust level as the `proc_pidpath` basename check). These functions are // PURE and intentionally UNGATED so they compile and are unit-tested on the // Linux/host target too (the macOS agent module that consumes them is // `#[cfg(target_os = "macos")]`-gated out of the Linux build). Only the sysctl -// fetch + the wiring into `resolve_cache_context` are macOS-only. +// fetch + the classification wiring in `new_session` are macOS-only. /// Basename of an argv[0] token (`/usr/local/bin/vt` → `vt`, `vt` → `vt`). pub(crate) fn program_basename(arg0: &str) -> &str {