init: vault1984 monorepo skeleton + L2 agent encryption design
This commit is contained in:
commit
bca87231b0
|
|
@ -0,0 +1,10 @@
|
|||
.DS_Store
|
||||
._.DS_Store
|
||||
vault1984
|
||||
*.log
|
||||
app/
|
||||
docs/
|
||||
website/
|
||||
.DS_Store
|
||||
._.DS_Store
|
||||
vault1984
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue