Kimosabe.AI

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 (no O/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 relevant conversation_members row.
  • Roles: stored in user_roles. All policies that check roles call public.has_role(auth.uid(), 'admin'), a SECURITY DEFINER function with a fixed search_path. Roles are never stored on profiles to 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:

  1. messages rows authored by the user
  2. conversation_members rows for the user
  3. wrapped_keys
  4. user_roles
  5. profiles
  6. The auth.users row via admin.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.