# 11 — Implementation Plan Assumes one experienced developer full-time (with AI assistance), or a two-person team halving the calendar. Weeks are estimates; each milestone has a demo and a "done" definition. Security tests from [12](12-testing-and-assurance.md) are part of "done", not a later phase. > **Order of execution (D-27, 2026-09-30):** Phase 0 → Phase 1 → M2.1 (MCP) → **M3.1 + M3.2 as "delegation-lite"** (web dashboard instead of desktop; CLI for grantees) → M2.2 desktop app, M2.3 adapters, transparent mode, importers → Phase 3 remainder → Phase 4. The phase numbering below is kept for reference; the sequence above is what we build. Ten customer conversations gate the delegation build. ## Phase 0 — Foundations (Week 1) - Monorepo, workspace, CI matrix (macOS/Windows/Linux/wasm32), `cargo deny`/`audit`, formatting and clippy gates. - `SecretBytes` type with zeroize, redacted Debug/Display, and a compile-fail test that it cannot be JSON-serialized. - `crypto` module: Argon2id, HKDF, XChaCha20-Poly1305, BLAKE3, Ed25519, HPKE wrappers; test vectors generated and committed. - `SECURITY.md`, licences, `CONTRIBUTING.md`. **Done:** CI green on all targets; test vectors pass natively and in WASM. ## Phase 1 — Self mode, local (Weeks 2–9) ### M1.1 Vault and CLI basics (Weeks 2–3) - `storage` trait + SQLite backend; `vault` records, versions, tombstones; header and key wrapping; unlock/lock. - `usk init`, `unlock`, `lock`, `add`, `ls`, `show`, `rm`, `reveal`, `export`, `import` (`.env`, CSV/JSON of 1Password/Bitwarden). - Recovery kit generation and restore. - Provider detection from pasted values. **Done:** Round-trip 1,000 secrets; vault file contains no plaintext (test greps ciphertext for known values); restore from recovery words works. ### M1.2 Daemon and management API (Week 4) - `uskd` with axum; session token file; Unix socket / named pipe; `/v1/*` routes; SSE events. - Ingress guard (Origin/Sec-Fetch/Host) with tests. - Auto-lock on idle; lock on sleep/screen-lock hooks per platform. - `usk doctor`. **Done:** Malicious-page test (a headless browser hitting localhost) gets 403 on every route; peer-token test passes. > **As built (2026-09-30):** the daemon is the `unseenkey-daemon` library, run in-process by `usk daemon run` (auto-spawned, detached, on first use) with `uskd` as a thin second binary from the same crate, so there is one executable to distribute. Runtime files `daemon.token` (0600), `daemon.addr`, `daemon.pid` live next to the vault; `USK_LISTEN` / `USK_IDLE_LOCK_MINUTES` override `config.toml`. Browser-origin rejection is tested with header-based requests and a raw-socket spoofed `Host`; the headless-browser (Playwright) variant moves to M1.5 hardening. Deferred: Unix socket / named pipe transport and sleep/screen-lock hooks (M2.2, where the desktop app has the platform hooks anyway). Windows gotcha: a spawned daemon inherits inheritable pipe handles, so the CLI clears the inherit flag on its std handles first (`usk::platform`, the crate's only `unsafe`). ### M1.3 Policy engine (Week 5) - Schema parse/validate, glob matcher, limits, schedule, approval modes, `explain` renderer. - Presets for 17 providers with default policies. - Property tests and fuzzing harness for the evaluator. **Done:** 100% branch coverage on `eval.rs`; fuzz 1 h without panic; explanations reviewed for all presets. ### M1.4 Broker pipeline (Weeks 6–7) - Handle creation/lookup (hashed, constant-time), caller binding. - Adapters: bearer, header, basic, query, template. - Host lock, egress guard, scrubber (with streaming overlap), forwarder with rustls and HTTP/2, streaming SSE. - Audit log with hash chain; `usk log`, `usk log verify`. - `usk handle`, `usk env`, `usk policy` commands. **Done:** OpenAI, Anthropic and Stripe official SDKs work unchanged via env vars (integration tests against mock upstream + one manual run against real APIs); streaming works; egress-block and scrub tests pass; p99 overhead < 10 ms on the mock. > **As built (2026-10-01):** `core::handles` (hashed, `usk_h_` + 32 base62), `core::audit` (hash chain verifiable while locked; bodies encrypted under an audit DEK), `core::broker` (host lock with policy narrowing, adapters bearer/header/basic/query/template, Aho-Corasick scrubber over raw, base64, URL-encoded and JSON-escaped forms with a streaming overlap buffer), and the daemon's `/p/{preset}/…` route (reqwest + rustls with the `ring` provider, no redirects, HTTP/2, streamed responses scrubbed chunk by chunk, per-handle sliding-window counters, audit `use` written before the first byte and the request refused if the write fails). Every vault action now audits (unlock, lock, secret changes, reveal, handle create/revoke, policy change). CLI: `usk handle create|ls|show|revoke|policy`, `usk env`, `usk log`, `usk log verify`. Deferred to M1.5/M2: approval prompts (NeedsApproval currently returns 403 `approval_required`), caller identities beyond `local` (MCP tokens in M2.1), streaming request bodies (buffered to 32 MiB), camouflage handle formats (D-26), counters persisted every 100 uses and on lock. Test hook: `ServeOptions.upstream_overrides` maps logical hosts to a mock so the host lock is exercised with real host names. Real-API run done 2026-10-01: Claude Code (`claude -p`, Anthropic TypeScript SDK, OAuth bearer token from `claude setup-token`/credentials) pointed at the broker via `ANTHROPIC_BASE_URL` + `CLAUDE_CODE_OAUTH_TOKEN=usk_h_…` completed a `POST /v1/messages` with a 200 and the expected reply; the token was never displayed. Two findings fixed on the way: Node's `fetch` sends `sec-fetch-mode: cors` (removed from the browser markers; `Sec-Fetch-Site`/`Dest`/`User`, `Sec-CH-UA`, `Origin`, `Referer` remain), and bearer-kind secrets are injected as `Authorization: Bearer` regardless of the preset's key header (`sk-ant-oat…` detected as an OAuth bearer token; `usk env` emits `CLAUDE_CODE_OAUTH_TOKEN`). Still owed: the p99 overhead measurement. ### M1.5 `usk run`, `.env` guard, hardening (Weeks 8–9) - Injection mode with output scrubbing; `allow_injection` gating. - `.env` guard scan/fix. - Memory hardening: mlock, per-request decrypt, no plaintext in errors/logs (redacting tracing layer). - Threat-model test suite pass (INV-1…INV-8). - Packaging: cargo-dist, Homebrew, winget, scoop; signed releases. **Done:** Public alpha `v0.1.0`. 10 external users onboarded by hand. > **As built (2026-10-01):** `usk run --with VAR=secret -- ` asks the daemon's `POST /v1/secrets/{ref}/inject` (refused unless the secret has `allow_injection`, every call audited as `inject` with the variable and command line), sets the variables on the child only, scrubs the child's stdout/stderr through the streaming scrubber, warns when the program is a shell, and exits with the child's code. `usk secret set --allow-injection|--no-scrub|--notes|--tag|--upstream` edits the per-secret flags. `usk guard scan|fix [.env]` lists lines that look like secrets and, for providers with a preset, moves them into the vault (name from the key, uniquely suffixed), creates a handle, rewrites the line to the handle and adds/updates the provider's base-URL variable; secrets without a preset are left in place and reported. Hardening: `SecretBytes` buffers are `mlock`/`VirtualLock`ed best-effort (`crypto::platform`, the core's only `unsafe`, via `memsec`; the scrubber automaton still holds unlocked copies, see docs/04), and the daemon's log writer passes every line through the process-wide redactor so stored values can never reach `uskd.log` or stderr. Packaging: `.github/workflows/release.yml` builds `usk`/`uskd` for Linux x86_64/arm64, macOS arm64/x86_64 and Windows x86_64 on a `v*` tag, attaches SHA256SUMS, Sigstore keyless signatures and a CycloneDX SBOM to a draft GitHub release (untested until the first tag). Deferred: Homebrew/winget/scoop manifests (need a public repo), a Playwright browser test for INV-6, `.env` guard support for secrets without presets. ## Phase 2 — Agents and desktop (Weeks 10–17) ### M2.1 MCP server (Weeks 10–11) - Tools, resources, prompts; stdio and streamable HTTP; per-client tokens and caller identity. - `usk mcp install` for Claude Desktop, Claude Code, Cursor, Windsurf. - Approval flow end-to-end in CLI (desktop prompt comes in M2.2). **Done:** Claude Code can list secrets and call Stripe through `call_api`; a prompt-injection test (agent instructed to print the key) yields nothing. > **As built (2026-10-01):** one implementation in the daemon (`POST /mcp`, Streamable HTTP with JSON responses; JSON-RPC methods `initialize`, `ping`, `tools/list|call`, `resources/list|read`, `prompts/list|get`); `usk mcp --client ` is a stdio bridge that forwards lines to it, so client configs hold only the command. Tools `list_secrets`, `call_api`, `request_access`, `explain_policy`, and `store_secret` (only with `[mcp] allow_store = true`); resources `usk://presets`, `usk://audit/recent` (this caller's events); prompt `setup-project`. Caller identity: `usk mcp install ` registers a `usk_m_` token (hash in `/mcp/clients.json`, plaintext in `/mcp/.token`, 0600) and writes the client's config (Claude Code via `claude mcp add --scope user`; Claude Desktop, Cursor, Windsurf by merging `mcpServers.unseenkey` into their JSON). `call_api` auto-creates a handle per (client, secret) labelled `mcp:/` with the provider's recommended policy and `callers = ["mcp:"]`; owners tighten it with `usk handle policy`. The broker pipeline was split into `proxy::execute` (used by both the HTTP route and the tool) with two short vault-lock phases. **Approvals are live:** `NeedsApproval` parks the request (lock released) until `usk approve [--remember]` / `usk deny`, with the policy's timeout; `first_use` and `paths` decisions can be remembered per (handle, rule) until the daemon restarts; `request_access` pre-approves a gated rule or reports `denied_by_policy` when rules forbid it outright. Audit kinds added: `approval_decided`, `access_requested`. Deferred: desktop/mobile approval prompts (M2.2), server-initiated SSE on `/mcp`, persisting remembered decisions. ### M2.2 Desktop app (Weeks 12–15) - Tauri shell managing uskd; onboarding (create vault, passkey, recovery kit); secrets list/detail; handle and policy editor with preset picker and plain-English explanation; approvals popup; audit viewer; tray status; `.env` guard UI; agent setup wizard. - Passkey unlock via platform WebAuthn PRF (macOS/Windows), password fallback. - Auto-updater with signed manifests; notarization and Authenticode. **Done:** A new user goes from download to a working OpenAI call in Cursor in < 5 minutes without reading docs (tested with 5 people). > **As built, slice 1 (2026-10-01):** `apps/desktop` is a Tauri 2 + React/TypeScript app kept outside the Cargo workspace (own lockfile; `cargo build --workspace` stays free of webkit/GTK) with its own CI job (`desktop.yml`, three OSes). The webview has no network permission: every action is a Tauri command in `src-tauri/src/lib.rs` that forwards to the daemon through `usk::client::Client` with the session token, so the UI reuses the CLI's routes and error codes. The app hosts the daemon itself when none is running: `Client::connect` re-executes the current binary as `--vault … daemon run --detached`, and the desktop binary recognises those arguments and runs `usk::daemon::run` instead of the UI, so a desktop install needs no separate `usk`. Screens: create vault (24 words shown once, acknowledged before continuing, daemon started and unlocked with the same password), unlock, secrets (add with provider detection and host lock, flags, audited 30-second reveal, delete), handles (policy cards from the preset with plain-English explanation, expiry, one-time handle + SDK environment in PowerShell/bash/.env form, revoke), approvals (daemon SSE pumped into Tauri events; system notification; window raised; Allow once / Allow always for this rule / Deny with a live countdown), activity (filter by kind, chain verification), agents (one-click MCP install for Claude Code, Claude Desktop, Cursor, Windsurf; shows last-seen), settings with the `.env` guard. Tray: Open, Lock vault, Quit; closing the window hides it. Deferred to slice 2: passkey unlock (WebAuthn PRF), signed auto-updater, notarization/Authenticode, sleep/screen-lock hooks, the five-person onboarding test (needs the public download). > **As built, slice 2 (2026-10-01):** *Device unlock* per D-28: `core` gained `KeyWrap`s in the header (new SQLite table `key_wraps`, old vaults read as empty), `KekKind::Device(wrap_id)`, `Vault::add_key_wrap / remove_key_wrap / unlock_with_kek / key_wraps`; the daemon gained `/v1/unlock-methods` and the `wrap_id + kek` form of `/v1/unlock` (malformed bodies are refused before the attempt limiter), audit kinds `unlock_method_added/removed`, and `unlock` events now record the method. The desktop app implements Windows Hello in `src-tauri/src/hello.rs` (enable/disable in Settings while unlocked; the unlock screen prompts Hello once automatically and keeps the password field). Not verifiable headless: the Hello prompt needs a person, so the enrollment and unlock paths are exercised by the owner. *Lock on sleep and session lock:* `[daemon] lock_on_sleep` and `lock_on_session_lock` (both default true); the daemon's `platform.rs` (its only `unsafe`, crate moved from `forbid` to `deny(unsafe_code)`) runs a message-only window for `WM_WTSSESSION_CHANGE` and a `RegisterSuspendResumeNotification` callback for `PBT_APMSUSPEND`, and a portable resume detector locks after a wall-clock jump of more than 95 s in case the OS hook misses the suspend; `AppState::lock_vault(reason)` is the single lock path for the API, idle timer and hooks. *Signed updates:* `tauri-plugin-updater` with a minisign public key in `tauri.conf.json` and the endpoint `github.com/Bhagyesh97/unseenkey/releases/latest/download/latest.json`; a silent check at startup shows an "Install and restart" banner, and Settings has a manual check. The private key lives outside the repo (`~/.tauri/unseenkey.key`, generated without a password) and must be stored as the `TAURI_SIGNING_PRIVATE_KEY` repository secret; `release.yml` gained a `desktop` job (tauri-action on Windows, macOS arm64/x86_64, Ubuntu 22.04) that attaches installers and `latest.json` to the same draft release and signs with Apple or Authenticode certificates only when those secrets exist. **Release pipeline dry run (2026-10-02, tag `v0.1.0-alpha.1`, private repo):** all ten jobs green on the second attempt; one draft pre-release holds the five CLI archives (each with a Sigstore `.sig` + `.pem`), `SHA256SUMS`, and the desktop installers (NSIS + MSI, two DMGs + `.app.tar.gz`, deb/rpm/AppImage) with minisign `.sig`s and a merged `latest.json` covering all platforms. The Windows CLI archive was downloaded, its checksum matched and the binary ran. Fixed on the way: `macos-13` has no hosted runners any more (Intel builds now cross-compile on the arm64 runner), arm64 Linux is cross-compiled with `cross` (hosted arm runners are free only on public repos), the desktop crate needs its own `cargo fmt` (it is outside the workspace), and the SBOM came out empty because syft was pointed at the archives rather than the source tree (now `path: .` with a component-count check; verified on the next tag). A `check` job refuses tags whose version does not match the workspace and desktop versions. Signing secrets: `TAURI_SIGNING_PRIVATE_KEY` is set; Apple and Authenticode are not, so installers are unsigned (SmartScreen/Gatekeeper warnings) until certificates exist. Still owed: macOS Touch ID and Linux session hooks, the five-person onboarding test. > **Debts closed (2026-10-02):** *Remembered approvals persist.* "Allow always" decisions moved from daemon memory into the handle record (`HandleRecord.remembered`, encrypted like the rest of the record): they survive restarts, are wiped when the policy is replaced, and can be cleared with `POST /v1/handles/{id}/forget` / `usk handle forget [--rule …]` / the × next to the rule in the desktop app; audit kind `approval_forgotten`. *p99 overhead measured* (`crates/daemon/tests/overhead.rs`, ignored by default, release build, loopback mock, 400 requests after 50 warm-ups, this Windows laptop): direct p50/p95/p99 0.21/0.25/0.28 ms, brokered 0.61/1.64/2.19 ms, overhead **0.40/1.39/1.91 ms**, within the 10 ms target from M1.4. *Browser-origin test (INV-6)* in `tests/browser` (Playwright, CI job `browser`): pages on a public site and on another loopback port try fetch with/without CORS, simple POSTs, image/script tags, a form, a WebSocket, plus typed-in navigation; two projects, stock Chromium and Chromium with its local-network-access checks disabled. Finding: stock Chromium already refuses most page-initiated requests to loopback before sending them ("Permission was denied for this request to access the `loopback` address space"), so only navigations reach the daemon; with those checks off, every attempt reaches the daemon and the ingress guard refuses each one with 403 `browser_origin_rejected` (the guard now logs method and path for each rejection, which is how the suite counts them). Nothing is ever a 2xx in either mode. ### M2.3 Provider adapters (Weeks 16–17) - AWS SigV4, OAuth2 refresh, GCP service-account token exchange. - More presets (community contributions via `presets/`). - Transparent mode with per-session ephemeral CA (D-23); tests: CA never in OS store, deleted on exit, non-upstream hosts tunnelled untouched. - Importers: OS keychain, 1Password, Bitwarden, Infisical Agent Vault, OneCLI, Veil, `pass` (D-24). - Agent-framework plugins: OpenClaw, NanoClaw, Hermes; one-line setup for Codex (D-25). - Camouflage handle formats per preset (D-26). - Optional dedicated-OS-user broker with peer-credential checks, Linux and Windows (D-24). > **As built, slice 1a: AWS SigV4 (2026-10-07).** `core::broker::sigv4` (HMAC-SHA256 via the `hmac` crate already in the tree): canonical request, signing-key chain, `aws-chunked` decoding with trailer promotion, endpoint and scope tables, wire-form tidying of paths and queries. Preset `aws` (auth `aws_sigv4`, upstreams `*.amazonaws.com`, policies `s3-read-only`, `s3`, `full`; preset policies may now narrow the preset's hosts, and host patterns accept a `*` label in any position). The SDK is configured with `AWS_ENDPOINT_URL` + the handle as `AWS_ACCESS_KEY_ID` + a placeholder secret (new preset field `env_static`); the broker reads handle and scope from the SDK's own SigV4 header and signs again with the real key, details in [07](07-broker-and-policy.md#aws). New general route form `/p//@/…`. Secrets: `usk add` asks for the secret access key after an access key id, and pasted two/three lines, a `~/.aws/credentials` section, the console CSV or `aws sts` JSON are all taken apart into value + `access_key_id` (+ `session_token`) fields. Audit `use` events carry `aws` and `action`. Verified three ways: 13 vectors generated with botocore match byte for byte; a capturing mock re-derives each signature from what it received; and boto3 1.43 runs S3 list/put (chunked upload, Unicode key)/get/head/delete, DynamoDB GetItem and STS GetCallerIdentity through the broker against a stand-in that checks every signature with botocore (CI job `aws-interop`). Not done: rules on AWS actions, SigV4A, presigned URLs. > **As built, slice 1b: camouflage handles, D-26 (2026-10-07).** Presets may list `handle_formats` (`when` = prefix of the real key, `format` = literal text with `{N}` runs of random base62, e.g. `sk-proj-usk-{40}`, `sk_live_usk{24}`, `ghp_usk{33}`); `core::handles::Format` validates them (the `usk` marker in the fixed text, at least 22 random characters), generates and matches. Opt-in per handle: `camouflage: true` on `POST /v1/handles`, `--camouflage` on `usk handle create` and `usk env`, a checkbox in the desktop app; the canonical `usk_h_…` form stays the default because it cannot be mistaken for a key. Formats exist for OpenAI, Anthropic, Stripe (following the real key's live/test mode), GitHub, Slack, SendGrid, Google AI, Groq, OpenRouter and Resend. Camouflage handles are looked up by hash like any other; `presets::is_handle` recognises both kinds, so provider detection never takes a handle for a key and the `.env` guard leaves them alone. Not done: registering the formats with secret scanners (needs the public repo). > **As built, slice 1c: OAuth2 refresh (2026-10-07).** `oauth2_client` secrets (refresh token + `client_id`, `token_url`, optional `client_secret`/`scope`/`token_auth`) work on every route: the handle stands where an access token goes and the daemon (`daemon::oauth`) keeps a current access token in memory, refreshing with the stored refresh token; details in [07](07-broker-and-policy.md#oauth2). Token host held to the secret's host lock, rotated refresh tokens stored as a new secret version, refusals surfaced with the provider's reason and never cached, access tokens scrubbed from responses and forgotten on lock. Pasted token JSON (including gcloud's ADC file) is taken apart when the secret is added. Tested against a scripted token endpoint: cache, expiry, rotation, refusal, host lock, public and Basic-authenticated clients. Not done: the authorization-code dance to get the first refresh token, GCP service-account exchange, client-credentials grants. > **As built, slice 1e: keys in two parts (2026-11-03).** `usk add`, the app and the daemon take both parts of a Basic-auth key; details in [07](07-broker-and-policy.md#keys-in-two-parts). New presets: Razorpay, Mailgun, Plivo, Vonage, Cloudinary, Jira, DataForSEO. New command: `usk passwd`. Tested against a scripted provider, not the real ones. > **As built, slice 1d: Google service accounts (2026-11-03).** `gcp_service_account` secrets work on every route: the broker signs a JWT with the key file's key (`ring`, already in the tree through rustls) and trades it for an access token, reusing the OAuth2 cache, host lock and scrubbing; details in [07](07-broker-and-policy.md#google-service-accounts). New: preset `gcp`, `usk add --file`, `--scope` on `usk add` and `usk secret set` (owner only), key test by signing in. Tested against a scripted token endpoint: signature, claims, cache, scope change, refusal, and that the key file is never sent. > **Public-readiness pass (2026-10-07).** History and tree checked for secrets and personal paths (none; every key-shaped string is a documented fixture). The competitive survey moved out of the repository into the ignored `private/` directory because it profiles named individuals; the decisions it produced (D-23 to D-27) stay. README rewritten for a first-time reader (status, two-minute quickstart, install from Releases or `cargo install --git`, how it works, what it does not do); `docs/00` status brought up to date; the GitHub settings to flip on the day the repository goes public are listed in [13](13-operations.md#repository-and-releases). Open: whether to rewrite history to drop the survey's earlier commits before the flip (the repository has had no other clone). > **As built, incidents slice A (2026-10-22): caps, canaries, responses.** The "leaked key runs up a bill" story, made true for handles: `--cap N/day|N/min` on `usk handle create` and `usk env` (policy `limits`, hard stops); canary handles (`--canary`: empty policy, never resolved, 401 like an unknown handle, audit `canary_tripped`, default response `lock`); a denial-burst detector in the daemon (ten policy refusals on a handle within a minute, one incident an hour, caps and the handle's own standing excluded); per-handle `on_anomaly` = alert | throttle (6/min for an hour) | approve (every request prompts) | pause | lock, persisted with `status` and `last_anomaly` in the handle record (`core::handles::{AnomalyResponse, HandleStatus, Anomaly}`, `Vault::set_handle_incident / resume_handle / set_handle_response`); `POST /v1/handles/{id}/resume`, `PATCH … {on_anomaly}`, SSE `anomaly`, desktop notifications and a "Back to normal" control, Activity rows for the new kinds. Tested end to end in `crates/daemon/tests/anomaly.rs` and the CLI smoke test. Not done (slices B–D): spend metering in currency, baseline and novelty detectors, caller binding, alert routing beyond the desktop. > **Released: v0.1.0 (2026-10-22).** The repository went public the same day (settings in [13](13-operations.md#repository-and-releases)); the first public CI run surfaced six new rustls/webpki advisories, fixed by updating both lockfiles. The release pipeline ran green on its first real tag: twelve jobs, 36 assets, release notes from `CHANGELOG.md` through a final `notes` job. Verified on Windows: archive checksum and Sigstore signature, `SHA256SUMS` signature, both installers' minisign signatures against the updater's key, silent install, `latest.json` with every platform. Install channels: Scoop bucket and Homebrew tap under the owner's account, winget pull request microsoft/winget-pkgs#458399. Installers remain unsigned (Authenticode and Apple certificates are owner to-dos). > **As built, dogfood fixes (2026-10-22).** Another agent used Unseenkey for a real task (Hostinger's API through the generic route) and reported nine problems; the ones that blocked it were real. Fixed: template mode stripped `Authorization` before filling `{{usk:…}}` in (placeholder-carrying headers now survive the strip); a value that cannot be a header value is refused with `malformed_request` instead of vanishing; `--upstream` is normalised (`https://host/path` → `host`, case-folded, wildcards kept) on `usk add` and `usk secret set`; `usk secret set --provider` changes the provider and brings the preset's hosts; user presets load from `/presets/*.toml` without a rebuild (`core::presets::load_user_dir`, called by the daemon at start and the CLI on every run; built-in ids may be overridden); MCP `request_access` takes `upstream` and an approval adds the host to the secret; `host_not_allowed` and the generic route's `unknown_handle` say what to do; a Hostinger preset; README names `cargo install --path crates/usk`; CONTRIBUTING explains the Windows file lock. Tests in `broker.rs`, `api.rs`, `mcp.rs`, `presets.rs` and the CLI smoke test. Not done: a `presets` subcommand that writes a template file, and policy explanations that name deeper path segments (today `/client/v4/accounts/*/pages/projects` reads as "anything under /client/v4/accounts"). **Gate before Phase 3:** delegation interviews with five agencies and five freelancers (D-25). **Done:** `v0.5.0` public beta; launch post; collect the 30 interviews. ## Phase 3 — Cloud: accounts, sync, delegation (Weeks 18–29) ### M3.1 Accounts and sync (Weeks 18–21) - `cloud-accounts` (passkeys, devices), `cloud-sync` (ciphertext records, versions, conflicts), client `sync` module, `usk cloud *`. - Web app skeleton (WASM core, unlock, list, dashboards). - docker-compose for self-hosting. **Done:** Two devices stay in sync with conflicts surfaced; relay database inspection reveals no names or values. ### M3.2 Delegation and hosted broker (Weeks 22–26) - `grants` module, invites, device registration with owner approval, envelopes to the broker key (KMS-backed). - `cloud-broker`: signed-request verification, envelope decrypt, shared pipeline, audit signing, revoke propagation (< 1 s via pub/sub), fail-closed/open per grant. - Local uskd relay mode for grantees' SDKs. - Owner dashboard: live usage, revoke/pause/extend; grantee view with exportable signed log. - `cloud-events`: push approvals to desktop and PWA. **Done:** Raj/Dev scenario S5–S7 passes end-to-end in staging; revoke measured < 1 s; penetration test by an external firm on the hosted broker. ### M3.3 Trust and growth (Weeks 27–29) - GitHub secret-scanning partner registration; `cloud-scan` auto-revoke. - Workspaces (owner/admin/member). - Billing (Stripe, naturally through Unseenkey), pricing tiers. - Bug bounty (private). **Done:** `v1.0.0`. Public launch. ## Phase 4 — Advanced assurance (Weeks 30+) - Nitro Enclave broker image, reproducible build, attestation verification in clients, published PCR list. - Rotation adapters (OpenAI, Anthropic, Stripe, AWS IAM, GitHub, GCP) and schedules. - Leak monitoring (hash-based) for opted-in raw keys. - Protocol proxies: Postgres, MySQL, Redis; SSH agent. - Spend accounting and caps. - SOC 2 Type II; third-party crypto audit; publish report. - Helm chart, SSO/SCIM for self-hosted enterprise. ## Next release: 0.1.7, one-step setup for AI tools Goal: an AI tool can be connected with one command, from a Claude Code plugin or an MCP bundle, without making caller identities easier to steal. Order matters: the identity fix lands before anything registers callers on its own. 1. **Caller identities cannot be taken over (security, first). Shipped on its own in 0.1.6 (2026-10-31).** `POST /v1/callers` for a name that already existed silently replaced its token, and needed only the session token any same-user program can read; the new token inherited everything approved for that caller. - An existing name answers 409 `caller_exists`; replacing it needs the owner token (`X-Usk-Owner`), like other access-widening routes since 0.1.5. `usk mcp install` asks for the password when it replaces. - Audit `caller_registered`, `caller_replaced` and `caller_removed`, and emit a daemon event so the app shows "New AI tool connected: ". - Tests: replace without the owner token is refused; each action is audited. 2. **The MCP bridge registers itself. Built.** `usk mcp --client ` with no token file registers the caller with the session token (a new caller gets only care-level access, as an AI tool's first use already does), writes the token file (0600) and carries on. A 409 means the name is taken: tell the owner to run `usk mcp install `, never replace it. No vault: say to install Unseenkey and run `usk init`. Added while building it: until it has a token the bridge answers the handshake itself and gives the reason on every tool call (a failed `initialize` would make the client drop the server); a removed name is remembered (`caller_removed`) so a disconnect survives the tool's next start; the desktop app's executable runs the bridge when started as `mcp --client `, which the configs it writes always assumed. 3. **Claude Code plugin in this repository. Built.** `.claude-plugin/marketplace.json` (marketplace `unseenkey`) listing `plugins/unseenkey/`: a `plugin.json`, an `.mcp.json` running `usk mcp --client claude-code-plugin`, and a skill that tells Claude to use `list_secrets` / `call_api` for any API key and never to ask the user to paste one (`usk add ` instead). Install: `claude plugin marketplace add Bhagyesh97/unseenkey`, then `claude plugin install unseenkey@unseenkey`. `claude plugin validate` in CI. The README says to use the plugin or `usk mcp install claude-code`, not both. 4. **MCP bundle and the official registry. Built, ships in 0.1.9.** The release workflow's `mcpb` job joins the two Mac builds into one universal `usk` with `lipo`, adds the Windows `usk.exe`, and packs `unseenkey-.mcpb` with `packaging/mcpb/build.py` (manifest from `packaging/mcpb/manifest.json`, checked with `mcpb validate`). Claude Desktop runs `usk mcp --client claude-desktop` from the bundle, which starts the daemon itself, so `uskd` is not included. One bundle for both platforms (`platform_overrides` picks `usk.exe` on Windows) rather than one per platform, because a registry entry cannot say which platform a package is for. Linux is left out: Claude Desktop does not run there. The bundle is signed with the other artifacts, and the job writes `server.json` (`io.github.Bhagyesh97/unseenkey`, `registryType: mcpb`, the release URL and the bundle's SHA-256) as a release asset. The owner publishes it with `mcp-publisher login github` and `mcp-publisher publish` after publishing the release, because the registry downloads the bundle to check its hash and a draft's files are not public. The bundle still needs a vault, and its description says so. MCPB rather than crates.io, so users need no Rust toolchain. 5. **The macOS test flake. Fixed in 0.1.7.** Daemon tests on the macOS runner failed now and then with "Invalid argument (os error 22)" while reading a reply. Two causes: ureq's `.timeout()` set the socket timeout before every read, which macOS refuses once the peer has shut the connection; and the daemon answered some requests without reading their body, so hyper reset the connection when the body arrived. The CLI now sets its timeouts once per request, and the daemon reads every body first. **Done:** a fresh Claude Code installs the plugin and calls an API through Unseenkey with no other setup than `usk init` and `usk add`; registering over an existing caller without the owner token is refused and audited; the server is listed at registry.modelcontextprotocol.io. Then: recommend the plugin to awesome-claude-code (it is Claude Code-specific), and automate the Scoop and Homebrew bumps. ## Suggested order of first commits (Phase 0/1 kickoff) 1. `cargo new` workspace; `crates/core` with `crypto::secret_bytes` + tests. 2. `crypto::kdf`, `crypto::aead`, vectors. 3. `storage::trait` + `storage::memory` + `storage::sqlite`. 4. `vault::header` (create/unlock) → `vault::records` (put/get/list) → `usk init/add/ls`. 5. `policy::schema` + `policy::eval` + presets. 6. `handles` → `broker::pipeline` with `bearer` adapter against mock upstream → `usk env`. 7. `broker::scrub` + `broker::egress` → `audit` → `usk log`. 8. `uskd` ingress guard + management API → `usk` switches to talking to `uskd`. Each step is independently testable and demo-able. ## Risks to the plan | Risk | Mitigation | |---|---| | Passkey PRF support uneven across platforms | Password path first; PRF behind feature detection; document support matrix | | MCP spec churn | Keep `mcp` module thin; adapt via the official SDK | | SDKs that ignore `base_url` or pin hosts | Presets flag them; fall back to template/injection; maintain a compatibility list | | Solo-dev burnout | Cut scope, not security tests; Phase 3 can be delayed, Phase 1–2 alone is a product | | Trust bootstrap | Open source from the first commit; publish threat model; local mode needs no trust |