clavitor/clavis/clavis-vault/VAULT_LOGIN_SIMPLIFIED.md

5.7 KiB
Raw Blame History

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
  • L0L3 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.