Kimosabe.AI

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

  1. Browser generates ECDH P-256 keypair.
  2. Browser generates a 24-char recovery code.
  3. Browser wraps the private key twice (under password, under recovery code).
  4. Browser calls supabase.auth.signUp(email, password).
  5. Browser inserts profiles (with identity_pubkey) and wrapped_keys (both wrapped blobs).
  6. UI shows the recovery code once with a download/copy step.

Login

  1. supabase.auth.signInWithPassword.
  2. Fetch wrapped_keys row for the user.
  3. Unwrap private key with the entered password (PBKDF2 → AES-GCM unwrap).
  4. 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/):

  1. 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.)
  2. Client unwraps private key with the recovery code in the browser.
  3. Client re-wraps it under the new password.
  4. complete: client sends { email, new_password, pwd_blob }. Server uses admin API to set the new auth password and UPDATE 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

  1. Resolve recipient identity_pubkey from profiles.
  2. Derive shared secret via ECDH; HKDF to a 32-byte key; AES-GCM-256 encrypt body.
  3. INSERT INTO messages (conversation_id, sender_id, ciphertext, nonce, header, expires_at).

Receive

  1. Subscribe via Supabase Realtime to messages filtered by conversation_id IN (my conversations).
  2. For each row: derive the same shared secret; AES-GCM decrypt.
  3. Cache decrypted text in IndexedDB for offline thread reading. Never on the server.

Disappearing messages

  • expires_at is 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.webmanifest declares the app for installation.
  • public/icon-512.jpg is 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 createServerFn RPCs or supabase-js directly.
  • Server-only modules use *.server.ts so the bundler keeps them out of client builds.
  • Auth-protected createServerFn calls use the requireSupabaseAuth middleware in src/integrations/supabase/auth-middleware.ts.
  • All new database changes go through Lovable migrations — never manual SQL in production.