5.7 KiB
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/<shard>/<p1>.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-<L0> SQLite, writes WL3/<shard>/<p1>.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/<shard>/<p1>.json → has L0, home_pop, credential_id list
6. Server opens clavitor-<L0> 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 <wl3_dir>/<first-byte-hex>/<full-p1-hex>.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:
{
"challenge": "base64(...)",
"credential_id": "base64(...)",
"public_key": "base64(...)",
"master_key": "base64(32 bytes)",
"name": "Primary Passkey",
"authenticator_attachment": "platform"
}
Response:
{
"status": "registered",
"cred_id": "...",
"registered_types": ["platform"]
}
POST /api/auth/login/begin
Request:
{
"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.