diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index ef85d8e..b8f4efe 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -69,6 +69,9 @@ export interface UserRecord { /** A pending reset to this binding, with no handover note (GRYT-1886). Takes over at `dm_key_reset_at`, in ms. */ dm_key_reset_binding: string | null; dm_key_reset_at: number | null; + /** When a device with the current key was last connected, in ms, and what it calls itself. */ + dm_key_seen_at: number | null; + dm_key_holder: string | null; /** Null when they have no designed look. Stored and passed on without being read — see `utils/wornString.ts`. */ avatar_worn: string | null; diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 8079247..cae06ee 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -1053,6 +1053,13 @@ function runMigrations(d: DatabaseSync): void { d.exec("ALTER TABLE users ADD COLUMN dm_key_reset_at INTEGER"); } + // When a device with the account's key was last here, and its name: a key nobody uses can hand over. + if (!hasColumn(d, "users", "dm_key_seen_at")) { + d.exec("ALTER TABLE users ADD COLUMN dm_key_seen_at INTEGER"); + d.exec("ALTER TABLE users ADD COLUMN dm_key_holder TEXT"); + d.prepare("UPDATE users SET dm_key_seen_at = ? WHERE dm_key_binding IS NOT NULL").run(Date.now()); + } + // The MLS person key binding (GRYT-1515). Checked against the DM key binding's // signer when it's published, then stored whole like that one. if (!hasColumn(d, "users", "person_key_binding")) { diff --git a/src/db/sqlite/users.ts b/src/db/sqlite/users.ts index f906fb1..a3c5e7b 100644 --- a/src/db/sqlite/users.ts +++ b/src/db/sqlite/users.ts @@ -37,6 +37,8 @@ function rowToUser(r: Record): UserRecord { person_key_binding: (r.person_key_binding as string) || null, dm_key_reset_binding: (r.dm_key_reset_binding as string) || null, dm_key_reset_at: r.dm_key_reset_at == null ? null : Number(r.dm_key_reset_at), + dm_key_seen_at: r.dm_key_seen_at == null ? null : Number(r.dm_key_seen_at), + dm_key_holder: (r.dm_key_holder as string) || null, }; } @@ -148,6 +150,8 @@ export async function upsertUser( person_key_binding: null, dm_key_reset_binding: null, dm_key_reset_at: null, + dm_key_seen_at: null, + dm_key_holder: null, }; } @@ -171,6 +175,12 @@ export async function setUserDmKeyReset(serverUserId: string, binding: string | .run(binding, at, serverUserId); } +export async function setUserDmKeySeen(serverUserId: string, at: number, holder: string | null): Promise { + getSqliteDb() + .prepare(`UPDATE users SET dm_key_seen_at = ?, dm_key_holder = ? WHERE server_user_id = ?`) + .run(at, holder, serverUserId); +} + /** Checked by the caller (`services/personKeyBinding.ts`), not here. Null withdraws it. */ export async function setUserPersonKeyBinding( serverUserId: string, diff --git a/src/services/personKeyBinding.ts b/src/services/personKeyBinding.ts index 048eafb..b3cd8ce 100644 --- a/src/services/personKeyBinding.ts +++ b/src/services/personKeyBinding.ts @@ -104,3 +104,12 @@ export async function dmBindingIdentity(binding: string): Promise return null; } } + +/** Long enough to outlast a holiday, so a thief with the password can't finish one while you're away. */ +export const KEY_RESET_DELAY_MS = 30 * 24 * 60 * 60 * 1000; + +/** A reset finishes 30 days after it was asked for, or 30 days after a device with the current key was last here, whichever is first. */ +export function keyResetDueAt(user: { dm_key_reset_binding: string | null; dm_key_reset_at: number | null; dm_key_seen_at: number | null }): number | null { + if (!user.dm_key_reset_binding || user.dm_key_reset_at == null) return null; + return user.dm_key_seen_at == null ? user.dm_key_reset_at : Math.min(user.dm_key_reset_at, user.dm_key_seen_at + KEY_RESET_DELAY_MS); +} diff --git a/src/socket/handlers/dmKeys.test.ts b/src/socket/handlers/dmKeys.test.ts index a735771..2fc9047 100644 --- a/src/socket/handlers/dmKeys.test.ts +++ b/src/socket/handlers/dmKeys.test.ts @@ -6,7 +6,7 @@ import { after, before, describe, it } from "node:test"; import { initSqlite } from "../../db/sqlite/connection"; import { createServerConfigIfNotExists, setServerRole } from "../../db/sqlite/servers"; -import { getUserByServerId, setUserDmKeyReset, upsertUser } from "../../db/sqlite/users"; +import { getUserByServerId, setUserDmKeyReset, setUserDmKeySeen, upsertUser } from "../../db/sqlite/users"; import type { Clients } from "../../types"; import { buildMemberList } from "../utils/clients"; import { KEY_RESET_DELAY_MS, registerDmKeyHandlers } from "./dmKeys"; @@ -358,6 +358,50 @@ describe("one identity per account (GRYT-1868)", () => { assert.equal(after?.dm_key_reset_binding, null, "a finished reset is cleared"); }); + it("keeps an ask after the asker leaves, and starts the 30 days with it", async () => { + const jill = await connect("Jill"); + const [pc, phone] = [await signer(), await signer()]; + await publish(jill, { binding: await pc.binding(), deviceName: "Windows PC" }); + const asking = await connect("Jill's phone"); + clientsInfo[asking.clientId].serverUserId = jill.serverUserId; + const next = await phone.binding(); + + const asked = await call(asking, "dm:key:handover:ask", { binding: next, deviceName: "iPhone" }); + assert.equal(asked.holder, "Windows PC", "the asker learns which device it waits for"); + assert.ok(Math.abs((asked.at as number) - (Date.now() + KEY_RESET_DELAY_MS)) < 5_000); + assert.equal((await getUserByServerId(jill.serverUserId))?.dm_key_reset_binding, next); + + delete clientsInfo[asking.clientId]; + const heard: unknown[] = []; + io.sockets.sockets.set(jill.clientId, { emit: (event: string, body: unknown) => heard.push([event, body]) }); + jill.handlers["dm:key:handover:pending"]({}); + assert.deepEqual(heard, [["dm:key:handover:asked", { binding: next, deviceName: "iPhone", askerId: asking.clientId }]], "an ask outlives the asker's connection"); + }); + + it("hands over at once when no device with the current key has been here for 30 days", async () => { + const kim = await connect("Kim"); + const [gone, phone] = [await signer(), await signer()]; + await publish(kim, { binding: await gone.binding() }); + const next = await phone.binding(); + assert.equal((await publish(kim, { binding: next, replace: true })).error, "handover_required", "not without asking"); + + await setUserDmKeySeen(kim.serverUserId, Date.now() - KEY_RESET_DELAY_MS - 1, null); + const asked = await call(kim, "dm:key:handover:ask", { binding: next }); + assert.ok((asked.at as number) <= Date.now(), "a key nobody has used for 30 days has nobody to say no"); + assert.deepEqual(await publish(kim, { binding: next, replace: true }), { ok: true, changed: true }); + }); + + it("a device with the current key coming back keeps the full 30 days", async () => { + const lee = await connect("Lee"); + const [pc, thief] = [await signer(), await signer()]; + const current = await pc.binding(); + await publish(lee, { binding: current }); + await setUserDmKeySeen(lee.serverUserId, Date.now() - KEY_RESET_DELAY_MS - 1, null); + await publish(lee, { binding: current }); + const asked = await call(lee, "dm:key:handover:ask", { binding: await thief.binding() }); + assert.ok((asked.at as number) > Date.now() + KEY_RESET_DELAY_MS - 5_000); + }); + it("only cancels a reset with proof from the current key", async () => { const hank = await connect("Hank"); const [mine, thief] = [await signer(), await signer()]; diff --git a/src/socket/handlers/dmKeys.ts b/src/socket/handlers/dmKeys.ts index 5b27074..e71c340 100644 --- a/src/socket/handlers/dmKeys.ts +++ b/src/socket/handlers/dmKeys.ts @@ -1,6 +1,6 @@ import type { HandlerContext, EventHandlerMap } from "./types"; -import { getUserByServerId, setUserDmKeyBinding, setUserDmKeyReset } from "../../db"; -import { dmBindingIdentity, handoverValid } from "../../services/personKeyBinding"; +import { getUserByServerId, setUserDmKeyBinding, setUserDmKeyReset, setUserDmKeySeen } from "../../db"; +import { dmBindingIdentity, handoverValid, KEY_RESET_DELAY_MS, keyResetDueAt } from "../../services/personKeyBinding"; import { pushNotify } from "../../services/push"; import { checkRateLimit, type RateLimitRule } from "../../utils/rateLimiter"; import { broadcastMemberList } from "../utils/clients"; @@ -17,23 +17,22 @@ const MAX_BINDING_BYTES = 4096; /** Three non-empty base64url segments. Not a verification, a shape. */ const COMPACT_JWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; -/** As long as a link code lasts: a phone that connects within this still sees the request. */ -const HANDOVER_ASK_TTL_MS = 10 * 60 * 1000; +export { KEY_RESET_DELAY_MS }; + +/** Every connect republishes, so this only stops a write per connect. */ +const SEEN_WRITE_EVERY_MS = 60 * 60 * 1000; interface HandoverAsk { binding: string; deviceName: string; askerId: string; at: number } /* Per server and account, the requests still waiting, so a phone that connects late gets them. */ const pendingAsks = new Map(); function liveAsks(key: string, now = Date.now()): HandoverAsk[] { - const live = (pendingAsks.get(key) ?? []).filter((a) => now - a.at < HANDOVER_ASK_TTL_MS); + const live = (pendingAsks.get(key) ?? []).filter((a) => now - a.at < KEY_RESET_DELAY_MS); if (live.length) pendingAsks.set(key, live); else pendingAsks.delete(key); return live; } -/** Long enough to outlast a holiday, so a thief with the password can't finish one while you're away. */ -export const KEY_RESET_DELAY_MS = 30 * 24 * 60 * 60 * 1000; - /** A binding is derived from a seed and a scope, so it is sent on arrival and then never again. Anything faster is a client with a loop in it. */ const RL_DM_KEY: RateLimitRule = { @@ -54,7 +53,7 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { }; return { - "dm:key:publish": async (payload: { binding?: string | null; replace?: boolean; handover?: string }, ack?: (reply: { ok: boolean; changed?: boolean; error?: string; message?: string }) => void) => { + "dm:key:publish": async (payload: { binding?: string | null; replace?: boolean; handover?: string; deviceName?: string }, ack?: (reply: { ok: boolean; changed?: boolean; error?: string; message?: string }) => void) => { const reply = typeof ack === "function" ? ack : () => {}; const serverUserId = clientsInfo[clientId]?.serverUserId; if (!serverUserId) return; @@ -75,7 +74,15 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { if (!COMPACT_JWT.test(binding)) return; } + const deviceName = typeof payload?.deviceName === "string" ? payload.deviceName.slice(0, 64) || null : null; + const seen = async () => { + if (Date.now() - (user.dm_key_seen_at ?? 0) > SEEN_WRITE_EVERY_MS || (deviceName && deviceName !== user.dm_key_holder)) { + await setUserDmKeySeen(serverUserId, Date.now(), deviceName ?? user.dm_key_holder); + } + }; + if (user.dm_key_binding === binding) { + if (binding) await seen(); reply({ ok: true, changed: false }); return; } @@ -88,16 +95,19 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { return; } // Only the key being replaced can hand over (GRYT-1886); the password alone isn't enough. - const resetDue = user.dm_key_reset_binding === binding && (user.dm_key_reset_at ?? Infinity) <= Date.now(); + const resetDue = user.dm_key_reset_binding === binding && (keyResetDueAt(user) ?? Infinity) <= Date.now(); if (had && sent !== had && !resetDue && !(typeof payload?.handover === "string" && await handoverValid(payload.handover, user.dm_key_binding, binding))) { reply({ ok: false, error: "handover_required", message: "Approve this from a device that has your current key here." }); return; } } + const askKey = `${serverId}:${serverUserId}`; await setUserDmKeyBinding(serverUserId, binding); if (user.dm_key_reset_binding) await setUserDmKeyReset(serverUserId, null, null); - const askKey = `${serverId}:${serverUserId}`; + // Approving publishes the asker's key, so the name is the asker's. + const asker = liveAsks(askKey).find((a) => a.binding === binding); + if (binding) await setUserDmKeySeen(serverUserId, Date.now(), asker ? asker.deviceName || null : deviceName); if (pendingAsks.has(askKey)) pendingAsks.set(askKey, liveAsks(askKey).filter((a) => a.binding !== binding)); reply({ ok: true, changed: true }); @@ -106,8 +116,12 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { broadcastMemberList(io, clientsInfo, serverId); }, - /** A device with its own key asks your other devices to hand this server over to it. */ - "dm:key:handover:ask": (payload: { binding?: string; deviceName?: string }) => { + /** + * A device with its own key asks your other devices to hand this server over to it. The ask is + * also a reset (GRYT-1886), so it finishes alone if nobody with the current key says no. + */ + "dm:key:handover:ask": async (payload: { binding?: string; deviceName?: string }, ack?: (reply: { ok: boolean; at?: number; holder?: string | null; seenAt?: number | null }) => void) => { + const reply = typeof ack === "function" ? ack : () => {}; const serverUserId = clientsInfo[clientId]?.serverUserId; const binding = payload?.binding; if (!serverUserId || typeof binding !== "string" || binding.length > MAX_BINDING_BYTES || !COMPACT_JWT.test(binding)) return; @@ -121,14 +135,27 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { } } pushNotify(clientsInfo, [serverUserId], "approve", `handover:${serverUserId}`, { evenIfPresent: true }); + + const user = await getUserByServerId(serverUserId); + const [had, sent] = user?.dm_key_binding ? await Promise.all([dmBindingIdentity(user.dm_key_binding), dmBindingIdentity(binding)]) : [null, null]; + if (!user || !sent || sent === had) return reply({ ok: false }); + // Same rule as a reset: asking again doesn't restart the clock. + if (user.dm_key_reset_binding !== binding || !user.dm_key_reset_at) { + await setUserDmKeyReset(serverUserId, binding, Date.now() + KEY_RESET_DELAY_MS); + } + const after = await getUserByServerId(serverUserId); + const at = after ? keyResetDueAt(after) : null; + reply({ ok: true, at: at ?? undefined, holder: user.dm_key_holder, seenAt: user.dm_key_seen_at }); + emitToAccount(serverUserId, "dm:key:reset:pending", { at }); + broadcastMemberList(io, clientsInfo, serverId); }, - /** A device that just connected asks for requests made while it was away. */ + /** A device that just connected asks for requests made while it was away, even if the asker has gone since. */ "dm:key:handover:pending": () => { const serverUserId = clientsInfo[clientId]?.serverUserId; if (!serverUserId) return; for (const ask of liveAsks(`${serverId}:${serverUserId}`)) { - if (ask.askerId === clientId || !clientsInfo[ask.askerId]) continue; + if (ask.askerId === clientId) continue; io.sockets.sockets.get(clientId)?.emit("dm:key:handover:asked", { binding: ask.binding, deviceName: ask.deviceName, askerId: ask.askerId }); } }, @@ -143,8 +170,9 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { const user = await getUserByServerId(serverUserId); if (!user || !(await dmBindingIdentity(binding))) return reply({ ok: false, error: "invalid_binding" }); // A second request doesn't restart the clock, or a thief could keep yours from ever finishing. - const at = user.dm_key_reset_binding === binding && user.dm_key_reset_at ? user.dm_key_reset_at : Date.now() + KEY_RESET_DELAY_MS; - await setUserDmKeyReset(serverUserId, binding, at); + if (user.dm_key_reset_binding !== binding || !user.dm_key_reset_at) await setUserDmKeyReset(serverUserId, binding, Date.now() + KEY_RESET_DELAY_MS); + const after = await getUserByServerId(serverUserId); + const at = (after && keyResetDueAt(after)) ?? Date.now() + KEY_RESET_DELAY_MS; reply({ ok: true, at }); emitToAccount(serverUserId, "dm:key:reset:pending", { at }); broadcastMemberList(io, clientsInfo, serverId); @@ -170,8 +198,8 @@ export function registerDmKeyHandlers(ctx: HandlerContext): EventHandlerMap { "dm:key:handover:deny": (payload: { askerId?: string }) => { const serverUserId = clientsInfo[clientId]?.serverUserId; const askerId = payload?.askerId; - if (!serverUserId || typeof askerId !== "string" || clientsInfo[askerId]?.serverUserId !== serverUserId) return; const key = `${serverId}:${serverUserId}`; + if (!serverUserId || typeof askerId !== "string" || !liveAsks(key).some((a) => a.askerId === askerId)) return; pendingAsks.set(key, liveAsks(key).filter((a) => a.askerId !== askerId)); io.sockets.sockets.get(askerId)?.emit("dm:key:handover:denied", {}); }, diff --git a/src/socket/utils/clients.ts b/src/socket/utils/clients.ts index 912cecc..148385a 100644 --- a/src/socket/utils/clients.ts +++ b/src/socket/utils/clients.ts @@ -14,6 +14,7 @@ import { memberIdentity } from "./memberIdentity"; import { scopedChannelIds, visibleChannelIds } from "../../services/channelPermissions"; import { hasPermission, listRolesByMember } from "../../services/permissions"; import { cardFields } from "../../utils/memberCard"; +import { keyResetDueAt } from "../../services/personKeyBinding"; /** A member whose permissions were never cached receives no broadcasts, so all three admission paths call this and all three await it. */ @@ -261,7 +262,7 @@ export async function buildMemberList(clientsInfo: Clients) { /** Checked against the DM key binding's signer on the way in (GRYT-1515). */ personKeyBinding: user.person_key_binding, /** When a pending key reset takes over, so contacts and your own devices can see it coming. */ - keyResetAt: user.dm_key_reset_binding ? user.dm_key_reset_at : null, + keyResetAt: keyResetDueAt(user), avatarFileId: user.avatar_file_id || null, avatarColor: user.avatar_file_id ? avatarFiles.get(user.avatar_file_id)?.dominant_color ?? null