Security & Threat Model
Kimosabe.AI's value is its trustworthiness. This document is the source of truth for what we protect against, what we don't, and how to report vulnerabilities. The user-facing version is at /trust.
What we protect against
| Threat | Mitigation |
|---|---|
| Server compromise reading message contents | E2EE: AES-GCM-256 with per-conversation keys derived via ECDH P-256 + HKDF. Server stores only ciphertext + nonces. |
| Operator subpoena of message bodies | Same as above — message contents are not decryptable by Kimosabe under any circumstances. |
| Stolen database dump | Private keys are wrapped under PBKDF2(SHA-256, 600k iters) of either the user's password or recovery code. A dump alone is brute-force-only ciphertext. |
| Passive network observers | TLS in transit + ciphertext at rest. |
| Future operator decrypting historical messages | Per-conversation keys + planned ratchet (Phase 2) provide forward secrecy. |
| Credential stuffing | Supabase Auth's HIBP-leaked-password check is enabled; min password length 10. |
| Email enumeration via recovery flow | The recovery edge function returns a generic envelope whether or not the email exists. |
| Privilege escalation via row-level access | RLS on every table; roles stored in a separate user_roles table with a SECURITY DEFINER has_role() function — never on profiles. |
What we do NOT protect against (Phase 1)
We are explicit about this. These are written into the product, not buried.
| Threat | Why not |
|---|---|
| Compromised device (browser malware, evil extension, OS keylogger) | If your endpoint is owned, no app can save you. Plaintext exists in browser memory by necessity. |
| Traffic analysis | The server still sees who talks to whom and when. Sealed-sender is Phase 3. |
| Subpoena of metadata | Sender, recipient, timestamp, and message size are visible to Kimosabe. |
| Grid-down / no internet | This is a cloud product. Off-grid mesh is Phase 4 (native app, not web). |
| Lost password AND lost recovery key | Message history becomes permanently undecryptable. This is a feature, not a bug — there is no master backdoor. |
Cryptographic design
Identity
- Algorithm: ECDH on P-256, generated in the browser at signup with
crypto.subtle.generateKey. - Public key: stored in
profiles.identity_pubkey, world-readable via a security-definer view. - Private key: never leaves the browser unwrapped.
Private key wrapping
The private key is exported as JWK, then wrapped with AES-GCM-256 under a key derived by:
PBKDF2(
password OR recovery_code,
random 16-byte salt,
iterations = 600_000,
hash = SHA-256
) → 32-byte AES key
The wrapped blob and salt are stored in wrapped_keys. Every user has two wrapped blobs:
pwd_*— wrapped under PBKDF2(password)rec_*— wrapped under PBKDF2(recovery code)
A password change re-wraps pwd_*. A recovery-key rotation re-wraps rec_*. Both happen entirely in the browser — the server only receives the new ciphertext.
Recovery code
- 24 base32 characters in 6 groups of 4.
- Alphabet:
ABCDEFGHJKLMNPQRSTUVWXYZ23456789(noO/0/I/1). - ~120 bits of entropy. Generated client-side with
crypto.getRandomValues. - Shown once at signup with explicit copy/download UI. Never stored server-side in plaintext or hashed form.
Per-message encryption
- Sender derives a shared secret via ECDH(senderPriv, recipientPub).
- HKDF-SHA-256 expands to a 32-byte AES key.
- AES-GCM-256 with a fresh 12-byte nonce per message.
- Stored as
messages.ciphertext(bytea) +messages.nonce(bytea).
Attachments
- Per-file random AES-GCM-256 key.
- File encrypted in the browser, ciphertext uploaded to Supabase Storage.
- Per-file key is wrapped to the recipient's public key and stored in
attachments.wrapped_key.
Randomness
All randomness comes from crypto.getRandomValues. No Math.random anywhere in security-sensitive code.
Recovery flow (zero-knowledge)
Implemented in supabase/functions/recover-with-key/index.ts.
Phase 1 (init):
client → server: { phase: "init", email }
server → client: { rec_wrapped_key, rec_wrap_iv, rec_kdf_salt, rec_kdf_iters }
(or generic "not found" envelope)
[ Browser does the work: ]
- PBKDF2(recovery_code, salt, iters) → AES key
- AES-GCM unwrap → private key (in browser memory only)
- PBKDF2(new_password, fresh_salt, iters) → new AES key
- AES-GCM wrap private key → new pwd_blob
Phase 2 (complete):
client → server: { phase: "complete", email, new_password, pwd_blob }
server: admin.auth.updateUserById(password=new_password)
UPDATE wrapped_keys SET pwd_* = pwd_blob WHERE user_id = ...
server → client: { ok: true }
The server never sees the recovery code or the plaintext private key.
Row-Level Security posture
- Every public-schema table has
ENABLE ROW LEVEL SECURITY. profiles: self-only write; public read of(username, identity_pubkey)via a security-definer view, never the table directly.wrapped_keys: self-only read and write.conversations/conversation_members/messages/attachments: access requires membership in the relevantconversation_membersrow.- Roles: stored in
user_roles. All policies that check roles callpublic.has_role(auth.uid(), 'admin'), aSECURITY DEFINERfunction with a fixedsearch_path. Roles are never stored onprofilesto prevent escalation.
Operational hygiene
- Region: US (Supabase region pinned).
- Logs: Short retention; no message bodies or auth tokens logged.
- Secrets: Service-role key only used in edge functions, never shipped to the client.
- Auto-confirm signups: disabled — users must verify their email before login.
- HIBP password check: enabled in Supabase Auth.
- Anonymous signups: disabled.
- CORS: edge functions allow
*origin only for the recovery and delete-account endpoints, which are explicitly designed to be safe to call from any origin.
Account deletion
supabase/functions/delete-account/index.ts (JWT-required) wipes:
messagesrows authored by the userconversation_membersrows for the userwrapped_keysuser_rolesprofiles- The
auth.usersrow viaadmin.auth.admin.deleteUser()
Conversations the user participated in are not deleted (other members may still want them); but with the user's wrapped_keys gone, none of their messages can ever be decrypted again.
Responsible disclosure
If you've found a vulnerability:
- Email:
security@kimosabe.ai - PGP: (to be published before Phase 2 launch)
- Please give us 30 days before public disclosure.
- Do not test against other users' accounts. Use accounts you control.
We do not currently run a paid bug bounty, but we will publicly credit reporters in this file unless you ask otherwise.
Changelog
- 2026-05-08 — Initial document. Phase 1 MVP posture.
