From c47090052dd5849d6310d4a6ffc79d0354bb6250 Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:55:47 +0100 Subject: [PATCH] Key asks are saved, and an unused key hands over (GRYT-2014) An ask to use a device's key now starts the 30-day reset by itself and is handed to the device with the key even after the asker disconnects. If no device with the current key has connected for 30 days, the ask finishes at once. The asker is told which device holds the key and when it was last here. Co-Authored-By: Claude Opus 5.5 --- src/db/interfaces.ts | 3 ++ src/db/sqlite/connection.ts | 7 ++++ src/db/sqlite/users.ts | 10 +++++ src/services/personKeyBinding.ts | 9 +++++ src/socket/handlers/dmKeys.test.ts | 46 ++++++++++++++++++++- src/socket/handlers/dmKeys.ts | 64 +++++++++++++++++++++--------- src/socket/utils/clients.ts | 3 +- 7 files changed, 122 insertions(+), 20 deletions(-) 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