- 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>
121 lines
4.5 KiB
JavaScript
121 lines
4.5 KiB
JavaScript
// 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;
|