# Spec: Scoped Access (Agents & Entries) ## Overview Implement scoped access control for the vault. Every agent/human gets a token with one or more scopes. Every entry has zero or more scopes. Access is granted by set intersection. Admin operations (create/modify/delete agents and entries) require WebAuthn signature. ## Schema changes ### agents table ```sql CREATE TABLE agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, token_hash TEXT UNIQUE NOT NULL, -- sha256 of bearer token name TEXT NOT NULL, -- human-readable: "Claude Code", "Sarah", "Deploy CI" scopes TEXT NOT NULL DEFAULT '', -- comma-separated 4-char hex: "" or "0001" or "0001,0003" all_access INTEGER NOT NULL DEFAULT 0, -- 1 = bypass scope check, read all entries admin INTEGER NOT NULL DEFAULT 0, -- 1 = can execute admin ops (with WebAuthn) created_at INTEGER NOT NULL -- unix timestamp ); CREATE INDEX idx_agents_token ON agents(token_hash); ``` Notes: - `id` is auto-increment and IS the scope identifier. Display as 4-char zero-padded hex: `printf('%04x', id)`. - `scopes` field on the agent row contains the agent's own scope(s). For most agents this is just their own id hex. For MSP technicians with multiple roles, it can be multiple: `"000a,000b"`. - `all_access = 1` means this agent can read all entries regardless of scope. Does NOT grant admin rights. - `admin = 1` means this agent can perform admin operations. Still requires WebAuthn signature per request. - `all_access = 1, admin = 1` = vault owner. - `all_access = 1, admin = 0` = MSP technician with full read access, no admin. - `all_access = 0, admin = 0` = scoped read-only. Standard agent or family member. - Token is generated server-side, shown once to the user, stored as sha256 hash. The raw token is never stored. ### entries table (modification) Add `scopes` column to existing entries table: ```sql ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT ''; ``` Notes: - Empty `""` = owner-only. No agent or family member can see this. Only `all_access = 1` tokens. - Single scope `"0002"` = only agents whose scopes include `0002`. - Multiple scopes `"0002,0004,0005"` = any agent whose scopes include any of these (OR logic). - Comma-separated 4-char zero-padded hex. No spaces. - Maximum realistic size: 10 scopes = 49 bytes. Negligible. ## Token format Bearer token format: `cvt_` prefix + 32 bytes random (base62 encoded). Example: `cvt_7kQ9mR2xP4wL8nB3vF6jH1cT5yA0dE4s` Stored as: `sha256("cvt_7kQ9mR2xP4wL8nB3vF6jH1cT5yA0dE4s")` The `cvt_` prefix allows secret scanning tools (GitHub, GitLab) to detect leaked tokens. ## Scope matching logic ### Function: `scopeMatch(agentScopes string, entryScopes string) bool` ``` If entryScopes is empty → return false (owner-only entry) Split agentScopes by "," Split entryScopes by "," Return true if any element in agentScopes exists in entryScopes ``` ### SQL implementation For a single agent scope (most common case): ```sql SELECT * FROM entries WHERE id = :entry_id AND ( :all_access = 1 OR (',' || scopes || ',' LIKE '%,' || :agent_scope || ',%') ) AND scopes != '' -- redundant if LIKE already fails, but explicit ``` For multi-scope agents (MSP technicians): The agent's `scopes` field may contain multiple values. Application code should split the agent's scopes and check each, OR build a dynamic OR clause: ```sql -- For agent with scopes "000a,000b": SELECT * FROM entries WHERE id = :entry_id AND ( :all_access = 1 OR (',' || scopes || ',' LIKE '%,000a,%') OR (',' || scopes || ',' LIKE '%,000b,%') ) ``` For listing all accessible entries: ```sql -- Single-scope agent: SELECT * FROM entries WHERE :all_access = 1 OR (',' || scopes || ',' LIKE '%,' || :agent_scope || ',%') -- all_access agent (owner, full-access MSP tech): SELECT * FROM entries ``` ## API endpoints ### Read operations (bearer token only) ``` GET /api/entries Auth: Bearer token Returns: all entries matching agent's scope (id, name, scopes, L2 fields, L3 ciphertext) If all_access: returns all entries GET /api/entries/:id Auth: Bearer token Returns: single entry if scope matches 403 if scope mismatch GET /api/ext/totp/:id Auth: Bearer token Returns: { "code": "284919", "expires_in": 14 } 403 if scope mismatch GET /api/search?q=:query Auth: Bearer token Returns: entries matching query AND scope Full-text search on name, url, username, notes within scoped entries ``` ### Admin operations (bearer token + WebAuthn) All admin requests must include: - `Authorization: Bearer ` header - `X-WebAuthn-Assertion: ` header - `X-WebAuthn-Challenge: ` header Server validates: token exists AND admin=1 AND WebAuthn signature valid AND challenge valid and not expired. ``` POST /api/webauthn/challenge Auth: Bearer token (admin=1 required) Returns: { "challenge": "", "challenge_id": "", "ttl": 60 } Stores challenge server-side with 60s TTL. POST /api/agents Auth: Bearer + WebAuthn Body: { "name": "Claude Code", "scopes": "auto", "all_access": false, "admin": false } If scopes is "auto": assigns next ordinal as scope (printf('%04x', new_id)) Returns: { "id": 2, "scope": "0002", "token": "cvt_...", "name": "Claude Code" } Token shown ONCE in response. Never retrievable again. GET /api/agents Auth: Bearer (admin=1 required, no WebAuthn needed for read) Returns: list of all agents (id, name, scopes, all_access, admin, created_at). Never returns token_hash. PUT /api/agents/:id Auth: Bearer + WebAuthn Body: { "name": "New name", "scopes": "0002,0005", "all_access": false, "admin": false } Updates agent row. Cannot modify own admin flag (prevent self-lockout? or allow?). DELETE /api/agents/:id Auth: Bearer + WebAuthn Deletes agent row. Token immediately invalid. Cannot delete self (prevent lockout). POST /api/entries Auth: Bearer + WebAuthn Body: { "name": "GitHub", "scopes": "0002,0003", "fields": { ... } } Creates entry with specified scopes. PUT /api/entries/:id Auth: Bearer + WebAuthn Body: { "name": "GitHub", "scopes": "0002,0003,0005", "fields": { ... } } Updates entry including scope list. PUT /api/entries/:id/scopes Auth: Bearer + WebAuthn Body: { "scopes": "0002,0003,0005" } Updates only the scopes column. Convenience endpoint for managing access without touching fields. DELETE /api/entries/:id Auth: Bearer + WebAuthn Deletes entry. ``` ## WebAuthn implementation ### Storage ```sql CREATE TABLE webauthn_credentials ( id TEXT PRIMARY KEY, -- credential ID from WebAuthn registration public_key BLOB NOT NULL, -- public key bytes sign_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE TABLE webauthn_challenges ( challenge_id TEXT PRIMARY KEY, challenge TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL -- created_at + 60 ); ``` ### Challenge lifecycle 1. `POST /api/webauthn/challenge` → generate 32 bytes random, store with 60s TTL, return challenge + challenge_id. 2. Client receives challenge, triggers WebAuthn `navigator.credentials.get()` with the challenge. 3. User taps hardware key. 4. Client sends admin request with signed assertion + challenge_id. 5. Server: look up challenge by challenge_id. Verify not expired. Verify signature against stored public key. Verify sign_count is incrementing. Delete challenge (single use). 6. If all valid → execute admin operation. ### Cleanup Expired challenges should be deleted periodically. Run on each challenge request or via a simple sweep: ```sql DELETE FROM webauthn_challenges WHERE expires_at < unixepoch(); ``` ## Authentication flow ### Request processing (pseudocode) ```go func handleRequest(r *http.Request) { // 1. Extract bearer token token := extractBearer(r) if token == "" { return 401 } // 2. Look up agent agent := db.QueryRow("SELECT id, scopes, all_access, admin FROM agents WHERE token_hash = ?", sha256(token)) if agent == nil { return 401 } // 3. If admin operation, verify WebAuthn if isAdminEndpoint(r) { if agent.admin != 1 { return 403 } if !verifyWebAuthn(r) { return 403 } } // 4. For read operations, check scope if isReadEndpoint(r) { entryID := extractEntryID(r) entry := getEntry(entryID) if !agent.all_access && !scopeMatch(agent.scopes, entry.scopes) { return 403 } // Serve entry (L2 decrypted, L3 as ciphertext) } } ``` ## Scope display In the web UI and API responses, scopes are displayed as both hex ID and name: ```json { "id": 2, "scope": "0002", "name": "Claude Code", "scopes": "0002", "all_access": false, "admin": false } ``` Entry scope display resolves IDs to names: ```json { "id": 1, "name": "GitHub token", "scopes": "0002,0003", "scope_names": ["Claude Code", "Deploy CI"], "fields": { ... } } ``` ## Migration ### For existing vaults (no scopes yet) 1. Add `scopes` column to entries: `ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT '';` 2. Create agents table. 3. Existing auth tokens become agent rows: create agent row for each existing token with `all_access=1, admin=1` (vault owner). 4. All existing entries have `scopes = ""` (owner-only) by default. This is the secure default — nothing changes for existing users until they explicitly create agents and assign scopes. ### Vault owner bootstrap On first vault setup or migration: 1. User registers WebAuthn credential (hardware key enrollment). 2. System creates first agent row: `id=1, name="Owner", all_access=1, admin=1`. 3. User receives their bearer token (shown once). 4. All entries default to `scopes=""` (owner only). 5. User creates additional agents and assigns scopes via admin operations. ## Constraints & validation - Agent name: 1-100 characters, non-empty. - Scopes string: must match pattern `^([0-9a-f]{4})(,[0-9a-f]{4})*$` or be empty string. - Token: 32 bytes random, base62 encoded, `cvt_` prefix. Generated server-side only. - Cannot delete the last admin agent (prevent lockout). - Cannot remove admin flag from self if no other admin exists. - Maximum agents per vault: enforced by pricing tier (5, 15, 50, unlimited). - Maximum devices (WebAuthn credentials): enforced by pricing tier (2, 6, unlimited). ## Tier enforcement The vault itself does not enforce tier limits. The hosted management layer (outside the vault) enforces: - Token count: `SELECT COUNT(*) FROM agents` checked before `POST /api/agents`. - Device count: `SELECT COUNT(*) FROM webauthn_credentials` checked before WebAuthn registration. - Vault count: checked at the account level, not within the vault. Self-hosted vaults have no limits.