cipher/src/routes/messages.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

78 lines
2.7 KiB
JavaScript

// 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;