- 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>
392 lines
12 KiB
JavaScript
392 lines
12 KiB
JavaScript
// 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();
|