Skip to content

feat(hub): zero-knowledge SSH key-vault sync (ADR-052 D-4) + accept ADR-052#299

Merged
physercoe merged 4 commits into
mainfrom
feat/hub-key-vault-sync
Jul 5, 2026
Merged

feat(hub): zero-knowledge SSH key-vault sync (ADR-052 D-4) + accept ADR-052#299
physercoe merged 4 commits into
mainfrom
feat/hub-key-vault-sync

Conversation

@physercoe

Copy link
Copy Markdown
Owner

Implements the first build of ADR-052 (director-chosen B2): the hub as a blind cross-device sync store for the client-side-encrypted SSH key vault. Also flips ADR-052 to Accepted and applies the forbidden-pattern #15 amendment that authorizes it (the amendment lands with the code it permits, per the forbidden-patterns rule).

What this is

The hub holds only opaque ciphertext it can never decrypt, keyed to the calling principal (server-derived from the token scope — never client-supplied, so a caller can only ever reach their own vault). This is defense-in-depth; the real protection is that only enrolled devices hold the key to decrypt. The hub never holds the vault key or any plaintext.

Changes

  • Migration 0061key_vaults (one sealed blob per principal, versioned) + key_vault_devices (per-device public key + the vault key wrapped to it, both opaque).
  • handlers_vault.go — endpoints under /v1/teams/{team}/vault (bearer + teamGate):
    • GET /vault — pull the sealed blob (404 if none)
    • PUT /vault — push with optimistic concurrency (base_version; 409 on stale/duplicate → pull and retry)
    • GET /vault/devices — list enrolled devices + wrapped keys
    • PUT /vault/devices/{device} — enroll a device (pubkey) / wrap the vault key to it
    • DELETE /vault/devices/{device} — revoke a device envelope
  • Payloads capped (2 MiB); mutations audited (vault.push / vault.device.enroll|revoke).
  • Docs commit — ADR-052 → Accepted, D-6 (hub PTY-relay + A2A-relay auth) marked DEFERRED per direction, forbidden-pattern deps(hub): bump modernc.org/sqlite from 1.34.4 to 1.50.1 in /hub #15 amended.

Verification

go build ./... + go vet clean; full internal/server package tests green (236s); -race on the vault suite green (24.7s). New tests cover push/pull round-trip, optimistic-concurrency conflicts, the new-device pubkey guard, and device enroll/wrap/list/revoke.

Notes / deferred

  • Identity: the hub has no durable per-human id; ownership keys on the token's principal (principalFromScope, never empty). Fine for the zero-knowledge model; a hard per-human key is a later concern.
  • Deferred (this PR does not touch): the hub PTY-relay + A2A-relay auth (ADR-052 D-6), and the recovery-secret design (losing all enrolled devices ⇒ lost vault) — worth deciding before clients ship enrollment.
  • Client-side (Rust/Dart) sealing/enrollment is a separate follow-up; this is the hub sync surface only.

🤖 Generated with Claude Code

Ubuntu and others added 4 commits July 5, 2026 09:14
…dge vault

Director accepted B2. ADR-052 → Accepted; D-6 (hub PTY-relay + A2A-relay auth)
marked DEFERRED per direction. forbidden-pattern #15 amended: narrows "no SSH
secrets on the hub" → "no secrets the hub can read or use; client-side-encrypted
zero-knowledge vault ciphertext permitted" (the hub never holds the vault key or
plaintext, so it can never authenticate as the user). This is the carve-out that
authorizes the vault sync store landing in the next commit.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
The hub as a blind cross-device sync store for the client-side-encrypted SSH key
vault. It holds only opaque ciphertext it can never decrypt, keyed to the calling
principal (server-derived from the token scope, never client-supplied — a caller
can only ever reach their own vault). Migration 0061 adds key_vaults (one sealed
blob per principal, versioned) + key_vault_devices (per-device public key + the
vault key wrapped to it, both opaque).

Endpoints under /v1/teams/{team}/vault (bearer + teamGate):
  GET  /vault                    pull the sealed blob (404 if none)
  PUT  /vault                    push with optimistic concurrency (base_version;
                                 409 on stale/duplicate — pull and retry)
  GET  /vault/devices            list enrolled devices + wrapped keys
  PUT  /vault/devices/{device}   enroll a device (pubkey) / wrap the key to it
  DELETE /vault/devices/{device} revoke a device envelope

Payloads capped (2 MiB); audited (vault.push / vault.device.enroll|revoke). Tests
cover push/pull round-trip, optimistic-concurrency conflicts, the new-device
pubkey guard, and device enroll/wrap/list/revoke. go build/vet clean; package
tests green incl. -race on the vault suite.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… escrow

Two director refinements:
- D-3 revised: the vault seals the WHOLE personal setup — connection bookmarks
  (host/port/username/auth-method/jump/tmux) AND keys/passphrases/passwords — as one
  client-encrypted bundle, so a new device is directly reusable. Personal connection
  metadata now lives INSIDE the zero-knowledge vault (hub-blind, not even hostnames
  visible), superseding the earlier "sync via hub-visible ssh_hint" framing; ssh_hint
  keeps its separate team-host-binding purpose.
- D-4: recovery escrow decided. The vault key is also wrapped under a director-held
  recovery code (self-escrowed offline), stored on the hub as an opaque recovery
  envelope — so losing every enrolled device is survivable. Hub never holds the
  recovery key; zero-knowledge holds. Re-key re-wraps it; operator/Shamir escrow are
  same-slot extensions.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds the recovery escrow to the vault sync store (ADR-052 D-4). Migration 0061 gains
recovery_envelope / recovery_hint / recovery_updated_at on key_vaults (the vault key
wrapped under the director's escrowed recovery key — opaque to the hub, never the
recovery key itself). New endpoints:
  GET    /v1/teams/{team}/vault/recovery   fetch the recovery envelope (recovering device)
  PUT    /v1/teams/{team}/vault/recovery   set/replace it (vault must exist -> 404 if not)
  DELETE /v1/teams/{team}/vault/recovery   clear it

Audited (vault.recovery.set/clear). Test covers set-before-vault 404, missing-envelope
400, set/get round-trip, survival across a vault version bump, and clear. The whole
connection+key bundle (D-3) needs no hub change — the vault blob is opaque, so a bigger
sealed bundle just flows through the existing store. go build/vet clean; vault suite
green incl. -race.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@physercoe

Copy link
Copy Markdown
Owner Author

Extended per director direction (2 commits added):

  • Recovery escrow (ADR-052 D-4). Migration 0061 gains recovery_envelope / recovery_hint / recovery_updated_at on key_vaults, plus GET/PUT/DELETE /vault/recovery. The vault key is wrapped under a director-held recovery code (self-escrowed offline); the hub stores only the opaque envelope, never the recovery key. Lets a principal who lost every enrolled device recover. Tests cover set-before-vault 404, missing-envelope 400, round-trip, survival across a vault version bump, and clear.
  • Vault syncs the whole bundle (ADR-052 D-3 revised). The vault now seals connections (host/port/username/auth-method/jump/tmux) and keys/passphrases/passwords as one client-encrypted bundle, so a new device is directly reusable. Personal connection metadata moves inside the zero-knowledge vault (hub-blind — not even hostnames visible); ssh_hint keeps its team-host-binding purpose. No hub code change for this — the blob is opaque.

Re-verified: go build/vet clean, vault suite green incl. -race (29.9s). Doc-lint green.

@physercoe
physercoe merged commit 6a97596 into main Jul 5, 2026
4 checks passed
physercoe pushed a commit that referenced this pull request Jul 5, 2026
Dart client for the zero-knowledge vault endpoints (hub store shipped in #299).
Thin transport over the blind blob store — everything is opaque client-encrypted
base64 (sealed by services/vault/vault_crypto.dart), the hub never sees plaintext.

VaultApi (wired into the HubClient facade as `client.vault`):
- pullVault / pushVault — pull the sealed bundle; push with optimistic concurrency
  (base_version; HubApiError 409 on stale -> pull, re-seal, retry). pullVault
  returns null on 404 so a fresh client knows to create.
- getRecovery / setRecovery / deleteRecovery — the recovery envelope.
- listDevices / putDevice / deleteDevice — per-device enrollment + wrapped keys.

Mirrors the existing sub-client pattern (AttentionApi etc.); like its siblings it's
a thin pass-through validated by flutter analyze + device testing (no live-server
unit harness exists for sub-clients). Flutter SDK isn't available locally, so CI is
the gate.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
physercoe pushed a commit that referenced this pull request Jul 5, 2026
Surfaces the zero-knowledge SSH key-vault sync (ADR-052 D-4) in the mobile
app end-to-end: hub blind blob store (#299), Dart crypto core (#300), VaultApi
sync client, and the Settings › Data › Key vault sync flow.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant