# Clavitor Vault Login — Simplified Flow **Updated:** April 7, 2026 **Status:** P1 lookup over filesystem-backed WL3 --- ## The model ``` User → Vault (local WL3 files) → SQLite vault data ``` Login is a P1 lookup against a small JSON file on disk. No central round-trip, no SQLite credential index — just `WL3//.json`. --- ## Key tiers — L vs P There are two parallel families derived from the same WebAuthn PRF output: | Tier | L (encryption key, secret) | P (lookup token, public-ish) | Bytes | |------|----------------------------|------------------------------|-------| | 0 | L0 — vault file routing | — | 4 | | 1 | L1 — server encryption key | **P1 — WL3 lookup index** | 8 | | 2 | L2 — agent decryption key | — | 16 | | 3 | L3 — hardware-only key | — | 32 | - **L0–L3** are derived from the user's L3 master and used to encrypt vault data at progressively higher tiers. Knowing L3 gives you all of L0/L1/L2. - **P1** is derived from the same 32-byte PRF output via HKDF with a distinct info string (`clavitor-p1-v1`). It is the public lookup token for the WL3 file. Knowing P1 leaks no decryption capability — login still requires a valid WebAuthn assertion. P1 ⊥ L1: HKDF with different info strings produces uncorrelated outputs. --- ## Registration (first device) ``` 1. User taps "Create Vault" 2. Browser calls navigator.credentials.create() with PRF extension 3. PRF output → 32-byte master key 4. P1 = HKDF(masterKey, info="clavitor-p1-v1", 8 bytes) 5. L0 = first 4 bytes of masterKey (vault file routing) 6. L1 = first 8 bytes of masterKey (vault data encryption) 7. wrapped_L3 = encrypt(L3, PRF) (browser only) 8. POST /api/auth/register/complete {master_key, credential_id, public_key, wrapped_l3} 9. Server derives P1, creates clavitor- SQLite, writes WL3//.json 10. Done — user is in ``` ## Login (returning user, no localStorage) ``` 1. Browser calls navigator.credentials.get() with PRF extension 2. PRF output → 32-byte master key 3. P1 = HKDF(masterKey, info="clavitor-p1-v1", 8 bytes) 4. POST /api/auth/login/begin {p1} 5. Server reads WL3//.json → has L0, home_pop, credential_id list 6. Server opens clavitor- SQLite, returns WebAuthn challenge 7. Browser does WebAuthn assertion, posts to /api/auth/login/complete 8. Server verifies assertion, opens session 9. Browser unwraps L3 in memory using PRF, derives L1 for vault calls ``` --- ## WL3 file format One file per credential at `//.json`: ```json { "p1": "ab2f7c8d9e1f4a3b", "l0": "12345678", "wrapped_l3": "base64url(...)", "credential_id": "base64url(...)", "public_key": "base64url(...)", "home_pop": "uk1", "created_at": 1712534400 } ``` - **No personal data.** No customer_id, no name, no email. The link from a credential to a human lives only in the central admin DB. WL3 files are GDPR-out-of-scope by design. - **Append-only.** Files are written once at registration and never edited. Atomic write via `.tmp` + rename. - **Sharded** by first byte of P1 (256 directories) for filesystem sanity at scale. ~390k files per shard at 100M users — fine for ext4/xfs. Default location: `./WL3/` relative to the binary's working directory. Override with the `WL3_DIR` environment variable. --- ## API endpoints ### POST /api/auth/register/complete **Request:** ```json { "challenge": "base64(...)", "credential_id": "base64(...)", "public_key": "base64(...)", "master_key": "base64(32 bytes)", "name": "Primary Passkey", "authenticator_attachment": "platform" } ``` **Response:** ```json { "status": "registered", "cred_id": "...", "registered_types": ["platform"] } ``` ### POST /api/auth/login/begin **Request:** ```json { "p1": "ab2f7c8d9e1f4a3b" } ``` **Response:** WebAuthn assertion challenge for the credential(s) bound to that vault. --- ## Security properties | What server sees | What server can do with it | |-----------------------------------|----------------------------| | P1 (8 bytes) | Look up the WL3 file. Nothing else. | | WL3 file contents | Route the login. Cannot decrypt L3. | | WebAuthn public key | Verify assertions. Cannot mint them. | | Attack | Outcome | |-----------------------------------|---------| | Server breach (WL3 corpus stolen) | All blobs encrypted with each user's PRF output. Useless without hardware keys. | | Client XSS | Can steal in-memory L3 during an active session. Requires the user to be unlocked. | | Network MITM | TLS protects in transit. P1 is not secret; observing P1 doesn't help. | | Lost device | Attacker needs the physical authenticator + user verification (PIN/biometric). | | P1 brute force | 2⁶⁴ space. Filesystem lookup is O(1) per attempt; the rate limiter and IP lockout cap attempts. Birthday-safe to ~4B credentials. | --- ## Community vs Commercial **This doc describes the community flow.** A community vault stores its WL3 files locally and never talks to anything else. Backup `data/` and `WL3/` and you can restore the whole vault to a fresh box. **Commercial editions** layer the same WL3 file format with a sync mechanism that mirrors the corpus to every POP via a central distribution hub. Same on-disk schema, same lookup path — only the sync layer differs. See the commercial edition docs for the enrollment token, central seat ledger, and sync cadence.