System architecture
Overview
Keypsafe is a client-side encrypted vault system for storing crypto wallet secrets (seeds, private keys). Encryption and decryption happen entirely on the user's device, while the backend stores only ciphertext. The server cannot read user secrets even with full database access.
The system has three user-facing surfaces: a web app, a CLI recovery tool, and a wallet SDK that is designed to support any platform — browser extension, mobile app, desktop app — to add encrypted backup and restore without sending plaintext off-device.
Applications
Web app
The primary user interface. Built with Vite + React.
Key routes:
| Route | Purpose |
|---|---|
/login | Supabase auth |
/encrypt | Create and encrypt a new vault |
/decrypt | Decrypt and view a stored vault |
/settings | Manage vaults, reset factors, delete account |
/wallet-bridge | postMessage relay for wallets (unprotected by design) |
All routes except /wallet-bridge are protected by a RequireAuth guard. The bridge is intentionally unguarded — wallets are not user accounts; the bridge's job is to accept encrypted payloads and store them after an auth check.
CLI
A standalone decryption tool with no network dependency. Takes an exported backup JSON and prompts interactively for the password and recovery key (hidden input, never passed as arguments). Intended as a last-resort recovery path if the web app is unavailable.
# Recommended — writes to a file, nothing appears on screen:
node recover.js backup.json --output secret.txt
# Explicit terminal output — clears screen + scrollback after confirmation:
node recover.js backup.json --stdoutSecrets are never accepted via command-line arguments (shell history / ps aux exposure). One of --output or --stdout must be specified; omitting both is an error.
Wallet SDK demos
Fox Wallet and Ghost Wallet are reference integrations showing how a wallet can embed the Keypsafe SDK to offer encrypted seed backup and restore without routing plaintext through a server.
The integration pattern:
- Wallet embeds the Keypsafe SDK
- Wallet encrypts the seed locally — plaintext never leaves the wallet process
- Wallet sends the encrypted vault to the Keypsafe bridge for storage
- On restore, the bridge returns the encrypted vault; the wallet decrypts locally
The current SDK targets JavaScript environments (browser and Node.js). The demos use postMessage as the bridge transport because they run in a browser context. The underlying crypto and vault format are platform-agnostic; native library ports are on the roadmap.
Packages
crypto
Low-level cryptographic primitives. No business logic, no storage calls.
- AES-GCM — authenticated encryption for all envelopes and payloads
- Argon2id — password key derivation (via hash-wasm, single-threaded)
- HKDF-SHA256 — key derivation from shared secrets
- Encoding — base64url, hex, Uint8Array conversions
- Zeroization —
zeroMany()wipes sensitive buffers after use
flows
Orchestrates multi-step encrypt/decrypt operations. Calls crypto primitives in the correct order, manages key lifetimes, zeroes intermediates.
encryptVault()— takes plaintext + factors → returns encrypted vault structuredecryptVault()— takes encrypted vault + factors → returns plaintext, with passkey-first / password-recovery key fallbackbuildPwdpkWrapKey(),unwrapDekWithPasskey(),unwrapDekWithPwdPaper()— factor-specific key operations
platform
Backend abstraction and WebAuthn integration.
StorageAdapterinterface —loadVault,createVault,listVaultssupabaseStorage— concrete implementation; filters by bothvaultIdanduserIdfor defense-in-depth on top of RLScreatePasskey(),getPasskeyPrf()— WebAuthn PRF extension for passkey-based key derivation
sdk
The KeypsafeSDK class. Wires flows, platform, and crypto together for callers.
decryptVault(opts)— decrypt a vault by IDdecryptVaultWith(opts, fn)— decrypt, run a function with the plaintext, auto-zerocreateVault(opts)— create a new vault with both recovery factors
Initialized with a storage adapter: new KeypsafeSDK({ storage }).
utils
Shared types and helpers. Most importantly, the BridgeRequest / BridgeResponse message types that define the wallet ↔ bridge protocol.
Data flow
Encryption (create vault)
User input (plaintext + password)
│
├─ WebAuthn: getPasskeyPrf() → prfOut [user taps authenticator]
├─ Argon2id(password, argon_salt) → kPwd
├─ kPwd || recoveryKey → pwdpkKey
│
└─ encryptVault()
├─ Generate random DEK
├─ HKDF(prfOut, kdf_salt) → wrapKeyPK
│ └─ AES-GCM wrap DEK → pk_envelope
├─ HKDF(pwdpkKey, kdf_salt) → wrapKeyPWDPK
│ └─ AES-GCM wrap DEK → pwdpk_envelope
├─ HKDF(DEK, kdf_salt, "keypsafe/meta/v1") → metaKey
│ └─ AES-GCM encrypt metadata JSON → meta_envelope
└─ HKDF(DEK, kdf_salt, "keypsafe/dek/payload/v1") → payloadKey
└─ AES-GCM encrypt plaintext → payload
│
└─ supabaseStorage.createVault() → persist all ciphertextsPlaintext never leaves the device. The recovery key is generated at signup in a dedicated system vault (hidden from vault listings) and shown to the user on the save-recovery-key page. During wallet backup, the bridge loads the paper key vault internally to construct recovery envelopes — the paper key never crosses the postMessage boundary to the wallet. Users can view the recovery key again at any time in Settings using their passkey.
Decryption (unlock vault)
User selects vault + provides factor
│
├─ supabaseStorage.loadVault(vaultId, userId) → encrypted vault
│
├─ [Passkey path] getPasskeyPrf() → prfOut
│ └─ HKDF(prfOut, kdf_salt) → wrapKeyPK
│ └─ AES-GCM unwrap pk_envelope → DEK
│
└─ [Password+recovery key path] Argon2id + HKDF → wrapKeyPWDPK
└─ AES-GCM unwrap pwdpk_envelope → DEK
│
└─ DEK → decrypt meta_envelope (verify kdf_salt) + decrypt payload → plaintextPasskey is primary method with password+recovery key as the fallback. If both fail, decryption throws.
Wallet SDK flow (backup and restore)
The wallet encrypts locally and sends only ciphertext to the bridge for storage. If the wallet needs the user's passkey PRF to do local crypto, it can request it from the bridge.
Wallet Keypsafe bridge
│ │
├─ PASSKEY_PRF_REQUEST ─────────────────►│ (if wallet needs PRF)
│ ◄────── PASSKEY_PRF_RESULT ───────────┤ bridge prompts user, returns PRF output
│ │
├─ BACKUP_REQUEST ───────────────────────►│ encrypted vault, no plaintext
│ ◄────── BACKUP_RESULT ────────────────┤ vaultId returnedDatabase schema (Supabase)
All vault data lives in a single vault table.
email is the only PII stored. Everything else is either opaque identifiers, ciphertext, or key-derivation parameters.
Identity and metadata
| Column | Type | Purpose |
|---|---|---|
id | UUID | Vault identifier |
user_id | UUID | Owner (FK to auth.users) |
email | text | Owner email — the only PII in this table |
vault_label | text | User-supplied vault name (plaintext) |
credential_id | text | WebAuthn credential ID (base64url) used for the passkey PRF ceremony |
secret_type | text | Vault classification — paper_key marks the system recovery vault; other values are wallet/user-defined |
vault_source | text | Origin of the vault — keypsafe for system vaults created by the web app |
Payload
| Column | Type | Purpose |
|---|---|---|
payload_ciphertext | bytea | AES-GCM ciphertext of the user's secret |
payload_nonce | bytea | AES-GCM nonce for payload_ciphertext |
payload_version | int | Envelope format version |
Passkey envelope (pk_*)
DEK wrapped with a key derived from the passkey PRF output.
| Column | Type | Purpose |
|---|---|---|
pk_envelope_ciphertext | bytea | AES-GCM-wrapped DEK |
pk_envelope_nonce | bytea | AES-GCM nonce |
pk_envelope_aad | bytea | AAD — binds this envelope to userId + vaultId |
pk_envelope_version | int | Envelope format version |
Password+recovery key envelope (pwdpk_*)
DEK wrapped with a key derived from the password and recovery key. Independent of the passkey path.
| Column | Type | Purpose |
|---|---|---|
pwdpk_envelope_ciphertext | bytea | AES-GCM-wrapped DEK |
pwdpk_envelope_nonce | bytea | AES-GCM nonce |
pwdpk_envelope_aad | bytea | AAD — binds this envelope to userId + vaultId |
pwdpk_envelope_version | int | Envelope format version |
Metadata envelope (meta_*)
Encrypted vault metadata. Contains the recovery key (paper key) in plaintext within the ciphertext, so subsequent vaults can recover it via passkey without asking the user again.
| Column | Type | Purpose |
|---|---|---|
meta_envelope_ciphertext | bytea | AES-GCM-encrypted metadata JSON |
meta_envelope_nonce | bytea | AES-GCM nonce |
meta_envelope_aad | bytea | AAD — binds this envelope to userId + vaultId |
meta_envelope_version | int | Envelope format version |
Key-derivation parameters
| Column | Type | Purpose |
|---|---|---|
kdf_salt | bytea | Per-vault HKDF salt (used when deriving all wrapping keys) |
argon_salt | bytea | Per-vault Argon2id salt |
argon_time | int | Argon2id time cost |
argon_mem_mib | int | Argon2id memory cost (MiB) |
argon_parallelism | int | Argon2id parallelism |
argon_version | int | Argon2id algorithm version |
Versioning and timestamps
| Column | Type | Purpose |
|---|---|---|
suite | int | Crypto suite version for the whole vault |
aad_version | int | AAD construction version |
created_at | timestamptz | Row creation time |
updated_at | timestamptz | Last row update time |
last_decrypted_at | timestamptz | Set each time the vault is successfully decrypted |
AAD binds each envelope to a specific userId + vaultId, preventing ciphertext transplant attacks.
RLS (user_id = auth.uid()) is the primary access guard. The storage adapter also filters by userId in every query as defense-in-depth.
Key design decisions
Client-side only encryption. The server stores ciphertext. Keypsafe/Supabase never sees plaintext, DEKs, or wrapping keys.
Two independent recovery factors. Passkey (hardware-backed PRF) and password+recovery key are separate wrapping paths for the same DEK. Losing one factor does not mean losing the vault.
Recovery key is vault-scoped but user-shared. Keypsafe generates the first vault (hidden) to generate a recovery key. Subsequent vaults recover it from the first vault's metadata via the passkey. This keeps the recovery UX simple (one recovery key to back up) without weakening the per-vault key hierarchy.
Passkey PRF, not passkey signature. The WebAuthn PRF extension returns a deterministic output tied to the credential and a salt. This output is used as key material, not as an authentication proof, so the passkey acts as a hardware key derivation function.
Versioned envelopes. Every ciphertext has a _version column. The crypto suite is versioned at the vault level. Migration paths for algorithm upgrades are built into the schema.