cipher/public/js/app.js
maverick 360bfeb5ab 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>
2026-07-22 01:56:59 +10:00

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();