Skip to content

Security Model

ursalock uses a zero-knowledge architecture with passkey-derived encryption keys.

  • Server compromise — Data is encrypted, server has no keys
  • Database leaks — Only encrypted blobs stored
  • Man-in-the-middle — HTTPS + client-side encryption
  • Unauthorized access — JWT auth + user isolation
  • Key recovery requests — No backdoors, no “forgot password”
  • Client-side compromise — Malware on user’s device
  • Passkey theft — Physical access to your unlocked device
  • Passkey provider breach — If iCloud/Google/Proton Pass is compromised
  • 256-bit key size
  • Authenticated encryption (integrity + confidentiality)
  • NIST approved, widely audited
  • Web Crypto API (native browser)

The encryption key (cipherJwk) is derived from your passkey using the PRF extension:

Passkey → WebAuthn PRF → HKDF → cipherJwk (AES-256 key)
  • PRF outputs are deterministic for the same passkey
  • Same passkey = same cipherJwk = same decryption
  • Different passkey = different cipherJwk = can’t decrypt
┌─────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ Passkey → PRF → HKDF → cipherJwk (JWK) │
│ ↓ │
│ Plaintext → AES-256-GCM → Encrypted Blob │
│ │
└────────────────────────┬────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────┐
│ SERVER │
│ │
│ Stores: { userId: opaqueId, blob, updatedAt } │
│ Knows: NOTHING about your data │
│ │
└─────────────────────────────────────────────────┘

Your user identity is a hash of your passkey’s rawId:

opaqueId = SHA-256(credential.rawId)

The server never sees your actual passkey — only this opaque identifier.

DataEncrypted?Notes
opaqueIdHashedSHA-256 of passkey rawId
Vault blobYesAES-256-GCM encrypted
updatedAtNoTimestamp for sync

That’s it. No email, no password hash, no personal info.

  • Read vault contents
  • Recover data without your passkey
  • Decrypt even with full database access
  • Know who you are (opaqueId is opaque)
  • Reset your “password” (there isn’t one)
  • Hardware-backed (Secure Enclave, TPM)
  • Phishing-resistant (bound to origin)
  • No passwords to type or remember
  • PRF extension for key derivation
  • Short-lived access tokens (15 min)
  • Stored in localStorage
  • Used for server API calls
  • Refresh not needed (re-auth with passkey instead)
WhatStored WhereSurvives Refresh
JWTlocalStorage✅ Yes
cipherJwkmemory only❌ No

The cipherJwk is never stored. After page refresh, users must re-authenticate with their passkey to re-derive it.

This is a security feature, not a bug.

Passkeys sync via your passkey provider:

ProviderSyncsSame rawId
iCloud Keychain
Google Password Manager
Proton Pass
Hardware key

If your provider syncs the passkey, you get the same rawId → same opaqueId → same user → same data.

The WebAuthn PRF extension is required for key derivation:

  • Chrome 116+ (August 2023)
  • Safari 17+ (September 2023)
  • Firefox — Not supported yet

On unsupported browsers, passkey auth may work but key derivation won’t.

The crypto uses standard Web APIs:

// Key derivation
const prfOutput = credential.getClientExtensionResults().prf;
const cipherKey = await crypto.subtle.importKey(...);
// Encryption
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
cipherKey,
plaintext
);

No custom crypto. No dependencies for core encryption.

Found an issue?

  1. Do not open a public issue
  2. Email: ndlz@pm.me
  3. Include: description, steps, impact

Response within 48 hours.