Cryptographic Architecture

Overview & Threat Model

BlindKeep is a zero-knowledge encrypted vault. All encryption and decryption happen exclusively in your browser (via WebAssembly) or mobile app. The server never has access to your plaintext data, decryption keys, or any material to derive them.

Threat model: We assume the server is an honest-but-curious adversary — it faithfully executes the protocol but may inspect all stored data. Even with full database and object storage access, an attacker learns nothing about your plaintext content.

What the server can see (inherent to any server-mediated system):

  • Email addresses (needed for account recovery and will notifications)
  • File sizes in bytes (needed for billing) — new uploads are padded to size buckets
  • Timestamps on database rows
  • Request IP addresses (rate limiting and audit logs)
  • Number of items, grants, and drops per user

Key Hierarchy

Your password never leaves your device. Instead, it's stretched into a high-entropy key, which is then split into two cryptographically independent branches:

Password
    |
    v
Argon2id(password, client_salt)  ->  password_key (256-bit)
  [64 MiB memory, 3 iterations]
    |
    +-- HKDF("vault-auth")  ->  auth_key
    |       '-- Sent to server, hashed again with Argon2id
    |           Server stores only: auth_key_hash
    |
    +-- HKDF("vault-enc")   ->  key_wrapping_key
    |       '-- Wraps/unwraps master_key (never sent to server)
    |
    '-- (password_key is discarded)

master_key (random 256-bit, created at registration)
    |
    +-- HKDF("vault-enc")   ->  enc_key
    |       +-- Encrypts per-item keys
    |       '-- Encrypts your X25519 private key
    |
    '-- HKDF("vault-wrap")  ->  wrap_key (reserved)

The auth path ("vault-auth") is completely separated from the encryption path ("vault-enc") via HKDF domain separation — knowing one reveals nothing about the other.

Items (Notes & Files)

Every item in your vault is encrypted with its own unique random key — per-item key isolation.

  1. A random 256-bit item_key is generated
  2. Your plaintext is encrypted: XChaCha20-Poly1305(item_key, plaintext, aad="item:{userId}")
  3. The item_key itself is wrapped: XChaCha20-Poly1305(enc_key, item_key, aad="wrap:{userId}")
  4. The encrypted blob goes to S3, the wrapped key goes to the database

Associated Authenticated Data (AAD) binds each ciphertext to its owner and context — even with database write access, an attacker cannot swap encrypted data between users.

Sharing (Grants)

Two methods, both zero-knowledge:

X25519 Key Exchange (registered users): A fresh ephemeral keypair computes a shared secret with the recipient's public key. Key-bound HKDF prevents misdirection attacks. The ephemeral key is discarded immediately, providing sender forward secrecy.

Link-Secret (anyone): A random 32-byte secret is embedded in the URL fragment (#), which is never sent to the server per the HTTP specification.

Policy controls include view limits, expiry dates, IP restrictions, and instant revocation.

Digital Will

Designate an heir to receive selected vault items. A random will_key wraps the item keys, and is itself encrypted for the heir using X25519 or a BIP39 mnemonic. A dead man's switch checks in with you on a schedule — miss enough check-ins and the will activates.

Algorithms

AlgorithmParametersUse
XChaCha20-Poly1305256-bit key, 192-bit nonceAll symmetric encryption
X25519Curve25519, 32-byte keysKey agreement for grants and wills
Argon2id64 MiB, t=3, p=1Password stretching, auth key hashing
HKDF-SHA256Domain-separated infoSubkey derivation
Ed25519Strict verificationDocument notarization signatures
PBKDF2-SHA512600,000 iterationsBIP39 mnemonic key derivation

Security Properties

  • AAD context binding: V1 format binds ciphertext to its owner and context, preventing cross-user substitution
  • Key-bound grant wrapping: HKDF salt includes both public keys, preventing key misdirection
  • Domain separation: HKDF info strings ensure subkeys are cryptographically independent
  • Per-item key isolation: Every item uses a unique random 256-bit key
  • Sender forward secrecy: Ephemeral X25519 keypairs are discarded after each grant
  • Key zeroization: Rust #[zeroize(drop)] overwrites key material on drop
  • Anti-enumeration: All public endpoints return plausible fake data for non-existent accounts
  • No plaintext leakage: Server rejects any non-encrypted item types or non-empty metadata
Made & operated in the EU