Building Personal Vault

February 23, 2026

I was building a form-filling agent — something that could take the first pass on insurance documents, government forms, tax returns, visa applications. The agent was good at reading PDFs and identifying fields. But it kept asking me the same questions: What's your full name? What's your address? What's your SSN?

I needed a way to give agents access to personal context — securely, with scoped access, under my control. So I built Personal Vault.

The problem

Every AI agent that acts on your behalf needs personal context. Your name, your address, your passport number, your dietary preferences, your loyalty program IDs. Today, you either type this into each app individually, or you paste it into a chat window and hope the model doesn't log it.

Neither option scales. If you use five agents across travel booking, tax prep, form filling, and shopping, you're entering the same data into five different systems with five different trust models. There's no single place where you own your context and delegate access on your terms.

I wanted something like 1Password, but for AI agents — an encrypted local store that agents can query with scoped, time-limited access. Every read and write audit-logged. No cloud. You own the keys.

Architecture

Personal Vault is a Go binary with four layers:

cmd/pvault/      CLI (thin HTTP clients)
internal/
  crypto/        KDF, cipher, subkey derivation
  store/         SQLite CRUD
  vault/         Business logic, sessions, encryption
  api/           HTTP server + MCP bridge

The CLI talks to a local HTTP server over 127.0.0.1:7200. When you run pvault unlock, it starts a background server, derives your vault key, and holds it in memory. When you run pvault lock (or after 30 minutes of inactivity), the key is zeroed and the server stops.

An MCP server (TypeScript, six tools) bridges agents to the vault API. Agents call vault_get, vault_list, or vault_context — the MCP server handles auth, scope filtering, and audit logging transparently.

The crypto layer

I wanted a design where the database file is useless without the vault key, and the vault key only exists in memory while unlocked.

The key derivation chain:

Profile Password + Secret Key (128-bit random)
  → Argon2id (64 MB, 3 iterations)
  → Vault Key (256-bit, in-memory only)
  → HKDF-SHA256 per category
  → AES-256-GCM per field (12-byte random nonce)

The secret key is a 128-bit random value stored at ~/.pvault/secret.key with 0600 permissions. It never enters the database. This means even if someone gets your password, they can't derive the vault key without the secret key file. And if someone copies your database, they need both.

Here's the actual key derivation:

func DeriveVaultKey(password, secretKey, salt []byte) []byte {
    combined := make([]byte, len(password)+len(secretKey))
    copy(combined, password)
    copy(combined[len(password):], secretKey)
    key := argon2.IDKey(combined, salt, 3, 64*1024, 1, 32)
    for i := range combined {
        combined[i] = 0
    }
    return key
}

The combined buffer is zeroed immediately after use. Argon2id with 64 MB memory cost makes brute-force expensive. One thread keeps performance deterministic across machines — this is a local tool, not a server handling concurrent logins.

Each category gets its own subkey via HKDF:

func DeriveSubkey(vaultKey, salt []byte, category string) ([]byte, error) {
    r := hkdf.New(sha256.New, vaultKey, salt, []byte(category))
    subkey := make([]byte, 32)
    if _, err := io.ReadFull(r, subkey); err != nil {
        return nil, fmt.Errorf("deriving subkey for %s: %w", category, err)
    }
    return subkey, nil
}

This means compromising one category's subkey doesn't compromise the others. Each field is encrypted with AES-256-GCM using a fresh 12-byte random nonce, so identical values encrypt differently.

Why not SQLCipher?

I evaluated SQLCipher for full-database encryption. The code change was trivial — about 20 lines. But SQLCipher requires CGO, which breaks cross-compilation and adds CI complexity. Since field values are already encrypted at the application layer, the database file only leaks metadata: field IDs, categories, and timestamps. That's an accepted tradeoff for the portability of a pure Go binary that compiles to a single static executable on any platform.

Session management

The vault key exists only while the vault is unlocked. The Session struct holds it in memory, locked to RAM with mlock to prevent swapping:

type Session struct {
    mu       sync.Mutex
    token    string
    vaultKey []byte
    timer    *time.Timer
    lockFn   func()
    ttl      time.Duration
}

On creation, the session copies the vault key (so the caller can't mutate it), generates a 32-byte random session token, and starts a 30-minute auto-lock timer. Every API call resets the timer. When it fires — or when you run pvault lock — the key is zeroed byte by byte:

func (s *Session) zeroKey() {
    unlockMemory(s.vaultKey)
    for i := range s.vaultKey {
        s.vaultKey[i] = 0
    }
    s.vaultKey = nil
}

Token validation uses constant-time comparison to prevent timing attacks:

func (s *Session) ValidateToken(token string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    if s.token == "" {
        return false
    }
    return subtle.ConstantTimeCompare([]byte(s.token), []byte(token)) == 1
}

Scoped access

The part that makes this useful for agents is scoped access. When you create a service token for an agent, you specify exactly what it can read:

pvault create-service-token tax-agent \
  --scope "identity.*,financial.*" \
  --ttl 1h

This gives the tax-agent a token that can read any field in the identity and financial categories, but nothing else. The token expires in one hour. The scope pattern matching is simple by design:

func ScopeAllows(scope, fieldID string) bool {
    for _, p := range strings.Split(scope, ",") {
        p = strings.TrimSpace(p)
        if p == "*" {
            return true
        }
        if strings.HasSuffix(p, ".*") {
            category := strings.TrimSuffix(p, ".*")
            if strings.HasPrefix(fieldID, category+".") {
                return true
            }
            continue
        }
        if p == fieldID {
            return true
        }
    }
    return false
}

Three levels: wildcard (*), category (identity.*), and exact field (identity.full_name). No regex, no complex ACLs. Service tokens are hashed with SHA-256 before storage — the plaintext token is shown once at creation and can never be retrieved.

The MCP server

The MCP server is the bridge between AI agents and the vault. It exposes six tools: vault_status, vault_schema, vault_get, vault_list, vault_context, and vault_set.

When an agent is configured with a scope, the MCP server provisions a scoped service token on startup:

async function main() {
  if (scope !== "*") {
    const consumer = process.env.VAULT_CONSUMER ?? "mcp";
    await provisionServiceToken(consumer, scope);
  }

  const transport = new StdioServerTransport();
  await server.connect(transport);
}

Every tool call filters results by scope before returning them to the agent. If a travel-booking agent has scope identity.*,documents.passport_number, it can read your name and passport number but not your SSN or financial data. The agent never sees fields outside its scope — they're filtered at the MCP layer before the response reaches the model.

The audit log captures every access:

Consumer: "tax-agent"  Scope: "financial.*"  Action: "read"
Consumer: "vault"      Scope: "*"            Action: "unlock"
Consumer: "travel-bot" Scope: "identity.*"   Action: "api_access"

You can run pvault audit anytime to see who accessed what and when.

Sensitivity tiers

Not all personal data is equal. Your timezone is public. Your name is standard. Your phone number is sensitive. Your SSN is critical. Personal Vault tracks this with four sensitivity tiers:

The canonical schema assigns default tiers to known fields, but you can override them. The tiers are metadata that agents can use to decide how to handle data — a form-filling agent might auto-populate public and standard fields but prompt you before filling sensitive ones.

What this unlocks

The original use case was form filling: agent reads a PDF, identifies required fields, pulls context from the vault, and fills 90% of the document without asking.

But the interesting part is what happens when multiple agents share the same vault:

You tell one vault. Not fifty apps.

Try it

curl -fsSL https://www.personalvault.dev/install.sh | sh
pvault onboard

The onboard command creates a vault, sets a password, and walks you through populating common fields. Two minutes to get started.

Personal Vault is open source at github.com/lovincyrus/personal-vault.