commit bca87231b08c4fbcbd8eac04906e0b1ab2061b71 Author: James Date: Tue Mar 10 05:39:37 2026 -0400 init: vault1984 monorepo skeleton + L2 agent encryption design diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..399d658 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +._.DS_Store +vault1984 +*.log +app/ +docs/ +website/ +.DS_Store +._.DS_Store +vault1984 diff --git a/L2_AGENT_ENCRYPTION.md b/L2_AGENT_ENCRYPTION.md new file mode 100644 index 0000000..903d90a --- /dev/null +++ b/L2_AGENT_ENCRYPTION.md @@ -0,0 +1,270 @@ +# L2 Agent Encryption — Design Document + +## Problem + +The database on the server should be worthless to steal. + +Today, anything an AI agent can read is encrypted with a server-held key. Steal the database + derive the vault key (from the first WebAuthn credential's public key) = read everything agents can read. The server is interesting to steal. + +## Solution: Three-Tier Encryption + +### L1 — Server-readable (metadata) + +What it protects: entry title, type, URLs, username labels. Knowing someone has a Coinbase account isn't an attack. + +What exists today: titles are already plaintext (for search). The rest is inside the L1-encrypted blob. Some metadata fields should move out of encryption into plaintext or remain L1-encrypted — acceptable either way since the server can read L1 by design. + +No changes needed. L1 stays as-is. + +### L2 — Agent-readable, server-opaque (secrets) + +What it protects: passwords, API keys, TOTP seeds, SSH private keys — anything an AI agent needs to act on. + +The server stores L2 ciphertext. It cannot decrypt it. Agents decrypt locally using the L2 private key embedded in their token. + +**This is the new tier. This is what we're building.** + +### L3 — Hardware-only (high-value secrets) + +What it protects: card numbers, CVV, passport numbers, government IDs, bank accounts, seed phrases. + +Encrypted with a symmetric key derived from WebAuthn PRF. Requires physical authenticator. Even a fully compromised agent with L2 access cannot reach L3. + +This is the current "L2" implementation (client-side PRF encryption). Rename and keep. + +## Key Derivation + +Single root of trust: the hardware authenticator's PRF output. + +``` +Hardware authenticator (Touch ID / YubiKey / Titan Key) + → WebAuthn PRF output (32 bytes) + │ + ├─ HKDF-SHA256(salt="vault1984-l2-seed", info=empty) + │ → 32 bytes + │ → X25519 keypair (asymmetric) + │ ├─ public key → stored on server (for browser encryption) + │ └─ private key → NEVER stored on server + │ ├─ browser has it during PRF session + │ └─ baked into agent tokens at creation time + │ + └─ HKDF-SHA256(salt="vault1984-l3", info=empty) + → 32 bytes → AES-256 key (symmetric) + → browser-only, never leaves the client +``` + +Properties: +- L2 private key cannot be used to derive L3 key (independent HKDF branches) +- Compromised agent = L2 exposed, L3 untouched +- Both derived from same PRF tap — one authentication unlocks both in browser + +## Combined Agent Token + +Agent credentials are a single opaque string containing both the MCP auth token and the L2 private key. + +### Format + +``` +base64url(mcp_token_bytes || AES-256-GCM(per_token_key, l2_private_key)) +``` + +Where: +``` +per_token_key = HKDF-SHA256(ikm=mcp_token_bytes, salt="vault1984-token-wrap", info=empty) +``` + +### Properties + +- Each token looks completely different (L2 key wrapped with token-specific key) +- Two tokens side by side reveal no shared material +- Agent splits locally: auth half → server, key half → local decryption only +- Server never sees the L2 private key in the combined token (only at creation time, briefly in memory) + +### Agent-side flow + +``` +1. Read combined token from config file +2. Decode base64url +3. Split at known offset (first 32 bytes = MCP token) +4. Derive per_token_key from MCP token bytes +5. Unwrap L2 private key via AES-256-GCM +6. Auth: send MCP token in Authorization header +7. Decrypt: use L2 private key locally on L2 ciphertext +``` + +## Token Creation Flow + +1. User clicks "Create MCP token" in browser UI +2. Browser triggers WebAuthn authentication (user taps hardware key) +3. PRF output → derive L2 private key via HKDF +4. Server creates MCP token record (label, scope, expiry) +5. Browser receives MCP token bytes from server +6. Browser wraps L2 private key with per-token key +7. Browser concatenates and base64url-encodes +8. Combined token displayed once for user to copy + +**Requires WebAuthn tap** — this is desirable, not a limitation. Creating agent credentials should require physical authentication. + +## Entry Save Flow (Browser) + +When saving an entry with L2 fields: + +1. User has active PRF session (already tapped hardware key) +2. Browser derives L2 keypair from PRF output +3. For each L2 field: + - Generate ephemeral X25519 keypair + - ECDH(ephemeral_private, l2_public_key) → shared secret + - HKDF(shared_secret) → AES-256-GCM key + - Encrypt field value + - Store: ephemeral_public_key || nonce || ciphertext +4. L2 field values in VaultData are replaced with the ciphertext blob +5. Entry saved normally (L1 encryption wraps the whole thing, L2 fields are ciphertext-within-ciphertext) + +Alternative (simpler): use NaCl `crypto_box_seal` (X25519 + XSalsa20-Poly1305). One function call, well-understood, available in tweetnacl-js and Go. + +## MCP Read Flow (Agent) + +1. Agent sends request with MCP token (auth half only) +2. Server decrypts entry with L1 key (as today) +3. Server returns entry — L2 field values are opaque ciphertext blobs +4. L3 field values are `"[L3 — requires hardware key]"` (as today's L2 redaction) +5. Agent decrypts L2 fields locally with its L2 private key + +## Import Flow + +Import already requires a browser session (LLM-powered import UI). User has already authenticated with WebAuthn. PRF is available. + +1. Import parses incoming data, auto-detects L2 fields via `l2labels.go` +2. Browser encrypts L2 fields with L2 public key before sending to server +3. Server stores encrypted blobs. Never sees plaintext. + +## Database Schema Changes + +### Modified: `mcp_tokens` + +```sql +ALTER TABLE mcp_tokens ADD COLUMN l2_public_key BLOB; +-- Not strictly needed (all agents share the same L2 public key, stored at vault level) +-- But useful if we ever want per-agent L2 keys in the future +``` + +Actually — since all agents share one L2 keypair, the public key should be vault-level: + +```sql +-- New vault-level config (or add to existing config mechanism) +-- Store the L2 public key once +ALTER TABLE ... ADD COLUMN l2_public_key BLOB; -- 32 bytes, X25519 +``` + +### Modified: `VaultField` + +```go +type VaultField struct { + Label string `json:"label"` + Value string `json:"value"` // plaintext (L1) or ciphertext blob (L2) + Kind string `json:"kind"` + Section string `json:"section,omitempty"` + Tier int `json:"tier,omitempty"` // 1=L1 (default), 2=agent-encrypted, 3=hardware-only +} +``` + +The `L2 bool` field becomes `Tier int`. Migration: `L2=false` → `Tier=1`, `L2=true` → `Tier=3` (current L2 maps to new L3). + +### No new tables + +No `l2_field_envelopes`. No `l2_key_wraps`. L2 ciphertext lives inline in the VaultField value. Clean. + +## Migration Path + +### Existing entries + +All existing fields are either L1 (server-encrypted) or flagged L2 (which maps to new L3, hardware-only). + +No existing fields need to become new-L2 today. The migration is: +1. Rename `L2 bool` to `Tier int` in types +2. Existing `L2=true` → `Tier=3` +3. Existing `L2=false` → `Tier=1` +4. New L2 tier is opt-in per field going forward + +Fields that *should* be L2 (passwords, API keys, TOTP) can be upgraded by the user through the UI. A "security upgrade" flow in the browser could batch-convert selected L1 fields to L2 (requires PRF session to encrypt). + +## What Breaks + +1. **MCP response format** — L2 fields return ciphertext instead of plaintext. Agents must decrypt. Breaking change for any existing MCP client. +2. **`stripL2Fields()` function** — replaced with tier-aware logic: L2 returns ciphertext, L3 returns redaction string. +3. **MCP token format** — combined token is longer and contains wrapped key. Existing tokens remain valid but can't decrypt L2 (they don't have the key half). Backward compatible for L1 access. +4. **Token creation UI** — now requires WebAuthn tap. +5. **Field model** — `L2 bool` → `Tier int`. All serialization, tests, l2labels.go detection must update. + +## What Doesn't Break + +- L1 encryption (unchanged) +- L3/WebAuthn PRF flow (unchanged, just renamed) +- Entry CRUD (L2 ciphertext is just a string value from the server's perspective) +- Blind indexing, search (operates on titles, which are L1) +- Audit logging (unchanged) +- Scoped tokens, read-only, expiry (unchanged) +- Import detection (l2labels.go still detects sensitive fields, just flags them as Tier 2 or 3) + +## Security Properties + +| Scenario | L1 | L2 | L3 | +|---|---|---|---| +| Database stolen | Readable (with vault key derivation) | Encrypted, worthless | Encrypted, worthless | +| Server process compromised | Readable | Readable (briefly, during L1 decryption of blob containing L2 ciphertext) | Not present | +| Agent compromised | Readable (via MCP) | Readable (has L2 key) | Not present | +| Agent + server compromised | Readable | Readable | Encrypted, worthless | +| Hardware authenticator stolen | Readable | Readable (can derive L2 key) | Readable (can derive L3 key) | + +Wait — "Server process compromised" for L2 says readable. Let's examine: +- Server decrypts L1 blob → sees L2 field values as ciphertext +- Server cannot decrypt that ciphertext (no L2 private key) +- Server returns ciphertext to agent → **L2 is NOT readable by compromised server** + +Corrected: + +| Scenario | L1 | L2 | L3 | +|---|---|---|---| +| Database stolen | Derivable from public key | Worthless ciphertext | Worthless ciphertext | +| Server memory dump | Plaintext (during request) | Ciphertext only | Not present | +| Agent compromised | Via MCP | Decryptable | Not present | +| Hardware key stolen + PIN | Everything | Everything | Everything | + +## Implementation Plan + +### Phase 1: Foundation (day 1) + +- [ ] Rename `L2 bool` → `Tier int` in `VaultField` (types.go) +- [ ] Update all references: l2labels.go (now assigns Tier 2 or 3), handlers, tests +- [ ] Add `l2_public_key BLOB` column to vault config storage +- [ ] Add L2 HKDF derivation branch in webauthn.js (alongside existing L3 derivation) +- [ ] Generate and store L2 public key on first passkey registration +- [ ] Tests for key derivation (L2 and L3 from same PRF output are independent) + +### Phase 2: L2 Encryption (day 2) + +- [ ] Implement L2 field encryption in browser (sealed box or X25519+AES-GCM via tweetnacl-js) +- [ ] Entry save: browser encrypts Tier=2 fields with L2 public key before packing +- [ ] Entry read (browser): decrypt Tier=2 fields with L2 private key (from PRF session) +- [ ] Entry read (MCP): return Tier=2 ciphertext as-is, Tier=3 as redacted string +- [ ] Import flow: encrypt detected L2 fields during import + +### Phase 3: Combined Token (day 3) + +- [ ] Modify token creation: require WebAuthn auth, derive L2 private key +- [ ] Implement token wrapping: `mcp_token || AES-GCM(HKDF(mcp_token), l2_private_key)` +- [ ] Token display: show combined base64url string +- [ ] Agent-side: split combined token, unwrap L2 key, use for decryption +- [ ] Update MCP client code to decrypt L2 fields after receiving response + +### Phase 4: Migration & Polish (day 4) + +- [ ] Data migration: existing `L2=true` → `Tier=3`, `L2=false` → `Tier=1` +- [ ] UI: field tier selector (L1/L2/L3) replacing L2 toggle +- [ ] UI: "upgrade to L2" batch flow for existing L1 passwords/API keys +- [ ] Update all tests +- [ ] Update extension to handle L2 ciphertext + +### Total: ~4 days of focused agent work + +Not 2-3 weeks. The crypto is straightforward (X25519 + AES-GCM, libraries exist for both Go and JS). The schema change is a rename. The hardest part is the browser-side encryption/decryption wiring and the combined token format. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f448d5 --- /dev/null +++ b/Makefile @@ -0,0 +1,109 @@ +# vault1984 — build pipeline +# FIPS 140-3: BoringCrypto via GOEXPERIMENT=boringcrypto +# Requires Go 1.24+ (verified: go1.24.0) +# +# Usage: +# make deploy — build + test + restart everything +# make deploy-app — build + test + restart app only +# make deploy-web — build + restart website only +# make status — check what's running + +GOEXPERIMENT := boringcrypto +export GOEXPERIMENT + +APP_DIR := app +WEB_DIR := website +APP_BIN := $(APP_DIR)/vault1984 +WEB_BIN := $(WEB_DIR)/vault1984-web +APP_ENTRY := ./cmd/vault1984 +WEB_ENTRY := . + +LDFLAGS := -s -w +GOFLAGS := -trimpath + +.PHONY: all app website test clean deploy deploy-app deploy-web \ + restart restart-app restart-web stop stop-app stop-web status verify-fips + +# --- build --- + +all: app website + +app: + cd $(APP_DIR) && go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o vault1984 $(APP_ENTRY) + @echo "built $(APP_BIN) (FIPS)" + +website: + cd $(WEB_DIR) && go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o vault1984-web $(WEB_ENTRY) + @echo "built $(WEB_BIN) (FIPS)" + +# --- test --- + +test: + cd $(APP_DIR) && go test ./api/... -v + +# --- deploy --- + +deploy: all test verify-fips restart + @echo "--- deployed ---" + +deploy-app: app test verify-fips-app restart-app + @echo "--- app deployed ---" + +deploy-web: website verify-fips-web restart-web + @echo "--- website deployed ---" + +# --- verify --- + +verify-fips: verify-fips-app verify-fips-web + +verify-fips-app: + @go version -m $(APP_BIN) | grep -q 'GOEXPERIMENT=boringcrypto' && echo "app: FIPS 140-3 (BoringCrypto) ✓" || { echo "app: BoringCrypto NOT linked ✗"; exit 1; } + +verify-fips-web: + @go version -m $(WEB_BIN) | grep -q 'GOEXPERIMENT=boringcrypto' && echo "web: FIPS 140-3 (BoringCrypto) ✓" || { echo "web: BoringCrypto NOT linked ✗"; exit 1; } + +# --- process management --- + +stop-app: + @pkill -f './vault1984$$' 2>/dev/null || pkill -f 'vault1984/vault1984$$' 2>/dev/null || true + @sleep 0.5 + +stop-web: + @pkill -f 'vault1984-web$$' 2>/dev/null || true + @sleep 0.5 + +stop: stop-app stop-web + +restart-app: stop-app + cd $(APP_DIR) && set -a && . ./.env && set +a && nohup ./vault1984 > /tmp/vault1984.log 2>&1 & + @sleep 1 + @ss -tlnp | grep -q ':1984' && echo "app running on :1984 ✓" || { echo "app failed to start ✗"; cat /tmp/vault1984.log; exit 1; } + +restart-web: stop-web + cd $(WEB_DIR) && nohup ./vault1984-web > /tmp/vault1984-web.log 2>&1 & + @sleep 1 + @ss -tlnp | grep -q ':8099' && echo "website running on :8099 ✓" || { echo "website failed to start ✗"; cat /tmp/vault1984-web.log; exit 1; } + +restart: restart-app restart-web + +status: + @echo "--- processes ---" + @ps aux | grep -E 'vault1984(-web)?$$' | grep -v grep || echo "nothing running" + @echo "--- ports ---" + @ss -tlnp | grep -E ':1984|:8099' || echo "no ports open" + @echo "--- fips ---" + @go version -m $(APP_BIN) 2>/dev/null | grep -q 'GOEXPERIMENT=boringcrypto' && echo "app: FIPS ✓" || echo "app: not built or no FIPS" + @go version -m $(WEB_BIN) 2>/dev/null | grep -q 'GOEXPERIMENT=boringcrypto' && echo "web: FIPS ✓" || echo "web: not built or no FIPS" + +# --- logs --- + +logs-app: + @tail -f /tmp/vault1984.log + +logs-web: + @tail -f /tmp/vault1984-web.log + +# --- clean --- + +clean: + rm -f $(APP_BIN) $(WEB_BIN)