The user-facing /app route now talks directly to Supabase:
- Supabase Auth handles email/password registration, login, current account lookup, logout, and Google OAuth.
- Supabase Postgres stores user profiles, public keys, encrypted key backups, and ciphertext-only messages.
- Supabase Realtime can refresh the active conversation when new message rows arrive.
- The browser still performs encryption and decryption locally before any message payload is written.
Client-side Supabase configuration:
NEXT_PUBLIC_SUPABASE_URL=https://lptfbgohubujthjnerwm.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key
NEXT_PUBLIC_SUPABASE_PROJECT_REF=lptfbgohubujthjnerwmProduct data tables:
profilesuser_keysprivate_key_backupsmessages
See Docs/10_Supabase_Setup.md for schema, RLS, auth, and MCP setup.
This document outlines the RESTful API endpoints for the current Whispr MVP backend.
These endpoints reflect the implemented authenticated flow with repository-backed storage. Production uses Postgres; local development may use memory or filesystem adapters.
Create a new user account.
-
Request Body:
{ "username": "alice", "password": "plaintext_password_sent_over_tls" } -
Response:
{ "token": "jwt_token_here", "user": { "id": "uuid-1234", "username": "alice", "hasPublicKey": false, "activePublicKeyId": null, "hasPrivateKeyBackup": false } }
Authenticate and return a session token.
- Response:
{ "token": "jwt_token_here", "user": { "id": "uuid-1234", "username": "alice", "hasPublicKey": true, "activePublicKeyId": "sha256-public-key-id", "hasPrivateKeyBackup": true } }
Return the currently authenticated user.
- Auth: Bearer token required
- Response:
{ "user": { "id": "uuid-1234", "username": "alice", "hasPublicKey": true, "activePublicKeyId": "sha256-public-key-id", "hasPrivateKeyBackup": true } }
Set or rotate the user's active public key. The previous public key remains addressable by key id so old messages can still identify which key version was used.
- Auth: Bearer token required
- Request Body:
{ "publicKey": "..." } - Response:
{ "user": { "id": "uuid-1234", "username": "alice", "hasPublicKey": true, "activePublicKeyId": "sha256-public-key-id", "hasPrivateKeyBackup": true } }
Store an encrypted backup of the client's serialized keyring.
- Auth: Bearer token required
- Request Body:
{ "ciphertext": "base64-encrypted-keyring", "salt": "base64-salt", "iv": "base64-iv", "version": "backup-pbkdf2-aes-gcm-v1" } - Response:
{ "backup": { "version": "backup-pbkdf2-aes-gcm-v1", "updatedAt": "2026-04-18T10:00:00Z" } }
The server never receives plaintext private keys. It stores only encrypted backup material.
Retrieve the authenticated user's encrypted keyring backup.
- Auth: Bearer token required
- Response:
{ "backup": { "userId": "uuid-1234", "ciphertext": "base64-encrypted-keyring", "salt": "base64-salt", "iv": "base64-iv", "version": "backup-pbkdf2-aes-gcm-v1", "updatedAt": "2026-04-18T10:00:00Z" } }
Retrieve the active public key for a target user.
- Auth: Bearer token required
- Response:
{ "username": "bob", "publicKey": "...", "keyId": "sha256-public-key-id" }
Retrieve a historical or active public key by deterministic key id.
- Auth: Bearer token required
- Response:
{ "key": { "id": "sha256-public-key-id", "username": "bob", "publicKey": "...", "isActive": false, "revokedAt": null } }
List users for chat discovery.
- Auth: Bearer token required
- Response:
{ "users": [ { "id": "uuid-5678", "username": "bob", "hasPublicKey": true, "activePublicKeyId": "sha256-public-key-id", "hasPrivateKeyBackup": true } ] }
Send an encrypted message payload. The server acts as a blind relay.
- Auth: Bearer token required
- Request Body:
{ "ciphertext": "base64_payload", "nonce": "base64_nonce", "salt": "base64_salt", "version": "p256-hkdf-aes-gcm-v2" }
The server derives the sender from the authenticated session. Clients do not send senderId.
Fetch encrypted messages for the authenticated user's conversation with a peer.
- Auth: Bearer token required
- Response:
{ "conversationId": "conversation-key", "messages": [ { "id": "msg-001", "conversationId": "conversation-uuid", "senderKeyId": "alice-key-id", "receiverKeyId": "bob-key-id", "senderUsername": "alice", "receiverUsername": "bob", "ciphertext": "...", "nonce": "...", "salt": "...", "version": "p256-hkdf-aes-gcm-v2", "createdAt": "2024-04-17T12:00:00Z", "tampered": false } ] }
Intentionally corrupt a stored ciphertext for the demo harness.
- Auth: Bearer token required
- Availability: only when
ENABLE_DEMO_TOOLS=true
Every stored message already has a unique id. That gives us a natural anchor if we want to introduce a chat session layer later.
Recommended distinction:
- JWT auth session: identifies the logged-in client and should remain separate from message storage
- conversation id: identifies the long-lived relationship between two users
- chat session id: identifies one bounded exchange inside a conversation
Recommended approach:
- do not use a message id as the user's auth session token
- if we want sessionized chats, use the first message in a bounded exchange as the
rootMessageId - expose a derived
sessionIdorrootMessageIdin message payloads for grouping
Example future response shape:
{
"conversationId": "user-a:user-b",
"sessionId": "msg-root-001",
"messages": [
{
"id": "msg-root-001",
"sessionId": "msg-root-001",
"senderUsername": "alice",
"receiverUsername": "bob",
"ciphertext": "...",
"nonce": "...",
"createdAt": "2026-04-18T10:00:00Z"
},
{
"id": "msg-reply-002",
"sessionId": "msg-root-001",
"senderUsername": "bob",
"receiverUsername": "alice",
"ciphertext": "...",
"nonce": "...",
"createdAt": "2026-04-18T10:01:00Z"
}
]
}Why this is safer:
- message ids are immutable record ids, which makes them good anchors
- auth sessions rotate and expire, so tying them to message ids would mix unrelated concerns
- this keeps room for future features like per-session key rotation, replay protection, session closing, and judge-friendly demo grouping
Whispr separates account access from message access:
- JWT login grants API/account access.
- Private keys grant ability to decrypt messages.
- Logout clears the JWT session but does not delete local private keys.
- Generating a new key adds it to the local keyring; older private keys are kept for old messages.
- Uploading a public key makes that key active for future incoming messages.
Encrypted backup model:
- The client serializes its keyring locally.
- The client encrypts the serialized keyring with PBKDF2 + AES-GCM using the user's secret.
- The server stores only
{ ciphertext, salt, iv, version }. - On a fresh device, login can fetch the encrypted backup and the client can decrypt it with the same user secret.
Old-chat readability:
- Messages store
senderKeyIdandreceiverKeyId. - The client picks the matching private key from the local/restored keyring.
- If that private key is missing, the UI should show a missing-key state rather than treating it as ciphertext tampering.
Current multi-device boundary:
- Only one active account public key is used for new inbound messages.
- Full per-device recipient fanout is deferred and will require device identities plus per-message recipient envelopes.
| Event | Direction | Description |
|---|---|---|
| socket handshake auth | Client -> Server | Provide bearer-style auth token through Socket.IO auth |
message:receive |
Server -> Client | Relay payload to authenticated participants |
message:tampered |
Server -> Client | Notify participants that demo tampering occurred |
- Payload Validation: Use Zod/Joi to enforce strict schemas.
- Privacy First: The server MUST NOT log ciphertext or any metadata that could be used for fingerprinting.
- Rate Limiting: Protect all endpoints against brute-force and DoS attacks.
- Password Storage: Store only password hashes, never plaintext or reversible encrypted passwords.