clavitor/clavis/clavis-vault/SPEC-scopes.md

11 KiB

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

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:

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):

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:

-- 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:

-- 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 <token> header
  • X-WebAuthn-Assertion: <base64-encoded signed assertion> header
  • X-WebAuthn-Challenge: <challenge-id> 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": "<random-base64>", "challenge_id": "<uuid>", "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

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:

DELETE FROM webauthn_challenges WHERE expires_at < unixepoch();

Authentication flow

Request processing (pseudocode)

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:

{
    "id": 2,
    "scope": "0002",
    "name": "Claude Code",
    "scopes": "0002",
    "all_access": false,
    "admin": false
}

Entry scope display resolves IDs to names:

{
    "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.