diff --git a/docs/decisions/052-breakglass-ssh-and-key-vault.md b/docs/decisions/052-breakglass-ssh-and-key-vault.md index 9bf348a6b..58f914881 100644 --- a/docs/decisions/052-breakglass-ssh-and-key-vault.md +++ b/docs/decisions/052-breakglass-ssh-and-key-vault.md @@ -1,12 +1,15 @@ # 052. Breakglass terminal, two SSH paths, and a zero-knowledge key vault > **Type:** decision -> **Status:** Proposed (2026-07-05) — resolves the key-posture fork in +> **Status:** Accepted (2026-07-05) — resolves the key-posture fork in > [`discussions/desktop-breakglass-ssh.md`](../discussions/desktop-breakglass-ssh.md) > §7: the director chose **B2 (zero-knowledge encrypted vault)**. Records the > terminal backend, the two terminal paths, the cross-device sync model, and — the -> load-bearing part — an **amendment to forbidden-pattern #15** so a hub that holds -> only blind ciphertext is permitted. +> load-bearing part — an **amendment to forbidden-pattern #15** (now in effect, +> [`spine/forbidden-patterns.md`](../spine/forbidden-patterns.md) updated) so a hub +> that holds only blind ciphertext is permitted. **D-6 (hub PTY-relay + A2A-relay +> auth) is DEFERRED** by director direction (2026-07-05); the first build is the +> **vault key-sync service** (D-4) — the hub as a blind cross-device blob store. > **Audience:** principal · contributors · maintainers > **Last verified vs code:** v1.0.820 @@ -14,11 +17,13 @@ transport** (not `libghostty` — wrong layer), mirroring mobile. There are **two paths**: to a **host-runner-managed host** the terminal is a **hub-brokered interactive PTY — no SSH, no keys, fully audited** (the safe primary path); to a -**personal bare-SSH host** it is direct `russh` with client-held keys. Host and -connection **metadata** (non-secret) **syncs** through the hub across the director's -devices (extending `ssh_hint`). Private keys sync via a **zero-knowledge vault -(B2)**: the hub stores key material **encrypted client-side under a key it never -sees**, wrapped per enrolled device — a blind ciphertext blob store. This requires +**personal bare-SSH host** it is direct `russh` with client-held keys. The +**whole personal setup — connections (host/user/jump/tmux) *and* keys/passphrases/ +passwords — syncs across devices as one bundle** via a **zero-knowledge vault +(B2)**: the hub stores it **encrypted client-side under a key it never sees**, +wrapped per enrolled device, plus a **recovery envelope** (the vault key wrapped +under a director-escrowed recovery code) so losing every device is survivable — a +blind ciphertext blob store throughout. This requires **amending forbidden-pattern #15**: the rule forbids the hub holding *usable* SSH secrets; a vault the hub cannot decrypt or use preserves the rule's intent (the hub can never authenticate as the user) and is permitted. Two security fixes travel with @@ -66,18 +71,22 @@ reconciles that by amending the rule to match its intent. - **Personal bare-SSH host (no host-runner) → direct `russh`.** Client-held keys, mirroring mobile. The hub's role is limited to the **non-secret** `ssh_hint`. -- **D-3 — Non-secret metadata syncs; plaintext keys never do.** Host/connection - bookmarks (hostname, port, user, key *fingerprint*, jump host, tmux path) are - non-secret and sync through the hub as **personal-scoped** metadata across the - director's devices, extending the existing `ssh_hint` concept to personal - connections. No private key, passphrase, or password ever transits or persists on - the hub in a form the hub can read. (This also fixes the current smell of - serializing `proxyPassword` into unencrypted prefs, `connection_provider.dart:138`.) - -- **D-4 — Private keys share via a zero-knowledge vault (B2).** The hub stores key - material (private keys, passphrases, connection passwords) **encrypted client-side - under a vault key the hub never sees**, and serves it as an opaque, versioned blob - — a **blind blob store**. The crypto shape: +- **D-3 — The vault carries the *whole* connection bundle, hub-blind (revised + 2026-07-05).** So a new device is directly reusable, the vault seals the complete + personal setup — **connection bookmarks (host, port, username, auth method, jump + host, tmux path) *and* the keys/passphrases/passwords** — as one client-encrypted + bundle. Personal connection metadata is therefore carried **inside** the + zero-knowledge vault (the hub never sees it, not even hostnames), **not** as + hub-visible personal `ssh_hint`. The team-host `ssh_hint` primitive keeps its + original, separate purpose (binding/displaying a *team* host row, non-secret, + hub-visible). This supersedes the earlier "non-secret metadata syncs via + `ssh_hint`" framing and also retires the smell of serializing `proxyPassword` into + unencrypted prefs (`connection_provider.dart:138`). + +- **D-4 — The connection+key bundle shares via a zero-knowledge vault (B2).** The + hub stores the sealed bundle (D-3: connections + private keys + passphrases + + passwords) **encrypted client-side under a vault key the hub never sees**, and + serves it as an opaque, versioned blob — a **blind blob store**. The crypto shape: - Each secret is sealed with **authenticated encryption** (an AEAD such as XChaCha20-Poly1305 / AES-256-GCM) under a symmetric **vault key**. - The vault key is **wrapped per enrolled device** (envelope encryption to each @@ -96,6 +105,17 @@ reconciles that by amending the rule to match its intent. sealing/opening happens on clients (Rust RustCrypto / Dart `cryptography`). - The vault **replaces** the cleartext `DataPortService` export as the cross-device mechanism. + - **Recovery escrow (decided 2026-07-05).** The vault key is *also* wrapped under + a director-held **recovery key** (a high-entropy recovery code, or a passphrase + via Argon2id), stored on the hub as an opaque **recovery envelope** — so a + principal who has lost **every** enrolled device can still recover. The director + **escrows the recovery code offline** (self-escrow, à la a BIP-39 phrase); the + hub holds only the wrapped envelope, never the recovery key, so zero-knowledge + holds. On a re-key (device revocation) the client re-wraps the recovery envelope + too. Operator/org escrow and Shamir-split recovery are possible extensions on the + same hub slot (one opaque envelope today; a keyed table later). Without a + recovery envelope set, losing all devices means losing the vault — the client + prompts the director to create one at first setup. - **D-5 — Amend forbidden-pattern #15 (the reconciliation).** The rule's intent is that **the hub must never be able to authenticate as the user** — never hold a @@ -106,13 +126,15 @@ reconciles that by amending the rule to match its intent. passwords may be stored **only** as client-side-encrypted, zero-knowledge vault ciphertext the hub cannot decrypt (the hub never holds the vault key or any plaintext); everything else stays in device secure storage."* The vault is - key-material-scale, not bulk bytes, so forbidden-pattern #1 is not engaged. On - **acceptance** of this ADR, `docs/spine/forbidden-patterns.md` #15 (and the - blueprint §7 reference) is updated to this text; until then #15 stands and no - key-storing code lands. - -- **D-6 — Hub workstream: authenticate the relay + a streaming PTY channel.** Path 1 - (D-2) needs a hub PTY-relay that does not exist today. Build: (a) a host-runner + key-material-scale, not bulk bytes, so forbidden-pattern #1 is not engaged. + **Applied 2026-07-05 on acceptance:** `docs/spine/forbidden-patterns.md` #15 now + carries this text; the zero-knowledge vault store (D-4) is thereby permitted. + +- **D-6 — Hub workstream: authenticate the relay + a streaming PTY channel.** + **DEFERRED (2026-07-05, director direction)** — tracked, not scheduled; the + managed-host PTY path (D-2 Path 1) waits on it, and personal direct-SSH (Path 2) + plus the vault sync (D-4) ship first. Path 1 + needs a hub PTY-relay that does not exist today. Build: (a) a host-runner "open interactive shell PTY" capability, and (b) an **authenticated, streaming** channel — either upgrade the A2A tunnel's `Kind` to carry a bidirectional PTY stream, or add a dedicated authed `…/hosts/{host}/shell` (WebSocket) serviced over @@ -130,10 +152,12 @@ reconciles that by amending the rule to match its intent. - The A2A relay gets authenticated, closing a pre-existing gap. **Harder / cost:** -- Real client-side crypto and a **device-enrollment / re-key** flow (trust a new - device, revoke one) on **both** clients — the highest-risk new surface; get the - key-wrapping and recovery story right (lose all devices ⇒ lost vault, unless a - recovery secret is escrowed by the director). +- Real client-side crypto and a **device-enrollment / re-key / recovery** flow + (trust a new device, revoke one, re-wrap the recovery envelope on re-key) on + **both** clients — the highest-risk new surface. Recovery is now designed (D-4 + escrow), so "lose all devices ⇒ lost vault" is survivable *when the director set a + recovery code*; the client must make first-setup recovery prominent so it isn't + skipped. - A new **hub PTY-relay + streaming-auth** workstream (D-6), plus mobile joining the vault — this spans hub, desktop, and mobile. - Amending a foundational axiom (D-5); done deliberately and narrowly. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 7baa9a832..5dcfead07 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -70,7 +70,7 @@ next sequential number — don't reserve, don't skip. | [049](049-multi-agent-collaboration-via-github.md) | Multi-agent collaboration via GitHub — delegate any dev work to heterogeneous agent CLIs on different hosts; maintainer/builder roles, ticket lifecycle + tiers, two-axis identity (git-config handle vs shared account), `holds:` baton, verify-before-merge; vendor-agnostic, general (i18n was the pilot) | Accepted 2026-06-11 | — | | [050](050-desktop-workbench-delivery-model.md) | Desktop research workbench delivery model — a local-first, hub-served **web-tech** app (second client on the client-agnostic API), not a wide Flutter layout; two-halves split (portable control plane vs component-heavy research workbench); operative rule build·embed·integrate·interop; BUILD the fleet-native surfaces (headline = multi-run comparison wall), EMBED/INTEGRATE the rest | Accepted 2026-07-04 · Amended 2026-07-05 | — | | [051](051-desktop-client-stack.md) | Desktop client stack — **Tauri v2** shell (Win/Mac/Linux + plain browser) hosting **React + TypeScript** (chosen by the embed axis: the workbench's tldraw/BlockNote/Monaco/Rerun/Viser components are React-first); TanStack Query over REST+cache; the Rust core proxies SSE (auth header) + keychains the token; a shared **DTCG `tokens.json` → Style Dictionary → {Dart, CSS}** pipeline makes ADR-047 tokens load-bearing across both clients | Proposed 2026-07-05 | (implements [050](050-desktop-workbench-delivery-model.md)) | -| [052](052-breakglass-ssh-and-key-vault.md) | Breakglass SSH terminal — **xterm.js + Tauri `russh`** (not libghostty); **two paths** (managed host = hub-brokered keyless audited PTY; personal = direct SSH + client keys); non-secret host/connection metadata syncs; private keys share via a **zero-knowledge vault** (hub stores blind ciphertext it can't decrypt) → **amends forbidden-pattern #15**; authenticate the A2A relay + retire the cleartext backup | Proposed 2026-07-05 | (amends [forbidden-patterns.md](../spine/forbidden-patterns.md) #15; builds on [051](051-desktop-client-stack.md)) | +| [052](052-breakglass-ssh-and-key-vault.md) | Breakglass SSH terminal — **xterm.js + Tauri `russh`** (not libghostty); **two paths** (managed host = hub-brokered keyless audited PTY; personal = direct SSH + client keys); non-secret host/connection metadata syncs; private keys share via a **zero-knowledge vault** (hub stores blind ciphertext it can't decrypt) → **amends forbidden-pattern #15**; authenticate the A2A relay + retire the cleartext backup | Accepted 2026-07-05 | (amends [forbidden-patterns.md](../spine/forbidden-patterns.md) #15; builds on [051](051-desktop-client-stack.md)) | --- diff --git a/docs/spine/forbidden-patterns.md b/docs/spine/forbidden-patterns.md index 61bf37bfa..a8e0156db 100644 --- a/docs/spine/forbidden-patterns.md +++ b/docs/spine/forbidden-patterns.md @@ -89,9 +89,18 @@ see [`information-architecture.md §8`](information-architecture.md). binary presence and version only. Mixing the two reintroduces provider-specific logic into the deputy layer. -15. **Hub storing SSH credentials to help with Enter-pane.** Only - non-secret `ssh_hint_json` (hostname, port, username) may live - in the hub. Secrets stay in the phone's secure storage. +15. **Hub storing SSH secrets it can read or use.** Non-secret + `ssh_hint_json` (hostname, port, username) may live in the hub. + Private keys, passphrases, and passwords may be stored **only** + as client-side-encrypted, **zero-knowledge vault** ciphertext the + hub cannot decrypt — the hub never holds the vault key or any + plaintext (it is a blind blob store); everything else stays in + device secure storage. *(Amended 2026-07-05 by + [ADR-052](../decisions/052-breakglass-ssh-and-key-vault.md): the + rule's intent is that the hub can never authenticate as the user, + which opaque ciphertext preserves. Was: "Only non-secret + `ssh_hint_json` … may live in the hub. Secrets stay in the phone's + secure storage.")* --- diff --git a/hub/internal/server/handlers_vault.go b/hub/internal/server/handlers_vault.go new file mode 100644 index 000000000..6a167d807 --- /dev/null +++ b/hub/internal/server/handlers_vault.go @@ -0,0 +1,397 @@ +package server + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/termipod/hub/internal/auth" +) + +// Zero-knowledge SSH key-vault sync (ADR-052 D-4). The hub is a blind blob +// store: it holds only client-side-encrypted ciphertext it can never decrypt, +// keyed to the calling principal, and syncs it across that principal's devices. +// This is the carve-out that amends forbidden-pattern #15 — the hub never holds +// the vault key or any plaintext. +// +// Ownership is server-derived from the token scope (never client-supplied), so a +// caller can only ever reach their own vault. principalFromScope is never empty +// (it falls back to "@principal"), so a token without a handle still gets a +// stable owner key. Because the payload is opaque ciphertext, this access +// scoping is defense-in-depth: the real protection is that only enrolled devices +// hold the key to decrypt it. + +// maxVaultBytes caps a pushed vault/device body. SSH key material is small +// (a few KB per key); 2 MiB is far more than a realistic vault. +const maxVaultBytes = 2 << 20 + +type vaultPushIn struct { + Ciphertext string `json:"ciphertext"` + BaseVersion int `json:"base_version"` +} + +type vaultOut struct { + Ciphertext string `json:"ciphertext"` + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` +} + +type vaultDeviceIn struct { + DeviceName string `json:"device_name,omitempty"` + PublicKey string `json:"public_key,omitempty"` + WrappedKey string `json:"wrapped_key,omitempty"` +} + +type vaultDeviceOut struct { + DeviceID string `json:"device_id"` + DeviceName *string `json:"device_name,omitempty"` + PublicKey string `json:"public_key"` + WrappedKey *string `json:"wrapped_key,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// vaultOwner returns the server-derived principal key for the calling token, or +// false (having written a 401) if the request is unauthenticated. +func (s *Server) vaultOwner(w http.ResponseWriter, r *http.Request) (string, bool) { + tok, ok := auth.FromContext(r.Context()) + if !ok || tok == nil { + writeErr(w, http.StatusUnauthorized, "unauthenticated") + return "", false + } + return principalFromScope(tok.ScopeJSON), true +} + +// GET /v1/teams/{team}/vault — pull the sealed vault blob. 404 when the +// principal has never pushed one (so a fresh client knows to create it). +func (s *Server) handlePullVault(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + var out vaultOut + err := s.db.QueryRowContext(r.Context(), + `SELECT ciphertext, version, updated_at + FROM key_vaults WHERE team_id = ? AND handle = ?`, + team, owner).Scan(&out.Ciphertext, &out.Version, &out.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + writeErr(w, http.StatusNotFound, "no vault for this principal") + return + } + if err != nil { + s.writeDBErr(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// PUT /v1/teams/{team}/vault — push the sealed vault blob with optimistic +// concurrency. base_version=0 creates it; a non-zero base_version updates only +// if it matches the stored version, else 409 (the client must pull and retry). +func (s *Server) handlePushVault(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxVaultBytes) + var in vaultPushIn + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + writeErr(w, http.StatusRequestEntityTooLarge, "vault too large") + return + } + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + if in.Ciphertext == "" { + writeErr(w, http.StatusBadRequest, "ciphertext required") + return + } + now := NowUTC() + + if in.BaseVersion == 0 { + // First write: insert version 1. A PK collision means a vault already + // exists, i.e. base_version is stale — writeDBErr maps the constraint + // to 409, telling the client to pull and retry. + _, err := s.writeDB.ExecContext(r.Context(), + `INSERT INTO key_vaults (team_id, handle, ciphertext, version, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?)`, + team, owner, in.Ciphertext, now, now) + if err != nil { + s.writeDBErr(w, err) + return + } + s.recordAudit(r.Context(), team, "vault.push", "vault", owner, + "create key vault", map[string]any{"version": 1}) + writeJSON(w, http.StatusOK, map[string]any{"version": 1, "updated_at": now}) + return + } + + res, err := s.writeDB.ExecContext(r.Context(), + `UPDATE key_vaults SET ciphertext = ?, version = version + 1, updated_at = ? + WHERE team_id = ? AND handle = ? AND version = ?`, + in.Ciphertext, now, team, owner, in.BaseVersion) + if err != nil { + s.writeDBErr(w, err) + return + } + n, _ := res.RowsAffected() + if n == 0 { + writeErrHint(w, http.StatusConflict, "vault version conflict", + Hint{HintText: "another device updated the vault; GET /vault and re-push with the current version"}) + return + } + newVersion := in.BaseVersion + 1 + s.recordAudit(r.Context(), team, "vault.push", "vault", owner, + "update key vault", map[string]any{"version": newVersion}) + writeJSON(w, http.StatusOK, map[string]any{"version": newVersion, "updated_at": now}) +} + +type vaultRecoveryIn struct { + RecoveryEnvelope string `json:"recovery_envelope"` + RecoveryHint string `json:"recovery_hint,omitempty"` +} + +type vaultRecoveryOut struct { + RecoveryEnvelope string `json:"recovery_envelope"` + RecoveryHint *string `json:"recovery_hint,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` +} + +// PUT /v1/teams/{team}/vault/recovery — set/replace the recovery envelope (the +// vault key wrapped under the director's escrowed recovery key, opaque to the +// hub). The vault must already exist. On a re-key the client re-wraps this too. +func (s *Server) handleSetVaultRecovery(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxVaultBytes) + var in vaultRecoveryIn + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + writeErr(w, http.StatusRequestEntityTooLarge, "recovery payload too large") + return + } + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + if in.RecoveryEnvelope == "" { + writeErr(w, http.StatusBadRequest, "recovery_envelope required") + return + } + now := NowUTC() + res, err := s.writeDB.ExecContext(r.Context(), + `UPDATE key_vaults SET recovery_envelope = ?, recovery_hint = ?, recovery_updated_at = ? + WHERE team_id = ? AND handle = ?`, + in.RecoveryEnvelope, nullIfEmpty(in.RecoveryHint), now, team, owner) + if err != nil { + s.writeDBErr(w, err) + return + } + n, _ := res.RowsAffected() + if n == 0 { + writeErr(w, http.StatusNotFound, "create the vault before setting recovery") + return + } + s.recordAudit(r.Context(), team, "vault.recovery.set", "vault", owner, + "set vault recovery envelope", nil) + writeJSON(w, http.StatusOK, map[string]any{"updated_at": now}) +} + +// GET /v1/teams/{team}/vault/recovery — fetch the recovery envelope for a +// principal recovering on a fresh device. 404 when no vault or none set. +func (s *Server) handleGetVaultRecovery(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + var env, hint, upd sql.NullString + err := s.db.QueryRowContext(r.Context(), + `SELECT recovery_envelope, recovery_hint, recovery_updated_at + FROM key_vaults WHERE team_id = ? AND handle = ?`, + team, owner).Scan(&env, &hint, &upd) + if errors.Is(err, sql.ErrNoRows) { + writeErr(w, http.StatusNotFound, "no vault for this principal") + return + } + if err != nil { + s.writeDBErr(w, err) + return + } + if !env.Valid { + writeErr(w, http.StatusNotFound, "no recovery envelope set") + return + } + out := vaultRecoveryOut{RecoveryEnvelope: env.String} + if hint.Valid { + out.RecoveryHint = &hint.String + } + if upd.Valid { + out.UpdatedAt = &upd.String + } + writeJSON(w, http.StatusOK, out) +} + +// DELETE /v1/teams/{team}/vault/recovery — clear the recovery envelope. +func (s *Server) handleDeleteVaultRecovery(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + res, err := s.writeDB.ExecContext(r.Context(), + `UPDATE key_vaults SET recovery_envelope = NULL, recovery_hint = NULL, recovery_updated_at = NULL + WHERE team_id = ? AND handle = ?`, + team, owner) + if err != nil { + s.writeDBErr(w, err) + return + } + n, _ := res.RowsAffected() + if n == 0 { + writeErr(w, http.StatusNotFound, "no vault for this principal") + return + } + s.recordAudit(r.Context(), team, "vault.recovery.clear", "vault", owner, + "clear vault recovery envelope", nil) + writeJSON(w, http.StatusOK, map[string]any{"cleared": true}) +} + +// GET /v1/teams/{team}/vault/devices — list the principal's enrolled devices +// (their public keys and per-device wrapped keys, all opaque to the hub). A +// new device polls this to discover when an enrolled device has wrapped the +// vault key to it. +func (s *Server) handleListVaultDevices(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + rows, err := s.db.QueryContext(r.Context(), + `SELECT device_id, device_name, public_key, wrapped_key, created_at, updated_at + FROM key_vault_devices WHERE team_id = ? AND handle = ? + ORDER BY created_at`, + team, owner) + if err != nil { + s.writeDBErr(w, err) + return + } + defer rows.Close() + devices := []vaultDeviceOut{} + for rows.Next() { + var d vaultDeviceOut + if err := rows.Scan(&d.DeviceID, &d.DeviceName, &d.PublicKey, + &d.WrappedKey, &d.CreatedAt, &d.UpdatedAt); err != nil { + s.writeDBErr(w, err) + return + } + devices = append(devices, d) + } + if err := rows.Err(); err != nil { + s.writeDBErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"devices": devices}) +} + +// PUT /v1/teams/{team}/vault/devices/{device} — enroll or update a device. A +// new device registers its public_key (wrapped_key empty); an already-enrolled +// device later PUTs the same device_id with wrapped_key set to distribute the +// vault key to it. Fields left empty on an update are preserved. +func (s *Server) handlePutVaultDevice(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + device := chi.URLParam(r, "device") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + if device == "" { + writeErr(w, http.StatusBadRequest, "device id required") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxVaultBytes) + var in vaultDeviceIn + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + writeErr(w, http.StatusRequestEntityTooLarge, "device payload too large") + return + } + writeErr(w, http.StatusBadRequest, "invalid json") + return + } + + // A public key is mandatory to enroll a brand-new device; on an update + // (setting wrapped_key) it may be omitted and the stored key is kept. + if in.PublicKey == "" { + var one int + err := s.db.QueryRowContext(r.Context(), + `SELECT 1 FROM key_vault_devices + WHERE team_id = ? AND handle = ? AND device_id = ?`, + team, owner, device).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + writeErr(w, http.StatusBadRequest, "public_key required to enroll a new device") + return + } + if err != nil { + s.writeDBErr(w, err) + return + } + } + + now := NowUTC() + _, err := s.writeDB.ExecContext(r.Context(), + `INSERT INTO key_vault_devices + (id, team_id, handle, device_id, device_name, public_key, wrapped_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(team_id, handle, device_id) DO UPDATE SET + device_name = COALESCE(excluded.device_name, key_vault_devices.device_name), + public_key = CASE WHEN excluded.public_key <> '' THEN excluded.public_key ELSE key_vault_devices.public_key END, + wrapped_key = COALESCE(excluded.wrapped_key, key_vault_devices.wrapped_key), + updated_at = excluded.updated_at`, + NewID(), team, owner, device, nullIfEmpty(in.DeviceName), + in.PublicKey, nullIfEmpty(in.WrappedKey), now, now) + if err != nil { + s.writeDBErr(w, err) + return + } + s.recordAudit(r.Context(), team, "vault.device.enroll", "vault_device", device, + "enroll vault device "+device, map[string]any{"device_id": device}) + writeJSON(w, http.StatusOK, map[string]any{"device_id": device, "updated_at": now}) +} + +// DELETE /v1/teams/{team}/vault/devices/{device} — revoke a device (remove its +// envelope). The client is expected to re-key the vault afterward (ADR-052). +func (s *Server) handleDeleteVaultDevice(w http.ResponseWriter, r *http.Request) { + team := chi.URLParam(r, "team") + device := chi.URLParam(r, "device") + owner, ok := s.vaultOwner(w, r) + if !ok { + return + } + res, err := s.writeDB.ExecContext(r.Context(), + `DELETE FROM key_vault_devices + WHERE team_id = ? AND handle = ? AND device_id = ?`, + team, owner, device) + if err != nil { + s.writeDBErr(w, err) + return + } + n, _ := res.RowsAffected() + if n == 0 { + writeErr(w, http.StatusNotFound, "device not found") + return + } + s.recordAudit(r.Context(), team, "vault.device.revoke", "vault_device", device, + "revoke vault device "+device, map[string]any{"device_id": device}) + writeJSON(w, http.StatusOK, map[string]any{"deleted": device}) +} diff --git a/hub/internal/server/handlers_vault_test.go b/hub/internal/server/handlers_vault_test.go new file mode 100644 index 000000000..663f89340 --- /dev/null +++ b/hub/internal/server/handlers_vault_test.go @@ -0,0 +1,217 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" +) + +func vaultPath(suffix string) string { + return "/v1/teams/" + defaultTeamID + "/vault" + suffix +} + +func TestVault_PushPullRoundTrip(t *testing.T) { + s, token := newA2ATestServer(t) + + // Fresh principal has no vault yet. + status, _ := doReq(t, s, token, http.MethodGet, vaultPath(""), nil) + if status != http.StatusNotFound { + t.Fatalf("pull before push: want 404, got %d", status) + } + + // First push (base_version 0) creates version 1. + status, body := doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "sealed-v1", "base_version": 0}) + if status != http.StatusOK { + t.Fatalf("first push: want 200, got %d (%s)", status, body) + } + var pushed struct { + Version int `json:"version"` + } + if err := json.Unmarshal(body, &pushed); err != nil { + t.Fatalf("decode push: %v", err) + } + if pushed.Version != 1 { + t.Fatalf("first push version: want 1, got %d", pushed.Version) + } + + // Pull returns the sealed ciphertext verbatim. + status, body = doReq(t, s, token, http.MethodGet, vaultPath(""), nil) + if status != http.StatusOK { + t.Fatalf("pull: want 200, got %d", status) + } + var out vaultOut + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decode pull: %v", err) + } + if out.Ciphertext != "sealed-v1" || out.Version != 1 { + t.Fatalf("pull mismatch: %+v", out) + } +} + +func TestVault_OptimisticConcurrency(t *testing.T) { + s, token := newA2ATestServer(t) + + doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "v1", "base_version": 0}) + + // Correct base_version updates to v2. + status, body := doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "v2", "base_version": 1}) + if status != http.StatusOK { + t.Fatalf("update with correct base: want 200, got %d (%s)", status, body) + } + + // A second push at the now-stale base_version 1 conflicts. + status, _ = doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "v2-stale", "base_version": 1}) + if status != http.StatusConflict { + t.Fatalf("stale push: want 409, got %d", status) + } + + // Re-pushing at base_version 0 over an existing vault also conflicts. + status, _ = doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "v-dupe", "base_version": 0}) + if status != http.StatusConflict { + t.Fatalf("duplicate create: want 409, got %d", status) + } + + // The winning write survived. + _, body = doReq(t, s, token, http.MethodGet, vaultPath(""), nil) + var out vaultOut + _ = json.Unmarshal(body, &out) + if out.Ciphertext != "v2" || out.Version != 2 { + t.Fatalf("after conflicts: want v2/2, got %+v", out) + } +} + +func TestVault_MissingCiphertext(t *testing.T) { + s, token := newA2ATestServer(t) + status, _ := doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"base_version": 0}) + if status != http.StatusBadRequest { + t.Fatalf("empty ciphertext: want 400, got %d", status) + } +} + +func TestVault_RecoveryEscrow(t *testing.T) { + s, token := newA2ATestServer(t) + + // Recovery can't be set before the vault exists. + status, _ := doReq(t, s, token, http.MethodPut, vaultPath("/recovery"), + map[string]any{"recovery_envelope": "wrapped-under-recovery-key"}) + if status != http.StatusNotFound { + t.Fatalf("set recovery before vault: want 404, got %d", status) + } + + // Create the vault, then no recovery envelope is set yet. + doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "sealed", "base_version": 0}) + status, _ = doReq(t, s, token, http.MethodGet, vaultPath("/recovery"), nil) + if status != http.StatusNotFound { + t.Fatalf("get recovery before set: want 404, got %d", status) + } + + // A missing envelope is rejected. + status, _ = doReq(t, s, token, http.MethodPut, vaultPath("/recovery"), + map[string]any{"recovery_hint": "no envelope"}) + if status != http.StatusBadRequest { + t.Fatalf("set recovery without envelope: want 400, got %d", status) + } + + // Set, then read it back verbatim. + status, _ = doReq(t, s, token, http.MethodPut, vaultPath("/recovery"), + map[string]any{"recovery_envelope": "wrapped-under-recovery-key", "recovery_hint": "code 2026-07-05"}) + if status != http.StatusOK { + t.Fatalf("set recovery: want 200, got %d", status) + } + status, body := doReq(t, s, token, http.MethodGet, vaultPath("/recovery"), nil) + if status != http.StatusOK { + t.Fatalf("get recovery: want 200, got %d", status) + } + var rec vaultRecoveryOut + if err := json.Unmarshal(body, &rec); err != nil { + t.Fatalf("decode recovery: %v", err) + } + if rec.RecoveryEnvelope != "wrapped-under-recovery-key" { + t.Fatalf("recovery envelope mismatch: %+v", rec) + } + if rec.RecoveryHint == nil || *rec.RecoveryHint != "code 2026-07-05" { + t.Fatalf("recovery hint mismatch: %+v", rec) + } + + // Pushing a new vault version leaves the recovery envelope intact. + doReq(t, s, token, http.MethodPut, vaultPath(""), + map[string]any{"ciphertext": "sealed-2", "base_version": 1}) + status, _ = doReq(t, s, token, http.MethodGet, vaultPath("/recovery"), nil) + if status != http.StatusOK { + t.Fatalf("recovery after vault update: want 200, got %d", status) + } + + // Clear it; then it 404s again. + status, _ = doReq(t, s, token, http.MethodDelete, vaultPath("/recovery"), nil) + if status != http.StatusOK { + t.Fatalf("clear recovery: want 200, got %d", status) + } + status, _ = doReq(t, s, token, http.MethodGet, vaultPath("/recovery"), nil) + if status != http.StatusNotFound { + t.Fatalf("get recovery after clear: want 404, got %d", status) + } +} + +func TestVault_DeviceEnrollWrapListRevoke(t *testing.T) { + s, token := newA2ATestServer(t) + + // A new device enrolls with its public key, no wrapped key yet. + status, _ := doReq(t, s, token, http.MethodPut, vaultPath("/devices/desktop-1"), + map[string]any{"device_name": "desktop", "public_key": "pk-desktop"}) + if status != http.StatusOK { + t.Fatalf("enroll device: want 200, got %d", status) + } + + // Enrolling a device with no public key and no prior row is rejected. + status, _ = doReq(t, s, token, http.MethodPut, vaultPath("/devices/phantom"), + map[string]any{"wrapped_key": "wk"}) + if status != http.StatusBadRequest { + t.Fatalf("enroll without pubkey: want 400, got %d", status) + } + + // An enrolled device wraps the vault key to desktop-1 (pubkey omitted, kept). + status, _ = doReq(t, s, token, http.MethodPut, vaultPath("/devices/desktop-1"), + map[string]any{"wrapped_key": "wrapped-for-desktop"}) + if status != http.StatusOK { + t.Fatalf("wrap key: want 200, got %d", status) + } + + // List shows one device with its wrapped key and preserved public key. + status, body := doReq(t, s, token, http.MethodGet, vaultPath("/devices"), nil) + if status != http.StatusOK { + t.Fatalf("list devices: want 200, got %d", status) + } + var listed struct { + Devices []vaultDeviceOut `json:"devices"` + } + if err := json.Unmarshal(body, &listed); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(listed.Devices) != 1 { + t.Fatalf("device count: want 1, got %d", len(listed.Devices)) + } + d := listed.Devices[0] + if d.DeviceID != "desktop-1" || d.PublicKey != "pk-desktop" { + t.Fatalf("device fields: %+v", d) + } + if d.WrappedKey == nil || *d.WrappedKey != "wrapped-for-desktop" { + t.Fatalf("wrapped key not set/preserved: %+v", d) + } + + // Revoke removes it; a second revoke 404s. + status, _ = doReq(t, s, token, http.MethodDelete, vaultPath("/devices/desktop-1"), nil) + if status != http.StatusOK { + t.Fatalf("revoke: want 200, got %d", status) + } + status, _ = doReq(t, s, token, http.MethodDelete, vaultPath("/devices/desktop-1"), nil) + if status != http.StatusNotFound { + t.Fatalf("revoke again: want 404, got %d", status) + } +} diff --git a/hub/internal/server/server.go b/hub/internal/server/server.go index 832364482..5192b50c2 100644 --- a/hub/internal/server/server.go +++ b/hub/internal/server/server.go @@ -748,6 +748,22 @@ func (s *Server) buildAuthedRoutes(r chi.Router) { r.Post("/run", s.handleRunSchedule) }) }) + // Zero-knowledge SSH key vault (ADR-052 D-4). Blind blob sync across a + // principal's devices; the hub stores only client-encrypted ciphertext. + r.Route("/vault", func(r chi.Router) { + r.Get("/", s.handlePullVault) + r.Put("/", s.handlePushVault) + r.Route("/recovery", func(r chi.Router) { + r.Get("/", s.handleGetVaultRecovery) + r.Put("/", s.handleSetVaultRecovery) + r.Delete("/", s.handleDeleteVaultRecovery) + }) + r.Route("/devices", func(r chi.Router) { + r.Get("/", s.handleListVaultDevices) + r.Put("/{device}", s.handlePutVaultDevice) + r.Delete("/{device}", s.handleDeleteVaultDevice) + }) + }) // Team-scope channels (project_id NULL, scope_kind='team'). Events + // stream reuse the project-scope handlers — they only consume the // channel URL param. diff --git a/hub/migrations/0061_key_vault.down.sql b/hub/migrations/0061_key_vault.down.sql new file mode 100644 index 000000000..ab8d65b7b --- /dev/null +++ b/hub/migrations/0061_key_vault.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS key_vault_devices; +DROP TABLE IF EXISTS key_vaults; diff --git a/hub/migrations/0061_key_vault.up.sql b/hub/migrations/0061_key_vault.up.sql new file mode 100644 index 000000000..657d65ef1 --- /dev/null +++ b/hub/migrations/0061_key_vault.up.sql @@ -0,0 +1,41 @@ +-- Zero-knowledge SSH key vault sync (ADR-052 D-4). The hub is a BLIND blob +-- store: it holds only client-side-encrypted ciphertext it can never decrypt, +-- keyed to the calling principal, and syncs it across that principal's devices. +-- This is the carve-out that amends forbidden-pattern #15 — the hub never holds +-- the vault key or any plaintext, so it can never authenticate as the user. +-- +-- Two tables: +-- key_vaults - the one sealed vault blob per principal (versioned). +-- key_vault_devices - per-device enrollment: the device's public key plus the +-- vault key wrapped to it (both opaque to the hub). + +CREATE TABLE key_vaults ( + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + handle TEXT NOT NULL, -- principal owner (server-derived from token scope) + ciphertext TEXT NOT NULL, -- opaque AEAD-sealed vault (connections + keys), client-encrypted + version INTEGER NOT NULL DEFAULT 1, -- optimistic-concurrency counter + -- Recovery escrow (ADR-052 D-4): the vault key wrapped under a director-held + -- recovery key. Opaque to the hub (it never holds the recovery key). Lets a + -- principal who has lost every enrolled device recover the vault. + recovery_envelope TEXT, -- wrapped vault key; NULL until set + recovery_hint TEXT, -- non-secret label, e.g. "recovery code created 2026-07-05" + recovery_updated_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (team_id, handle) +); + +CREATE TABLE key_vault_devices ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + handle TEXT NOT NULL, -- principal owner (server-derived) + device_id TEXT NOT NULL, -- client-chosen stable device id + device_name TEXT, -- human label ("desktop", "phone") + public_key TEXT NOT NULL, -- device public key (opaque, for wrapping) + wrapped_key TEXT, -- vault key wrapped to this device (opaque); NULL until an enrolled device wraps it + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (team_id, handle, device_id) +); + +CREATE INDEX idx_key_vault_devices_owner ON key_vault_devices(team_id, handle);