Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/db/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
10 changes: 10 additions & 0 deletions src/db/sqlite/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function rowToUser(r: Record<string, unknown>): 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,
};
}

Expand Down Expand Up @@ -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,
};
}

Expand All @@ -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<void> {
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,
Expand Down
9 changes: 9 additions & 0 deletions src/services/personKeyBinding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,12 @@ export async function dmBindingIdentity(binding: string): Promise<string | null>
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);
}
46 changes: 45 additions & 1 deletion src/socket/handlers/dmKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()];
Expand Down
64 changes: 46 additions & 18 deletions src/socket/handlers/dmKeys.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, HandoverAsk[]>();

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 = {
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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 });

Expand All @@ -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;
Expand All @@ -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 });
}
},
Expand All @@ -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);
Expand All @@ -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", {});
},
Expand Down
3 changes: 2 additions & 1 deletion src/socket/utils/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
Loading