Architecture
This document describes how Kimosabe.AI is built end-to-end: the runtime, the data model, the crypto layer, and the edge functions. It is intended for engineers and security reviewers.
Runtime topology
┌─────────────────────────┐ ┌──────────────────────────────┐
│ Browser (PWA) │ HTTPS │ Cloudflare Workers (edge) │
│ - WebCrypto │ ──────▶ │ - TanStack Start SSR │
│ - IndexedDB cache │ │ - Server functions (RPC) │
│ - React 19 / Tanstack │ └──────────────┬───────────────┘
└──────────┬──────────────┘ │
│ supabase-js (Realtime over WebSocket) │
│ ▼
│ ┌────────────────────────────┐
└─────────────────────────▶│ Lovable Cloud (Supabase) │
│ - Postgres + RLS │
│ - Auth (email + password) │
│ - Storage (attachments) │
│ - Edge Functions (Deno) │
│ - Realtime │
└────────────────────────────┘
Hosting: Cloudflare Workers via Lovable. SSR runs at the edge, but no private user data ever decrypts there — the SSR layer only sees ciphertext.
Database: Supabase Postgres, US region, RLS on every table.
Frontend
TanStack Start v1 with file-based routing in src/routes/. Key files:
| Path | Purpose |
|---|---|
src/routes/__root.tsx |
html/head/body shell, providers, toaster, PWA meta |
src/routes/index.tsx |
Landing page |
src/routes/login.tsx / signup.tsx |
Auth + identity keypair generation |
src/routes/forgot-password.tsx |
Recovery-key flow (passwordless) + email link fallback |
src/routes/trust.tsx |
Public threat model |
src/routes/_authenticated.tsx |
Auth guard via beforeLoad + supabase.auth.getUser() |
src/routes/_authenticated/home.tsx |
Conversation list |
src/routes/_authenticated/c.$id.tsx |
Thread view + composer + TTL toggle |
src/routes/_authenticated/new.tsx |
Username search + start chat |
src/routes/_authenticated/settings.tsx |
Change password, rotate recovery key, export identity, delete account |
Styling lives in src/styles.css using Tailwind v4 native CSS (@import "tailwindcss" + @theme blocks). All colors are oklch design tokens — no raw hex in components.
Database schema
All tables have RLS enabled. Policies use a SECURITY DEFINER has_role() function and conversation-membership checks.
| Table | Purpose |
|---|---|
profiles |
user_id (PK → auth.users), username, identity_pubkey, created_at. Public read on username + identity_pubkey only via a security-definer view. |
wrapped_keys |
user_id (PK), pwd_wrapped_key, pwd_wrap_iv, pwd_kdf_salt, pwd_kdf_iters (password-wrapped private key). Recovery columns: rec_wrapped_key, rec_wrap_iv, rec_kdf_salt, rec_kdf_iters. Self-only access. |
conversations |
id (PK), created_at. |
conversation_members |
(conversation_id, user_id) composite PK, joined_at. RLS: members only. |
messages |
id, conversation_id, sender_id, ciphertext (bytea), nonce, header, expires_at, created_at. RLS: visible only to conversation members. |
attachments |
id, message_id, storage_path, wrapped_key, nonce. RLS via parent message. |
user_roles |
Separate table + has_role() SECURITY DEFINER function. Scaffolded for moderation later. |
Why a separate wrapped_keys table: keeps the encrypted private key out of joins on profiles, and makes recovery-key rotation atomic.
Crypto layer (src/lib/crypto.ts)
Everything runs in the browser with WebCrypto. No third-party crypto code in the hot path.
| Concern | Algorithm |
|---|---|
| Identity keypair | ECDH P-256 (generateKey) |
| Per-message body | ECDH → HKDF-SHA-256 → AES-GCM-256 |
| Private key wrapping | PBKDF2(SHA-256, 600,000 iters, 16-byte salt) → AES-GCM-256 |
| Recovery code | 24 base32 chars in 6 groups of 4 = ~120 bits entropy; alphabet excludes O/0/I/1 |
| Attachment encryption | Per-file random AES-GCM-256 key, key then wrapped to recipient |
| Randomness | crypto.getRandomValues only |
Key storage:
- Public key →
profiles.identity_pubkey(readable by anyone for envelope encryption). - Private key → wrapped twice and stored in
wrapped_keys:pwd_*columns: wrapped under PBKDF2(password)rec_*columns: wrapped under PBKDF2(recovery code)
A password change re-wraps pwd_* only. A recovery-key rotation re-wraps rec_* only. The plaintext private key never leaves the browser.
Auth + recovery flows
Signup
- Browser generates ECDH P-256 keypair.
- Browser generates a 24-char recovery code.
- Browser wraps the private key twice (under password, under recovery code).
- Browser calls
supabase.auth.signUp(email, password). - Browser inserts
profiles(withidentity_pubkey) andwrapped_keys(both wrapped blobs). - UI shows the recovery code once with a download/copy step.
Login
supabase.auth.signInWithPassword.- Fetch
wrapped_keysrow for the user. - Unwrap private key with the entered password (PBKDF2 → AES-GCM unwrap).
- Hold the unwrapped CryptoKey in memory only; never persist.
Recovery (passwordless via recovery key)
Implemented as a zero-knowledge two-phase edge function (supabase/functions/recover-with-key/):
init: client sends{ email }. Server admin-looks-up the user, returns{ rec_wrapped_key, rec_wrap_iv, rec_kdf_salt, rec_kdf_iters }. (Always returns a generic envelope to prevent email enumeration.)- Client unwraps private key with the recovery code in the browser.
- Client re-wraps it under the new password.
complete: client sends{ email, new_password, pwd_blob }. Server uses admin API to set the new auth password andUPDATE wrapped_keys SET pwd_* = ....
The server never sees the recovery code or the unwrapped key.
Account deletion (supabase/functions/delete-account/)
JWT-protected (verify_jwt = true in supabase/config.toml). Verifies the caller, then with the service role: deletes messages, conversation_members, wrapped_keys, user_roles, profiles, then auth.admin.deleteUser(). Storage objects in the user's prefix are purged in the same call.
Messaging flow
Send
- Resolve recipient
identity_pubkeyfromprofiles. - Derive shared secret via ECDH; HKDF to a 32-byte key; AES-GCM-256 encrypt body.
INSERT INTO messages (conversation_id, sender_id, ciphertext, nonce, header, expires_at).
Receive
- Subscribe via Supabase Realtime to
messagesfiltered byconversation_id IN (my conversations). - For each row: derive the same shared secret; AES-GCM decrypt.
- Cache decrypted text in IndexedDB for offline thread reading. Never on the server.
Disappearing messages
expires_atis set on insert based on per-conversation TTL (off / 1d / 7d).- A pg_cron job (Phase 1.1) deletes expired rows server-side; clients also hide them locally.
Edge functions
| Function | Auth | Purpose |
|---|---|---|
delete-account |
verify_jwt = true |
Wipes user data + auth row |
recover-with-key |
verify_jwt = false |
Two-phase passwordless recovery (zero-knowledge) |
Edge functions deploy automatically when committed. Configuration lives in supabase/config.toml.
PWA
public/manifest.webmanifestdeclares the app for installation.public/icon-512.jpgis the Metatron's Cube logo.- Service worker (Phase 1.2) will cache decrypted threads in IndexedDB and queue sends while offline.
Coding conventions
- Routes only call typed
createServerFnRPCs orsupabase-jsdirectly. - Server-only modules use
*.server.tsso the bundler keeps them out of client builds. - Auth-protected
createServerFncalls use therequireSupabaseAuthmiddleware insrc/integrations/supabase/auth-middleware.ts. - All new database changes go through Lovable migrations — never manual SQL in production.
