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:
commit
360bfeb5ab
22 changed files with 32879 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
56
migrations/0001_init.sql
Normal file
56
migrations/0001_init.sql
Normal 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
1803
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
Normal file
22
package.json
Normal 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
9
public/icons/icon.svg
Normal 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
98
public/index.html
Normal 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
108
public/js/api.js
Normal 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
392
public/js/app.js
Normal 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
170
public/js/crypto.js
Normal 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
161
public/js/store.js
Normal 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
29317
public/js/vendor/libsignal.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
public/manifest.webmanifest
Normal file
12
public/manifest.webmanifest
Normal 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
59
public/sw.js
Normal 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
152
src/do/mailbox.js
Normal 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
25
src/index.js
Normal 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
73
src/lib/auth.js
Normal 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
32
src/lib/util.js
Normal 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
102
src/routes/auth.js
Normal 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
121
src/routes/keys.js
Normal 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
45
src/routes/media.js
Normal 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
78
src/routes/messages.js
Normal 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
38
wrangler.jsonc
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue