Cipher: E2E encrypted messenger scaffold (Signal Protocol + Cloudflare)

- Hono Worker: auth, key directory (X3DH prekey bundles), message relay, R2 media
- Mailbox Durable Object: WebSocket delivery + offline ciphertext queue
- PWA client: libsignal (vendored), IndexedDB key store, chat UI, media E2E
- Server only ever holds public keys + ciphertext; private keys stay on device

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
maverick 2026-07-22 01:56:59 +10:00
commit 360bfeb5ab
22 changed files with 32879 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
.wrangler/
.dev.vars
dist/
*.log
.DS_Store

56
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,56 @@
-- Cipher schema. The server is a "dumb" key directory + relay.
-- It stores ONLY public keys and ciphertext. Private keys never leave the client.
-- A user account. Identified by username; discoverable by other users.
CREATE TABLE users (
id TEXT PRIMARY KEY, -- uuid
username TEXT NOT NULL UNIQUE, -- lowercase handle, e.g. "alice"
display_name TEXT NOT NULL,
pw_hash TEXT NOT NULL, -- PBKDF2(password) — account auth only, unrelated to E2E keys
pw_salt TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_users_username ON users(username);
-- A device belonging to a user. Each device has its own Signal identity.
-- Multi-device: a user may register several (phone, desktop) via QR linking.
CREATE TABLE devices (
id TEXT PRIMARY KEY, -- uuid
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
registration_id INTEGER NOT NULL, -- Signal registrationId
identity_key TEXT NOT NULL, -- base64 public identity key
signed_prekey_id INTEGER NOT NULL,
signed_prekey_pub TEXT NOT NULL, -- base64
signed_prekey_sig TEXT NOT NULL, -- base64 signature over signed_prekey_pub
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE INDEX idx_devices_user ON devices(user_id);
-- One-time prekeys. Server hands one out per session-init request, then deletes it.
CREATE TABLE one_time_prekeys (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
prekey_id INTEGER NOT NULL,
prekey_pub TEXT NOT NULL, -- base64
PRIMARY KEY (device_id, prekey_id)
);
CREATE INDEX idx_otk_device ON one_time_prekeys(device_id);
-- Bearer auth sessions (account-level). Token proves control of the account.
CREATE TABLE sessions (
token TEXT PRIMARY KEY, -- random opaque token
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
-- Web Push subscriptions for wake-on-message notifications (endpoint is opaque).
CREATE TABLE push_subs (
device_id TEXT PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);

1803
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "cipher",
"version": "0.1.0",
"private": true,
"description": "Cipher — end-to-end encrypted messenger (Signal Protocol) on Cloudflare",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:migrate:local": "wrangler d1 migrations apply cipher --local",
"db:migrate": "wrangler d1 migrations apply cipher --remote"
},
"dependencies": {
"hono": "^4.6.14"
},
"devDependencies": {
"@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
"buffer": "^6.0.3",
"esbuild": "^0.28.1",
"wrangler": "^4.96.0"
}
}

9
public/icons/icon.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="112" fill="#0b141a"/>
<path d="M256 96c-79.5 0-144 58.7-144 131 0 41 20.7 77.6 53.2 101.6L152 416l72.6-38.1c10.1 2 20.6 3.1 31.4 3.1 79.5 0 144-58.7 144-131S335.5 96 256 96z" fill="#00a884"/>
<path d="M256 168c-30.9 0-56 22.4-56 50 0 12.2 4.9 23.4 13 32v34l31-19.2c3.9.8 8 1.2 12 1.2 30.9 0 56-22.4 56-50s-25.1-48-56-48z" fill="#0b141a" opacity="0"/>
<g fill="#04140f">
<rect x="214" y="232" width="84" height="60" rx="10"/>
<path d="M232 232v-18a24 24 0 0 1 48 0v18h-16v-18a8 8 0 0 0-16 0v18z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 650 B

98
public/index.html Normal file
View file

@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b141a" />
<title>Cipher — encrypted messaging</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon.svg" />
<style>
:root {
--bg: #0b141a;
--panel: #111b21;
--panel-2: #202c33;
--accent: #00a884;
--accent-2: #005c4b;
--text: #e9edef;
--muted: #8696a0;
--bubble-in: #202c33;
--bubble-out: #005c4b;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--text); height: 100dvh; overflow: hidden;
}
#app { height: 100dvh; }
/* ---- auth ---- */
.auth-card {
max-width: 360px; margin: 12vh auto 0; padding: 32px 24px;
background: var(--panel); border-radius: 16px; display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,.4);
}
.auth-card h1 { margin: 0; text-align: center; color: var(--accent); letter-spacing: .5px; }
.tagline { margin: 0 0 8px; text-align: center; color: var(--muted); font-size: 13px; }
.auth-card input {
padding: 12px 14px; border-radius: 10px; border: 1px solid var(--panel-2);
background: var(--panel-2); color: var(--text); font-size: 15px;
}
.auth-card input:focus { outline: 2px solid var(--accent); }
.auth-err { color: #f15c6d; font-size: 13px; min-height: 16px; text-align: center; }
button { cursor: pointer; font-size: 15px; padding: 12px 14px; border-radius: 10px; border: none; }
button.primary { background: var(--accent); color: #04140f; font-weight: 600; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
button:disabled { opacity: .6; cursor: default; }
/* ---- shell ---- */
.shell { display: grid; grid-template-columns: 340px 1fr; height: 100dvh; }
.sidebar { background: var(--panel); border-right: 1px solid #0a0f13; display: flex; flex-direction: column; min-height: 0; }
.side-header { padding: 16px; border-bottom: 1px solid #0a0f13; }
.me { font-weight: 600; }
.handle { color: var(--muted); font-size: 13px; }
.new-chat { margin: 12px; background: var(--panel-2); color: var(--text); text-align: left; }
.chat-list { overflow-y: auto; flex: 1; }
.chat-item { display: flex; gap: 12px; padding: 12px 16px; cursor: pointer; align-items: center; }
.chat-item:hover { background: var(--panel-2); }
.avatar {
width: 42px; height: 42px; border-radius: 50%; background: var(--accent-2);
display: grid; place-items: center; font-weight: 700; color: var(--text); flex: none;
}
.chat-name { font-weight: 500; }
.chat-sub { color: var(--muted); font-size: 13px; }
/* ---- main ---- */
.main { display: flex; flex-direction: column; min-height: 0; background:
linear-gradient(rgba(11,20,26,.95), rgba(11,20,26,.95)); }
.empty { margin: auto; color: var(--muted); }
.chat-head { display: flex; gap: 12px; align-items: center; padding: 12px 16px; background: var(--panel); border-bottom: 1px solid #0a0f13; }
.thread { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 6px; }
.bubble { max-width: 65%; padding: 8px 10px 6px; border-radius: 10px; font-size: 14.5px; line-height: 1.35; position: relative; word-wrap: break-word; }
.bubble.in { background: var(--bubble-in); align-self: flex-start; border-top-left-radius: 2px; }
.bubble.out { background: var(--bubble-out); align-self: flex-end; border-top-right-radius: 2px; }
.bubble .time { font-size: 10.5px; color: var(--muted); text-align: right; margin-top: 2px; }
.media-img { max-width: 260px; border-radius: 8px; display: block; }
.media-file { color: var(--text); text-decoration: none; }
.composer { display: flex; gap: 10px; padding: 12px 16px; background: var(--panel); align-items: center; }
.composer input[type=text], .composer input:not([type]) {
flex: 1; padding: 11px 14px; border-radius: 20px; border: none; background: var(--panel-2); color: var(--text); font-size: 15px;
}
.composer input:focus { outline: none; }
.attach { cursor: pointer; font-size: 20px; user-select: none; }
.send { background: var(--accent); color: #04140f; font-weight: 600; border-radius: 20px; }
@media (max-width: 720px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.shell.chat-open .sidebar { display: none; }
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/js/app.js"></script>
</body>
</html>

108
public/js/api.js Normal file
View file

@ -0,0 +1,108 @@
// Thin client for the Cipher Worker API + the mailbox WebSocket.
let token = null;
export function setToken(t) {
token = t;
}
async function req(method, path, body, raw = false) {
const headers = {};
if (token) headers["Authorization"] = "Bearer " + token;
const opts = { method, headers };
if (body !== undefined) {
if (raw) {
opts.body = body; // ArrayBuffer / Blob
} else {
headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
}
const res = await fetch(path, opts);
if (!res.ok) {
let msg = res.statusText;
try {
msg = (await res.json()).error || msg;
} catch {}
throw new Error(msg);
}
const ct = res.headers.get("Content-Type") || "";
return ct.includes("application/json") ? res.json() : res;
}
export const api = {
register: (payload) => req("POST", "/api/auth/register", payload),
login: (payload) => req("POST", "/api/auth/login", payload),
lookup: (username) => req("GET", "/api/users/" + encodeURIComponent(username)),
bundle: (userId) => req("GET", "/api/keys/bundle/" + userId),
keyCount: () => req("GET", "/api/keys/count"),
addPreKeys: (oneTimePreKeys) => req("POST", "/api/keys/prekeys", { oneTimePreKeys }),
send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }),
uploadMedia: async (ciphertext) => {
const res = await req("POST", "/api/media", ciphertext, true);
return res.json ? res.json() : res;
},
fetchMedia: async (key) => {
const res = await req("GET", "/api/media/" + key);
return res.arrayBuffer();
},
};
// ---- WebSocket with auto-reconnect + ack ----
export class MailboxSocket {
constructor(tok, onEnvelope) {
this.tok = tok;
this.onEnvelope = onEnvelope;
this.ws = null;
this.backoff = 1000;
this.closed = false;
this.pending = []; // envelope ids awaiting ack batch
}
connect() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
this.ws = new WebSocket(`${proto}//${location.host}/api/ws?token=${encodeURIComponent(this.tok)}`);
this.ws.onopen = () => {
this.backoff = 1000;
this.ws.send(JSON.stringify({ t: "pull" }));
};
this.ws.onmessage = async (ev) => {
let msg;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
if (msg.t === "message" && msg.envelope) {
try {
await this.onEnvelope(msg.envelope);
this.ack(msg.envelope.id);
} catch (e) {
console.error("envelope handling failed", e);
}
}
};
this.ws.onclose = () => {
if (this.closed) return;
setTimeout(() => this.connect(), this.backoff);
this.backoff = Math.min(this.backoff * 2, 30000);
};
this.ws.onerror = () => this.ws && this.ws.close();
}
ack(id) {
// Batch acks briefly to reduce chatter.
this.pending.push(id);
clearTimeout(this._ackTimer);
this._ackTimer = setTimeout(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.pending.length) {
this.ws.send(JSON.stringify({ t: "ack", ids: this.pending }));
this.pending = [];
}
}, 150);
}
close() {
this.closed = true;
if (this.ws) this.ws.close();
}
}

392
public/js/app.js Normal file
View file

@ -0,0 +1,392 @@
// Cipher client orchestration: auth, contacts, chat, media. Ties crypto + api + UI.
import { api, setToken, MailboxSocket } from "./api.js";
import * as C from "./crypto.js";
import { meta, contacts, messages as msgStore } from "./store.js";
const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...children) => {
const n = Object.assign(document.createElement(tag), props);
for (const c of children) n.append(c);
return n;
};
const state = {
me: null, // { userId, deviceId, username, displayName }
token: null,
socket: null,
activeContact: null, // contact object
};
// ---------- boot ----------
async function boot() {
const saved = await meta.get("account");
if (saved?.token) {
state.me = saved;
state.token = saved.token;
setToken(saved.token);
await enterApp();
} else {
renderAuth();
}
}
// ---------- auth ----------
function renderAuth() {
const root = $("#app");
root.innerHTML = "";
const form = el("form", { className: "auth-card" });
const title = el("h1", { textContent: "Cipher" });
const tag = el("p", { className: "tagline", textContent: "end-to-end encrypted messaging" });
const err = el("div", { className: "auth-err" });
const username = el("input", { placeholder: "username", autocomplete: "username", required: true });
const displayName = el("input", { placeholder: "display name (register only)", autocomplete: "name" });
const password = el("input", { type: "password", placeholder: "password", autocomplete: "current-password", required: true });
const loginBtn = el("button", { type: "submit", className: "primary", textContent: "Log in" });
const registerBtn = el("button", { type: "button", className: "ghost", textContent: "Create account" });
form.append(title, tag, err, username, displayName, password, loginBtn, registerBtn);
root.append(form);
form.onsubmit = async (e) => {
e.preventDefault();
err.textContent = "";
try {
loginBtn.disabled = true;
const res = await api.login({ username: username.value, password: password.value });
await onAuthenticated(res);
} catch (ex) {
err.textContent = ex.message;
} finally {
loginBtn.disabled = false;
}
};
registerBtn.onclick = async () => {
err.textContent = "";
if (!username.value || password.value.length < 8) {
err.textContent = "username + 8-char password required";
return;
}
try {
registerBtn.disabled = true;
registerBtn.textContent = "Generating keys…";
const device = await C.generateRegistration();
const res = await api.register({
username: username.value,
displayName: displayName.value || username.value,
password: password.value,
device,
});
await onAuthenticated(res);
} catch (ex) {
err.textContent = ex.message;
} finally {
registerBtn.disabled = false;
registerBtn.textContent = "Create account";
}
};
}
async function onAuthenticated(res) {
state.me = res;
state.token = res.token;
setToken(res.token);
await meta.set("account", res);
await enterApp();
}
// ---------- main app ----------
async function enterApp() {
renderShell();
await refreshContacts();
connectSocket();
maybeReplenishPreKeys();
registerServiceWorker();
}
function renderShell() {
const root = $("#app");
root.innerHTML = "";
const shell = el("div", { className: "shell" });
// Sidebar
const sidebar = el("aside", { className: "sidebar" });
const header = el("div", { className: "side-header" });
header.append(
el("div", { className: "me", textContent: state.me.displayName || state.me.username }),
el("div", { className: "handle", textContent: "@" + state.me.username })
);
const newChat = el("button", { className: "new-chat", textContent: "+ New chat", onclick: startNewChat });
const list = el("div", { className: "chat-list", id: "chatList" });
sidebar.append(header, newChat, list);
// Main pane
const main = el("section", { className: "main", id: "main" });
main.append(el("div", { className: "empty", textContent: "Select a chat or start a new one" }));
shell.append(sidebar, main);
root.append(shell);
}
async function refreshContacts() {
const list = $("#chatList");
if (!list) return;
list.innerHTML = "";
const all = await contacts.all();
for (const c of all) {
const item = el("div", { className: "chat-item", onclick: () => openChat(c) });
item.append(
el("div", { className: "avatar", textContent: (c.displayName || c.username)[0].toUpperCase() }),
el("div", { className: "chat-meta" },
el("div", { className: "chat-name", textContent: c.displayName || c.username }),
el("div", { className: "chat-sub", textContent: "@" + c.username })
)
);
list.append(item);
}
}
async function startNewChat() {
const username = prompt("Username to message:");
if (!username) return;
try {
const user = await api.lookup(username.trim().toLowerCase());
const contact = {
userId: user.userId,
username: user.username,
displayName: user.displayName,
deviceIds: [],
};
await contacts.put(contact);
await refreshContacts();
await openChat(contact);
} catch (ex) {
alert("Could not find user: " + ex.message);
}
}
// ---------- chat view ----------
async function openChat(contact) {
state.activeContact = contact;
const main = $("#main");
main.innerHTML = "";
const head = el("div", { className: "chat-head" });
head.append(
el("div", { className: "avatar", textContent: (contact.displayName || contact.username)[0].toUpperCase() }),
el("div", {},
el("div", { className: "chat-name", textContent: contact.displayName || contact.username }),
el("div", { className: "chat-sub", textContent: "🔒 end-to-end encrypted" })
)
);
const thread = el("div", { className: "thread", id: "thread" });
const composer = el("form", { className: "composer" });
const input = el("input", { placeholder: "Type a message", autocomplete: "off" });
const attach = el("label", { className: "attach", textContent: "📎" });
const fileInput = el("input", { type: "file", style: "display:none" });
attach.append(fileInput);
const sendBtn = el("button", { type: "submit", className: "send", textContent: "Send" });
composer.append(attach, input, sendBtn);
main.append(head, thread, composer);
// load history
const convo = state.me.userId + ":" + contact.userId;
const history = await msgStore.forConvo(convo);
for (const m of history) renderMessage(m);
scrollThread();
composer.onsubmit = async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
input.value = "";
await sendText(contact, text);
};
fileInput.onchange = async () => {
const file = fileInput.files[0];
if (file) await sendMedia(contact, file);
fileInput.value = "";
};
}
function convoKey(otherUserId) {
return state.me.userId + ":" + otherUserId;
}
async function ensureRecipientDevices(contact) {
const bundle = await api.bundle(contact.userId);
contact.deviceIds = bundle.bundles.map((b) => b.deviceId);
await contacts.put(contact);
await C.ensureSessions(bundle);
return contact.deviceIds;
}
async function sendText(contact, text) {
// structured payload lets us carry media refs in the same E2E channel
const payload = JSON.stringify({ k: "text", text });
await deliver(contact, payload);
await persistAndRender(contact.userId, { dir: "out", kind: "text", text, ts: Date.now() });
}
async function sendMedia(contact, file) {
const buf = await file.arrayBuffer();
const enc = await C.encryptMedia(buf);
const { key } = await api.uploadMedia(enc.ciphertext);
const payload = JSON.stringify({
k: "media",
key,
keyB64: enc.keyB64,
ivB64: enc.ivB64,
name: file.name,
type: file.type,
size: file.size,
});
await deliver(contact, payload);
await persistAndRender(contact.userId, {
dir: "out",
kind: "media",
name: file.name,
mime: file.type,
localUrl: URL.createObjectURL(file),
ts: Date.now(),
});
}
async function deliver(contact, plaintextPayload) {
let deviceIds = contact.deviceIds;
if (!deviceIds || !deviceIds.length) deviceIds = await ensureRecipientDevices(contact);
const messages = await C.encryptForUser(contact.userId, deviceIds, plaintextPayload);
await api.send(contact.userId, messages);
}
// ---------- receiving ----------
function connectSocket() {
if (state.socket) state.socket.close();
state.socket = new MailboxSocket(state.token, onEnvelope);
state.socket.connect();
}
async function onEnvelope(envelope) {
// Look up / create the sender contact.
let contact = await contactByUserId(envelope.fromUserId);
const plaintext = await C.decryptEnvelope(envelope, envelope.fromUserId);
let data;
try {
data = JSON.parse(plaintext);
} catch {
data = { k: "text", text: plaintext };
}
if (!contact) {
// Unknown sender — create a stub contact so the message has a home.
contact = { userId: envelope.fromUserId, username: envelope.fromUserId.slice(0, 8), displayName: "New contact", deviceIds: [] };
await contacts.put(contact);
await refreshContacts();
}
if (data.k === "media") {
await persistAndRender(envelope.fromUserId, {
dir: "in",
kind: "media",
name: data.name,
mime: data.type,
mediaRef: { key: data.key, keyB64: data.keyB64, ivB64: data.ivB64 },
ts: envelope.sentAt || Date.now(),
});
} else {
await persistAndRender(envelope.fromUserId, { dir: "in", kind: "text", text: data.text, ts: envelope.sentAt || Date.now() });
}
}
async function contactByUserId(userId) {
return contacts.get(userId);
}
// ---------- persistence + render ----------
async function persistAndRender(otherUserId, msg) {
const record = { ...msg, convo: convoKey(otherUserId), otherUserId };
await msgStore.add(record);
if (state.activeContact && state.activeContact.userId === otherUserId) {
renderMessage(record);
scrollThread();
}
}
function renderMessage(m) {
const thread = $("#thread");
if (!thread) return;
const bubble = el("div", { className: "bubble " + (m.dir === "out" ? "out" : "in") });
if (m.kind === "media") {
if (m.mime && m.mime.startsWith("image/")) {
const img = el("img", { className: "media-img", alt: m.name || "image" });
if (m.localUrl) img.src = m.localUrl;
else if (m.mediaRef) loadMediaInto(img, m.mediaRef);
bubble.append(img);
} else {
const link = el("a", { className: "media-file", textContent: "📄 " + (m.name || "file") });
if (m.mediaRef) makeDownloadable(link, m.mediaRef, m.name);
bubble.append(link);
}
} else {
bubble.append(el("div", { className: "text", textContent: m.text }));
}
bubble.append(el("div", { className: "time", textContent: fmtTime(m.ts) }));
thread.append(bubble);
}
async function loadMediaInto(img, ref) {
try {
const ct = await api.fetchMedia(ref.key);
const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64);
img.src = URL.createObjectURL(new Blob([plain]));
} catch (e) {
img.alt = "failed to load media";
}
}
async function makeDownloadable(link, ref, name) {
link.onclick = async (e) => {
e.preventDefault();
const ct = await api.fetchMedia(ref.key);
const plain = await C.decryptMedia(ct, ref.keyB64, ref.ivB64);
const url = URL.createObjectURL(new Blob([plain]));
const a = el("a", { href: url, download: name || "file" });
a.click();
};
}
function scrollThread() {
const t = $("#thread");
if (t) t.scrollTop = t.scrollHeight;
}
function fmtTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ---------- prekey replenishment ----------
async function maybeReplenishPreKeys() {
try {
const { count } = await api.keyCount();
if (count < 20) {
const otks = await C.generateMoreOneTimePreKeys(100);
await api.addPreKeys(otks);
}
} catch {}
}
function registerServiceWorker() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}
boot();

170
public/js/crypto.js Normal file
View file

@ -0,0 +1,170 @@
// High-level E2E crypto built on the vendored Signal Protocol library.
// This module is the ONLY place message plaintext exists in the app besides the UI.
import {
KeyHelper,
SignalProtocolAddress,
SessionBuilder,
SessionCipher,
} from "./vendor/libsignal.js";
import { SignalStore } from "./store.js";
export const store = new SignalStore();
// ---- encoding helpers ----
export function abToB64(ab) {
const bytes = new Uint8Array(ab);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
export function b64ToAb(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
// Signal message bodies are binary strings; base64 them for JSON transport.
function binStrToB64(s) {
return btoa(s);
}
function b64ToBinStr(b64) {
return atob(b64);
}
const NUM_ONE_TIME_PREKEYS = 100;
const SIGNED_PREKEY_ID = 1;
// Generate a fresh identity + prekeys and persist private halves locally.
// Returns the PUBLIC registration payload to send to the server.
export async function generateRegistration() {
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
const registrationId = KeyHelper.generateRegistrationId();
await store.setIdentity(identityKeyPair, registrationId);
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SIGNED_PREKEY_ID);
await store.storeSignedPreKey(SIGNED_PREKEY_ID, signedPreKey.keyPair);
const oneTimePreKeys = [];
const startId = 1;
for (let i = 0; i < NUM_ONE_TIME_PREKEYS; i++) {
const id = startId + i;
const pk = await KeyHelper.generatePreKey(id);
await store.storePreKey(id, pk.keyPair);
oneTimePreKeys.push({ id, pub: abToB64(pk.keyPair.pubKey) });
}
return {
registrationId,
identityKey: abToB64(identityKeyPair.pubKey),
signedPreKey: {
id: SIGNED_PREKEY_ID,
pub: abToB64(signedPreKey.keyPair.pubKey),
sig: abToB64(signedPreKey.signature),
},
oneTimePreKeys,
};
}
// Generate a batch of fresh one-time prekeys to replenish the server's supply.
export async function generateMoreOneTimePreKeys(count = 100) {
// Continue numbering past whatever we've used; store a counter in meta-less way:
// derive from a random high base to avoid collisions (server ignores dupes).
const base = 1000 + Math.floor(Math.random() * 1_000_000);
const oneTimePreKeys = [];
for (let i = 0; i < count; i++) {
const id = base + i;
const pk = await KeyHelper.generatePreKey(id);
await store.storePreKey(id, pk.keyPair);
oneTimePreKeys.push({ id, pub: abToB64(pk.keyPair.pubKey) });
}
return oneTimePreKeys;
}
// Establish outbound sessions with every device in a fetched prekey bundle.
export async function ensureSessions(bundlesResponse) {
for (const b of bundlesResponse.bundles) {
const address = new SignalProtocolAddress(bundlesResponse.userId, deviceIndex(b.deviceId));
const existing = await store.loadSession(address.toString());
if (existing) continue;
const builder = new SessionBuilder(store, address);
await builder.processPreKey({
registrationId: b.registrationId,
identityKey: b64ToAb(b.identityKey),
signedPreKey: {
keyId: b.signedPreKey.id,
publicKey: b64ToAb(b.signedPreKey.pub),
signature: b64ToAb(b.signedPreKey.sig),
},
preKey: b.oneTimePreKey
? { keyId: b.oneTimePreKey.id, publicKey: b64ToAb(b.oneTimePreKey.pub) }
: undefined,
});
}
}
// Signal addresses need an integer deviceId. Map our uuid deviceIds to a stable
// small int by keeping a per-user registry in memory (and the session key string
// already encodes the full identity, so uniqueness within a user is all we need).
const deviceIndexMap = new Map();
let deviceCounter = 1;
function deviceIndex(deviceId) {
if (!deviceIndexMap.has(deviceId)) deviceIndexMap.set(deviceId, deviceCounter++);
return deviceIndexMap.get(deviceId);
}
// Encrypt a plaintext string for every device of a recipient. Returns the array
// of per-device messages to POST to /api/messages/send.
export async function encryptForUser(userId, deviceIds, plaintext) {
const buffer = new TextEncoder().encode(plaintext).buffer;
const messages = [];
for (const deviceId of deviceIds) {
const address = new SignalProtocolAddress(userId, deviceIndex(deviceId));
const cipher = new SessionCipher(store, address);
const ct = await cipher.encrypt(buffer);
messages.push({ toDeviceId: deviceId, type: ct.type, body: binStrToB64(ct.body) });
}
return messages;
}
// Decrypt an inbound envelope. Handles both prekey (type 3) and whisper (type 1).
export async function decryptEnvelope(envelope, senderUserId) {
const address = new SignalProtocolAddress(senderUserId, deviceIndex(envelope.fromDeviceId));
const cipher = new SessionCipher(store, address);
const body = b64ToBinStr(envelope.body);
let plaintextBuf;
if (envelope.type === 3) {
plaintextBuf = await cipher.decryptPreKeyWhisperMessage(body, "binary");
} else {
plaintextBuf = await cipher.decryptWhisperMessage(body, "binary");
}
return new TextDecoder().decode(new Uint8Array(plaintextBuf));
}
// ---- media encryption (AES-GCM, key travels inside the E2E message) ----
export async function encryptMedia(fileArrayBuffer) {
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
"encrypt",
"decrypt",
]);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, fileArrayBuffer);
const rawKey = await crypto.subtle.exportKey("raw", key);
return {
ciphertext, // ArrayBuffer to upload to R2
keyB64: abToB64(rawKey),
ivB64: abToB64(iv.buffer),
};
}
export async function decryptMedia(ciphertext, keyB64, ivB64) {
const key = await crypto.subtle.importKey(
"raw",
b64ToAb(keyB64),
{ name: "AES-GCM" },
false,
["decrypt"]
);
const iv = new Uint8Array(b64ToAb(ivB64));
return crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
}

161
public/js/store.js Normal file
View file

@ -0,0 +1,161 @@
// IndexedDB persistence: the Signal protocol store + app data (contacts, chats).
// Private keys live ONLY here, in the browser. They are never sent to the server.
const DB_NAME = "cipher";
const DB_VERSION = 1;
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
// Signal protocol material (identity keypair, prekeys, sessions, peer identities).
if (!db.objectStoreNames.contains("signal")) db.createObjectStore("signal");
// App metadata (my account, settings).
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
// Known contacts, keyed by userId.
if (!db.objectStoreNames.contains("contacts")) db.createObjectStore("contacts", { keyPath: "userId" });
// Message history, keyed by autoincrement, indexed by conversation.
if (!db.objectStoreNames.contains("messages")) {
const s = db.createObjectStore("messages", { keyPath: "id", autoIncrement: true });
s.createIndex("convo", "convo");
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
let _db;
async function db() {
if (!_db) _db = await openDB();
return _db;
}
function tx(store, mode = "readonly") {
return db().then((d) => d.transaction(store, mode).objectStore(store));
}
function reqP(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// ---- generic KV helpers over the "signal" and "meta" stores ----
async function kvGet(store, key) {
return reqP((await tx(store)).get(key));
}
async function kvPut(store, key, val) {
return reqP((await tx(store, "readwrite")).put(val, key));
}
async function kvDel(store, key) {
return reqP((await tx(store, "readwrite")).delete(key));
}
export const meta = {
get: (k) => kvGet("meta", k),
set: (k, v) => kvPut("meta", k, v),
del: (k) => kvDel("meta", k),
};
// ---- contacts ----
export const contacts = {
async put(contact) {
return reqP((await tx("contacts", "readwrite")).put(contact));
},
async get(userId) {
return reqP((await tx("contacts")).get(userId));
},
async all() {
return reqP((await tx("contacts")).getAll());
},
};
// ---- messages ----
export const messages = {
async add(msg) {
return reqP((await tx("messages", "readwrite")).add(msg));
},
async forConvo(convo) {
const idx = (await tx("messages")).index("convo");
return reqP(idx.getAll(convo));
},
};
// ---- Signal protocol store ----
// Implements the StorageType interface libsignal expects. Keys are strings;
// ArrayBuffers and records are stored directly (IndexedDB structured-clones them).
export class SignalStore {
Direction = { SENDING: 1, RECEIVING: 2 };
async getIdentityKeyPair() {
return kvGet("signal", "identityKey");
}
async getLocalRegistrationId() {
return kvGet("signal", "registrationId");
}
async setIdentity(keyPair, registrationId) {
await kvPut("signal", "identityKey", keyPair);
await kvPut("signal", "registrationId", registrationId);
}
// Trust-on-first-use: trust a new peer identity; reject if it silently changes.
async isTrustedIdentity(identifier, identityKey /*, direction */) {
const existing = await kvGet("signal", "identity/" + identifier);
if (!existing) return true;
return buffersEqual(existing, identityKey);
}
async saveIdentity(identifier, identityKey) {
const addr = identifier.split(".")[0];
const existing = await kvGet("signal", "identity/" + addr);
await kvPut("signal", "identity/" + addr, identityKey);
return !!existing && !buffersEqual(existing, identityKey); // true = identity changed
}
async loadIdentityKey(identifier) {
return kvGet("signal", "identity/" + identifier.split(".")[0]);
}
async loadPreKey(keyId) {
return kvGet("signal", "prekey/" + keyId);
}
async storePreKey(keyId, keyPair) {
return kvPut("signal", "prekey/" + keyId, keyPair);
}
async removePreKey(keyId) {
return kvDel("signal", "prekey/" + keyId);
}
async loadSignedPreKey(keyId) {
return kvGet("signal", "signedprekey/" + keyId);
}
async storeSignedPreKey(keyId, keyPair) {
return kvPut("signal", "signedprekey/" + keyId, keyPair);
}
async removeSignedPreKey(keyId) {
return kvDel("signal", "signedprekey/" + keyId);
}
async loadSession(identifier) {
return kvGet("signal", "session/" + identifier);
}
async storeSession(identifier, record) {
return kvPut("signal", "session/" + identifier, record);
}
async removeSession(identifier) {
return kvDel("signal", "session/" + identifier);
}
async removeAllSessions() {
/* not needed for MVP */
}
}
function buffersEqual(a, b) {
const ua = new Uint8Array(a);
const ub = new Uint8Array(b);
if (ua.length !== ub.length) return false;
let diff = 0;
for (let i = 0; i < ua.length; i++) diff |= ua[i] ^ ub[i];
return diff === 0;
}

29317
public/js/vendor/libsignal.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,12 @@
{
"name": "Cipher",
"short_name": "Cipher",
"description": "End-to-end encrypted messaging",
"start_url": "/",
"display": "standalone",
"background_color": "#0b141a",
"theme_color": "#0b141a",
"icons": [
{ "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}

59
public/sw.js Normal file
View file

@ -0,0 +1,59 @@
// Service worker: offline app shell + Web Push wake-up.
// It never touches message plaintext — decryption happens only in the page.
const CACHE = "cipher-v1";
const SHELL = [
"/",
"/index.html",
"/manifest.webmanifest",
"/js/app.js",
"/js/api.js",
"/js/crypto.js",
"/js/store.js",
"/js/vendor/libsignal.js",
"/icons/icon.svg",
];
self.addEventListener("install", (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
// Never cache API or WebSocket traffic.
if (url.pathname.startsWith("/api/")) return;
// Cache-first for the shell, network fallback.
e.respondWith(
caches.match(e.request).then((hit) => hit || fetch(e.request))
);
});
// Payloadless push: just wake the client, which pulls ciphertext over the socket.
self.addEventListener("push", (e) => {
e.waitUntil(
self.registration.showNotification("Cipher", {
body: "New encrypted message",
icon: "/icons/icon.svg",
badge: "/icons/icon.svg",
tag: "cipher-msg",
})
);
});
self.addEventListener("notificationclick", (e) => {
e.notification.close();
e.waitUntil(
self.clients.matchAll({ type: "window" }).then((cl) => {
for (const c of cl) if ("focus" in c) return c.focus();
return self.clients.openWindow("/");
})
);
});

152
src/do/mailbox.js Normal file
View file

@ -0,0 +1,152 @@
// Mailbox: one Durable Object per USER. It is the store-and-forward relay.
//
// - Holds the user's live WebSocket connection(s) (one per device).
// - When an envelope arrives it delivers immediately if a matching device is
// online, otherwise persists it in DO SQLite storage.
// - On (re)connect it flushes everything queued for that device.
// - The client ACKs delivered envelopes by id; only then are they deleted.
//
// Everything stored here is ciphertext. The DO cannot read message contents.
export class Mailbox {
constructor(state, env) {
this.state = state;
this.env = env;
this.sql = state.storage.sql;
// deviceId -> WebSocket (live connections in this DO instance)
this.sockets = new Map();
this.state.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS queue (
id TEXT PRIMARY KEY,
to_device TEXT NOT NULL,
envelope TEXT NOT NULL,
queued_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_queue_device ON queue(to_device);
`);
});
// Re-attach hibernated WebSockets after the DO wakes.
for (const ws of this.state.getWebSockets()) {
const meta = ws.deserializeAttachment();
if (meta?.deviceId) this.sockets.set(meta.deviceId, ws);
}
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/deliver") {
const envelope = await request.json();
return this.deliver(envelope);
}
if (url.pathname === "/connect") {
const deviceId = url.searchParams.get("deviceId");
if (!deviceId) return new Response("missing deviceId", { status: 400 });
return this.connect(deviceId);
}
return new Response("not found", { status: 404 });
}
async deliver(envelope) {
const ws = this.sockets.get(envelope.toDeviceId);
if (ws) {
try {
ws.send(JSON.stringify({ t: "message", envelope }));
// Delivered live, but keep queued until the client ACKs (survives drops).
} catch {
// fall through to queue
}
}
this.sql.exec(
"INSERT OR REPLACE INTO queue (id, to_device, envelope, queued_at) VALUES (?,?,?,?)",
envelope.id,
envelope.toDeviceId,
JSON.stringify(envelope),
Date.now()
);
return new Response("ok");
}
async connect(deviceId) {
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
this.state.acceptWebSocket(server);
server.serializeAttachment({ deviceId });
// Replace any stale socket for this device.
const prev = this.sockets.get(deviceId);
if (prev && prev !== server) {
try { prev.close(1000, "replaced"); } catch {}
}
this.sockets.set(deviceId, server);
// Flush queued envelopes for this device.
this.flush(deviceId, server);
return new Response(null, { status: 101, webSocket: client });
}
flush(deviceId, ws) {
const rows = this.sql
.exec("SELECT envelope FROM queue WHERE to_device = ? ORDER BY queued_at ASC", deviceId)
.toArray();
for (const row of rows) {
try {
ws.send(JSON.stringify({ t: "message", envelope: JSON.parse(row.envelope) }));
} catch {
break;
}
}
}
// --- Hibernation WebSocket handlers ---
async webSocketMessage(ws, raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
const meta = ws.deserializeAttachment() || {};
if (msg.t === "ack" && Array.isArray(msg.ids)) {
// Client confirmed receipt — safe to drop from the queue.
for (const id of msg.ids) {
this.sql.exec("DELETE FROM queue WHERE id = ?", id);
}
return;
}
if (msg.t === "ping") {
ws.send(JSON.stringify({ t: "pong" }));
return;
}
if (msg.t === "pull") {
// Client explicitly requests a re-flush (e.g. after reconnect).
if (meta.deviceId) this.flush(meta.deviceId, ws);
return;
}
}
async webSocketClose(ws) {
const meta = ws.deserializeAttachment() || {};
if (meta.deviceId && this.sockets.get(meta.deviceId) === ws) {
this.sockets.delete(meta.deviceId);
}
}
async webSocketError(ws) {
const meta = ws.deserializeAttachment() || {};
if (meta.deviceId && this.sockets.get(meta.deviceId) === ws) {
this.sockets.delete(meta.deviceId);
}
}
}

25
src/index.js Normal file
View file

@ -0,0 +1,25 @@
// Cipher — end-to-end encrypted messenger on Cloudflare Workers.
// The Worker is a key directory + encrypted relay. It never sees plaintext.
import { Hono } from "hono";
import auth from "./routes/auth.js";
import keys from "./routes/keys.js";
import messages from "./routes/messages.js";
import media from "./routes/media.js";
export { Mailbox } from "./do/mailbox.js";
const app = new Hono();
app.get("/api/health", (c) => c.json({ ok: true, service: "cipher" }));
app.route("/api/auth", auth);
app.route("/api", keys);
app.route("/api", messages);
app.route("/api", media);
// Everything else falls through to static assets (the PWA), handled by the
// ASSETS binding via not_found_handling: single-page-application.
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
export default app;

73
src/lib/auth.js Normal file
View file

@ -0,0 +1,73 @@
// Account-level authentication. This is SEPARATE from the E2E encryption:
// it only proves "you control this account" so the server knows whose prekeys
// to publish and whose mailbox to read. It never touches message plaintext.
import { b64, randomToken, now } from "./util.js";
const PBKDF2_ITERS = 210000;
async function pbkdf2(password, saltBytes) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt: saltBytes, iterations: PBKDF2_ITERS },
key,
256
);
return b64(new Uint8Array(bits));
}
export async function hashPassword(password) {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
const hash = await pbkdf2(password, salt);
return { pw_hash: hash, pw_salt: b64(salt) };
}
export async function verifyPassword(password, pw_hash, pw_salt) {
const salt = Uint8Array.from(atob(pw_salt), (c) => c.charCodeAt(0));
const hash = await pbkdf2(password, salt);
// constant-time-ish compare
if (hash.length !== pw_hash.length) return false;
let diff = 0;
for (let i = 0; i < hash.length; i++) diff |= hash.charCodeAt(i) ^ pw_hash.charCodeAt(i);
return diff === 0;
}
const SESSION_TTL = 1000 * 60 * 60 * 24 * 60; // 60 days
export async function createSession(db, userId, deviceId) {
const token = randomToken();
const ts = now();
await db
.prepare(
"INSERT INTO sessions (token, user_id, device_id, created_at, expires_at) VALUES (?,?,?,?,?)"
)
.bind(token, userId, deviceId, ts, ts + SESSION_TTL)
.run();
return token;
}
// Hono middleware: require a valid bearer token; attach { userId, deviceId } to context.
export function requireAuth() {
return async (c, next) => {
const auth = c.req.header("Authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return c.json({ error: "unauthorized" }, 401);
const row = await c.env.DB.prepare(
"SELECT user_id, device_id, expires_at FROM sessions WHERE token = ?"
)
.bind(token)
.first();
if (!row || row.expires_at < now()) return c.json({ error: "unauthorized" }, 401);
c.set("userId", row.user_id);
c.set("deviceId", row.device_id);
await next();
};
}

32
src/lib/util.js Normal file
View file

@ -0,0 +1,32 @@
// Small shared helpers.
export function uuid() {
return crypto.randomUUID();
}
// URL-safe random token.
export function randomToken(bytes = 32) {
const b = new Uint8Array(bytes);
crypto.getRandomValues(b);
return b64url(b);
}
export function b64(bytes) {
let bin = "";
const arr = new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
return btoa(bin);
}
export function b64url(bytes) {
return b64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function now() {
return Date.now();
}
// Deterministic conversation id for a pair of users (sorted), for grouping.
export function pairId(a, b) {
return [a, b].sort().join(":");
}

102
src/routes/auth.js Normal file
View file

@ -0,0 +1,102 @@
// Registration & login. On register the client has already generated its Signal
// identity + prekeys locally and sends only the PUBLIC halves here.
import { Hono } from "hono";
import { uuid, now } from "../lib/util.js";
import { hashPassword, verifyPassword, createSession } from "../lib/auth.js";
const app = new Hono();
// POST /api/auth/register
// body: { username, displayName, password, device: { registrationId, identityKey,
// signedPreKey:{id,pub,sig}, oneTimePreKeys:[{id,pub}...] } }
app.post("/register", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: "bad request" }, 400);
const { username, displayName, password, device } = body;
const uname = String(username || "").trim().toLowerCase();
if (!/^[a-z0-9_]{3,20}$/.test(uname))
return c.json({ error: "username must be 3-20 chars: a-z 0-9 _" }, 400);
if (!password || password.length < 8)
return c.json({ error: "password must be at least 8 chars" }, 400);
if (!device?.identityKey || !device?.signedPreKey)
return c.json({ error: "missing device keys" }, 400);
const existing = await c.env.DB.prepare("SELECT id FROM users WHERE username = ?")
.bind(uname)
.first();
if (existing) return c.json({ error: "username taken" }, 409);
const userId = uuid();
const deviceId = uuid();
const ts = now();
const { pw_hash, pw_salt } = await hashPassword(password);
const stmts = [
c.env.DB.prepare(
"INSERT INTO users (id, username, display_name, pw_hash, pw_salt, created_at) VALUES (?,?,?,?,?,?)"
).bind(userId, uname, displayName || uname, pw_hash, pw_salt, ts),
c.env.DB.prepare(
`INSERT INTO devices (id, user_id, registration_id, identity_key,
signed_prekey_id, signed_prekey_pub, signed_prekey_sig, created_at, last_seen)
VALUES (?,?,?,?,?,?,?,?,?)`
).bind(
deviceId,
userId,
device.registrationId,
device.identityKey,
device.signedPreKey.id,
device.signedPreKey.pub,
device.signedPreKey.sig,
ts,
ts
),
];
for (const otk of device.oneTimePreKeys || []) {
stmts.push(
c.env.DB.prepare(
"INSERT INTO one_time_prekeys (device_id, prekey_id, prekey_pub) VALUES (?,?,?)"
).bind(deviceId, otk.id, otk.pub)
);
}
await c.env.DB.batch(stmts);
const token = await createSession(c.env.DB, userId, deviceId);
return c.json({ token, userId, deviceId, username: uname });
});
// POST /api/auth/login body: { username, password }
// Note: login proves account control; the client still holds its own private keys.
app.post("/login", async (c) => {
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: "bad request" }, 400);
const uname = String(body.username || "").trim().toLowerCase();
const user = await c.env.DB.prepare(
"SELECT id, pw_hash, pw_salt, display_name FROM users WHERE username = ?"
)
.bind(uname)
.first();
if (!user) return c.json({ error: "invalid credentials" }, 401);
const ok = await verifyPassword(body.password || "", user.pw_hash, user.pw_salt);
if (!ok) return c.json({ error: "invalid credentials" }, 401);
// Return the device on this account (MVP: single primary device).
const device = await c.env.DB.prepare(
"SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC LIMIT 1"
)
.bind(user.id)
.first();
if (!device) return c.json({ error: "no device registered" }, 409);
const token = await createSession(c.env.DB, user.id, device.id);
return c.json({
token,
userId: user.id,
deviceId: device.id,
username: uname,
displayName: user.display_name,
});
});
export default app;

121
src/routes/keys.js Normal file
View file

@ -0,0 +1,121 @@
// The key directory. This is what makes X3DH work: a sender fetches the
// recipient's "prekey bundle" (public keys) to establish a session without the
// recipient being online. The server hands out one-time prekeys and deletes them.
import { Hono } from "hono";
import { requireAuth } from "../lib/auth.js";
import { now } from "../lib/util.js";
const app = new Hono();
// GET /api/users/:username -> directory lookup
app.get("/users/:username", requireAuth(), async (c) => {
const uname = c.req.param("username").toLowerCase();
const user = await c.env.DB.prepare(
"SELECT id, username, display_name FROM users WHERE username = ?"
)
.bind(uname)
.first();
if (!user) return c.json({ error: "not found" }, 404);
return c.json({ userId: user.id, username: user.username, displayName: user.display_name });
});
// POST /api/keys/prekeys — replenish one-time prekeys (client tops up when low)
// body: { oneTimePreKeys: [{id,pub}, ...] }
app.post("/keys/prekeys", requireAuth(), async (c) => {
const deviceId = c.get("deviceId");
const body = await c.req.json().catch(() => null);
const otks = body?.oneTimePreKeys || [];
if (!Array.isArray(otks) || otks.length === 0)
return c.json({ error: "no prekeys" }, 400);
const stmts = otks.map((o) =>
c.env.DB.prepare(
"INSERT OR IGNORE INTO one_time_prekeys (device_id, prekey_id, prekey_pub) VALUES (?,?,?)"
).bind(deviceId, o.id, o.pub)
);
await c.env.DB.batch(stmts);
return c.json({ ok: true, added: otks.length });
});
// POST /api/keys/signed — rotate the signed prekey
// body: { signedPreKey: {id,pub,sig} }
app.post("/keys/signed", requireAuth(), async (c) => {
const deviceId = c.get("deviceId");
const body = await c.req.json().catch(() => null);
const spk = body?.signedPreKey;
if (!spk) return c.json({ error: "missing signedPreKey" }, 400);
await c.env.DB.prepare(
"UPDATE devices SET signed_prekey_id=?, signed_prekey_pub=?, signed_prekey_sig=? WHERE id=?"
)
.bind(spk.id, spk.pub, spk.sig, deviceId)
.run();
return c.json({ ok: true });
});
// GET /api/keys/count — how many one-time prekeys remain for my device
app.get("/keys/count", requireAuth(), async (c) => {
const deviceId = c.get("deviceId");
const row = await c.env.DB.prepare(
"SELECT COUNT(*) AS n FROM one_time_prekeys WHERE device_id = ?"
)
.bind(deviceId)
.first();
return c.json({ count: row?.n ?? 0 });
});
// GET /api/keys/bundle/:userId — fetch prekey bundle(s) for every device of a user.
// Consumes one one-time prekey per device (atomic delete-and-return).
app.get("/keys/bundle/:userId", requireAuth(), async (c) => {
const userId = c.req.param("userId");
const devices = await c.env.DB.prepare(
`SELECT id, registration_id, identity_key, signed_prekey_id,
signed_prekey_pub, signed_prekey_sig FROM devices WHERE user_id = ?`
)
.bind(userId)
.all();
if (!devices.results?.length) return c.json({ error: "no devices" }, 404);
const bundles = [];
for (const d of devices.results) {
// Grab and consume one one-time prekey (best-effort; bundle still valid without one).
const otk = await c.env.DB.prepare(
"SELECT prekey_id, prekey_pub FROM one_time_prekeys WHERE device_id = ? LIMIT 1"
)
.bind(d.id)
.first();
if (otk) {
await c.env.DB.prepare(
"DELETE FROM one_time_prekeys WHERE device_id = ? AND prekey_id = ?"
)
.bind(d.id, otk.prekey_id)
.run();
}
bundles.push({
deviceId: d.id,
registrationId: d.registration_id,
identityKey: d.identity_key,
signedPreKey: { id: d.signed_prekey_id, pub: d.signed_prekey_pub, sig: d.signed_prekey_sig },
oneTimePreKey: otk ? { id: otk.prekey_id, pub: otk.prekey_pub } : null,
});
}
return c.json({ userId, bundles });
});
// POST /api/push/subscribe — register a Web Push subscription
app.post("/push/subscribe", requireAuth(), async (c) => {
const deviceId = c.get("deviceId");
const sub = await c.req.json().catch(() => null);
if (!sub?.endpoint || !sub?.keys) return c.json({ error: "bad subscription" }, 400);
await c.env.DB.prepare(
`INSERT INTO push_subs (device_id, endpoint, p256dh, auth, created_at)
VALUES (?,?,?,?,?)
ON CONFLICT(device_id) DO UPDATE SET endpoint=excluded.endpoint,
p256dh=excluded.p256dh, auth=excluded.auth`
)
.bind(deviceId, sub.endpoint, sub.keys.p256dh, sub.keys.auth, now())
.run();
return c.json({ ok: true });
});
export default app;

45
src/routes/media.js Normal file
View file

@ -0,0 +1,45 @@
// Encrypted media. The client encrypts the file locally with a random key
// (AES-GCM) BEFORE upload, so R2 only ever holds ciphertext. The decryption key
// travels to the recipient inside the E2E-encrypted message, never to the server.
import { Hono } from "hono";
import { requireAuth } from "../lib/auth.js";
import { uuid, now } from "../lib/util.js";
const app = new Hono();
const MAX_BYTES = 100 * 1024 * 1024; // 100 MB
// POST /api/media — raw body is the ALREADY-ENCRYPTED blob. Returns an opaque key.
app.post("/media", requireAuth(), async (c) => {
const userId = c.get("userId");
const len = Number(c.req.header("Content-Length") || 0);
if (len > MAX_BYTES) return c.json({ error: "too large" }, 413);
const key = `${userId}/${now()}-${uuid()}`;
const body = c.req.raw.body;
if (!body) return c.json({ error: "empty body" }, 400);
await c.env.MEDIA.put(key, body, {
httpMetadata: { contentType: "application/octet-stream" },
customMetadata: { uploader: userId },
});
return c.json({ key });
});
// GET /api/media/:key — fetch the ciphertext blob (recipient decrypts locally).
// Key contains a slash, so we match the rest of the path.
app.get("/media/*", requireAuth(), async (c) => {
const key = c.req.path.replace(/^\/api\/media\//, "");
if (!key) return c.json({ error: "bad key" }, 400);
const obj = await c.env.MEDIA.get(key);
if (!obj) return c.json({ error: "not found" }, 404);
return new Response(obj.body, {
headers: {
"Content-Type": "application/octet-stream",
"Cache-Control": "private, max-age=31536000, immutable",
},
});
});
export default app;

78
src/routes/messages.js Normal file
View file

@ -0,0 +1,78 @@
// Message relay. The Worker never inspects ciphertext — it just routes an
// encrypted envelope to the recipient's Mailbox Durable Object, which delivers
// live over WebSocket or queues until the recipient reconnects.
import { Hono } from "hono";
import { requireAuth } from "../lib/auth.js";
import { now, uuid } from "../lib/util.js";
const app = new Hono();
function mailboxStub(env, userId) {
const id = env.MAILBOX.idFromName(userId);
return env.MAILBOX.get(id);
}
// POST /api/messages/send
// body: { toUserId, messages: [{ toDeviceId, type, body }] } // one entry per recipient device
// `body` is Signal ciphertext (base64); `type` is the Signal message type (1=whisper,3=prekey).
app.post("/messages/send", requireAuth(), async (c) => {
const fromUserId = c.get("userId");
const fromDeviceId = c.get("deviceId");
const payload = await c.req.json().catch(() => null);
if (!payload?.toUserId || !Array.isArray(payload.messages))
return c.json({ error: "bad request" }, 400);
const ts = now();
const results = [];
for (const m of payload.messages) {
const envelope = {
id: uuid(),
fromUserId,
fromDeviceId,
toDeviceId: m.toDeviceId,
type: m.type,
body: m.body, // ciphertext — opaque to the server
sentAt: ts,
};
const stub = mailboxStub(c.env, payload.toUserId);
const res = await stub.fetch("https://mailbox/deliver", {
method: "POST",
body: JSON.stringify(envelope),
});
results.push({ toDeviceId: m.toDeviceId, delivered: res.ok });
}
// Best-effort push wake-up (payloadless — client fetches the real ciphertext over WS).
c.executionCtx.waitUntil(wakeRecipient(c.env, payload.toUserId));
return c.json({ ok: true, id: uuid(), sentAt: ts, results });
});
async function wakeRecipient(env, toUserId) {
// Placeholder for Web Push fan-out; wired up once VAPID keys are configured.
// Left intentionally minimal so message send never blocks on push.
return;
}
// GET /api/ws — upgrade to WebSocket, routed to the caller's own Mailbox DO.
app.get("/ws", async (c) => {
// Auth via query token (browsers can't set headers on WebSocket handshakes).
const token = c.req.query("token");
if (!token) return c.json({ error: "unauthorized" }, 401);
const sess = await c.env.DB.prepare(
"SELECT user_id, device_id, expires_at FROM sessions WHERE token = ?"
)
.bind(token)
.first();
if (!sess || sess.expires_at < now()) return c.json({ error: "unauthorized" }, 401);
const stub = mailboxStub(c.env, sess.user_id);
const url = new URL("https://mailbox/connect");
url.searchParams.set("deviceId", sess.device_id);
return stub.fetch(url.toString(), {
headers: { Upgrade: "websocket", Connection: "Upgrade" },
});
});
export default app;

38
wrangler.jsonc Normal file
View file

@ -0,0 +1,38 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cipher",
"main": "src/index.js",
"compatibility_date": "2025-07-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"assets": {
"directory": "./public",
"binding": "ASSETS",
"not_found_handling": "single-page-application"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "cipher",
"database_id": "REPLACE_AFTER_CREATE",
"migrations_dir": "migrations"
}
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "cipher-media"
}
],
"durable_objects": {
"bindings": [
{ "name": "MAILBOX", "class_name": "Mailbox" }
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Mailbox"]
}
]
}