From 7d93daeff8f3d458a35e3e64ab66a94fa3e21ad6 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 09:39:58 +0200 Subject: [PATCH 1/2] Somewhere to publish the MLS person key binding (GRYT-1515) Apps couldn't pin anybody's person key, because the server had no event to publish the binding and the member list didn't carry one. The MLS driver refuses every leaf it can't match to a pinned person key, so no DM could open on MLS. mls:person:publish takes { accessToken, binding } with an ack, like the other mls:* events. The binding has to verify with @gryt/crypto's verifyPersonKeyBinding, and it has to be signed by the same identity key and for the same scope as the member's stored DM key binding. That's the key peers have pinned. null withdraws it. It's stored in a new users.person_key_binding column and goes out as personKeyBinding in the member list, in buildMemberList and in the dedupe hash, so both members:fetch and the broadcast carry it. Adds @gryt/crypto 0.6.0 as a dependency, loaded by subpath so the MLS code in its index stays out. Co-Authored-By: Claude Opus 5.5 --- package.json | 1 + src/db/interfaces.ts | 2 + src/db/sqlite/connection.ts | 6 + src/db/sqlite/users.ts | 14 ++ src/services/personKeyBinding.ts | 82 +++++++ src/socket/handlers/mlsPersonKey.test.ts | 294 +++++++++++++++++++++++ src/socket/handlers/mlsPersonKey.ts | 82 +++++++ src/socket/index.ts | 2 + src/socket/utils/clients.ts | 4 + src/socket/utils/memberStateHash.test.ts | 4 + yarn.lock | 35 +++ 11 files changed, 526 insertions(+) create mode 100644 src/services/personKeyBinding.ts create mode 100644 src/socket/handlers/mlsPersonKey.test.ts create mode 100644 src/socket/handlers/mlsPersonKey.ts diff --git a/package.json b/package.json index 3dc551fb..68f2214e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@aws-sdk/client-s3": "^3.994.0", "@aws-sdk/lib-storage": "^3.1106.0", "@aws-sdk/s3-request-presigner": "^3.994.0", + "@gryt/crypto": "^0.6.0", "bonjour-service": "^1.3.0", "consola": "^3.4.2", "dompurify": "^3.4.13", diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 46c34f4e..1fff5ad4 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -61,6 +61,8 @@ export interface UserRecord { /** Opaque on purpose: a server vouching for the binding would vouch for what a member has to check anyway. */ dm_key_binding: string | null; + /** Signed by the same identity key as `dm_key_binding`, which is checked on the way in. */ + person_key_binding: 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 559a736e..cdc93b96 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -878,6 +878,12 @@ function runMigrations(d: DatabaseSync): void { d.exec("ALTER TABLE users ADD COLUMN dm_key_binding TEXT"); } + // 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")) { + d.exec("ALTER TABLE users ADD COLUMN person_key_binding TEXT"); + } + // The whole envelope, untouched. With it set `text` is null and there is // nothing to filter or moderate, so a channel cannot hold one. if (!hasColumn(d, "messages", "sealed")) { diff --git a/src/db/sqlite/users.ts b/src/db/sqlite/users.ts index 1f8d3979..961e5967 100644 --- a/src/db/sqlite/users.ts +++ b/src/db/sqlite/users.ts @@ -29,6 +29,7 @@ function rowToUser(r: Record): UserRecord { : null, avatar_worn: (r.avatar_worn as string) || null, dm_key_binding: (r.dm_key_binding as string) || null, + person_key_binding: (r.person_key_binding as string) || null, }; } @@ -132,6 +133,7 @@ export async function upsertUser( // Sent after joining, if at all. A client older than GRYT-720 never sends // one, and a member with no binding simply has no encrypted messages. dm_key_binding: null, + person_key_binding: null, }; } @@ -148,6 +150,18 @@ export async function setUserDmKeyBinding( ); } +/** Checked by the caller (`services/personKeyBinding.ts`), not here. Null withdraws it. */ +export async function setUserPersonKeyBinding( + serverUserId: string, + binding: string | null, +): Promise { + const db = getSqliteDb(); + db.prepare(`UPDATE users SET person_key_binding = ? WHERE server_user_id = ?`).run( + binding, + serverUserId, + ); +} + export async function getUserByGrytId(grytUserId: string): Promise { const db = getSqliteDb(); const row = db.prepare(`SELECT * FROM users WHERE gryt_user_id = ?`).get(grytUserId) as Record | undefined; diff --git a/src/services/personKeyBinding.ts b/src/services/personKeyBinding.ts new file mode 100644 index 00000000..c3a1fadb --- /dev/null +++ b/src/services/personKeyBinding.ts @@ -0,0 +1,82 @@ +import type { IdentityScope } from "@gryt/crypto/dist/scope"; + +/** + * The MLS person key binding a member publishes (GRYT-1515, mls-design.md section 1). Peers + * check it themselves; this keeps an honest server from handing out one they'd refuse. + */ + +/* Subpaths, like mlsWire does with ts-mls: the package index pulls in all of MLS. */ +/* eslint-disable @typescript-eslint/no-require-imports */ +const { verifyPersonKeyBinding } = require("@gryt/crypto/mls-person-key") as typeof import("@gryt/crypto/dist/mls-person-key"); +const { verifyDmKeyBinding } = require("@gryt/crypto/dm-key-binding") as typeof import("@gryt/crypto/dist/dm-key-binding"); +/* eslint-enable @typescript-eslint/no-require-imports */ + +/** A real binding is about 500 bytes. The same ceiling as the DM key binding's. */ +export const MAX_PERSON_KEY_BINDING_BYTES = 4096; + +const COMPACT_JWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; + +export type PersonKeyCheck = + | { ok: true } + | { + ok: false; + error: "invalid_binding" | "no_dm_key" | "wrong_identity"; + message: string; + }; + +/* The scope a binding names, so it can be verified under that one. Null when unreadable. */ +function claimedScope(jwt: string): IdentityScope | null { + try { + const payload = JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")); + return typeof payload?.scope === "string" ? (payload.scope as IdentityScope) : null; + } catch { + return null; + } +} + +const refuse = (error: Exclude["error"], message: string): PersonKeyCheck => ({ + ok: false, + error, + message, +}); + +/** + * Valid, and signed by the identity key that signed this member's DM key binding, for the + * same scope. That's the key peers have pinned, and `pinPersonKey` refuses any other signer. + */ +export async function checkPersonKeyBinding( + binding: string, + dmKeyBinding: string | null, +): Promise { + if (binding.length > MAX_PERSON_KEY_BINDING_BYTES || !COMPACT_JWT.test(binding)) { + return refuse("invalid_binding", "That isn't a person key binding."); + } + const scope = claimedScope(binding); + if (!scope) return refuse("invalid_binding", "That person key binding names no scope."); + + let person; + try { + person = await verifyPersonKeyBinding(binding, scope); + } catch (err) { + return refuse("invalid_binding", err instanceof Error ? err.message : "That person key binding does not verify."); + } + + const dmScope = dmKeyBinding ? claimedScope(dmKeyBinding) : null; + if (!dmKeyBinding || !dmScope) { + return refuse("no_dm_key", "Publish your DM key first. The person key is checked against the identity that signed it."); + } + let dm; + try { + dm = await verifyDmKeyBinding(dmKeyBinding, dmScope); + } catch { + return refuse("no_dm_key", "Your DM key binding here doesn't verify, so there's nothing to check the person key against."); + } + + if (person.identityThumbprint !== dm.identityThumbprint) { + return refuse("wrong_identity", "The person key binding is signed by a different identity key than your DM key binding."); + } + if (person.scope !== dm.scope) { + return refuse("wrong_identity", "The person key binding was signed for a different server than your DM key binding."); + } + return { ok: true }; +} diff --git a/src/socket/handlers/mlsPersonKey.test.ts b/src/socket/handlers/mlsPersonKey.test.ts new file mode 100644 index 00000000..2f6cf4ff --- /dev/null +++ b/src/socket/handlers/mlsPersonKey.test.ts @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import type { IdentityScope } from "@gryt/crypto/dist/scope"; + +import { initSqlite } from "../../db/sqlite/connection"; +import { createServerConfigIfNotExists, setServerRole } from "../../db/sqlite/servers"; +import { getUserByServerId, setUserDmKeyBinding, upsertUser } from "../../db/sqlite/users"; +import type { Clients } from "../../types"; +import { generateAccessToken } from "../../utils/jwt"; +import { broadcastMemberList, buildMemberList } from "../utils/clients"; +import { refreshClientPermissions } from "../utils/standing"; +import { registerMemberHandlers } from "./members"; +import { registerMlsPersonKeyHandlers } from "./mlsPersonKey"; +import type { HandlerContext } from "./types"; + +/** Real bindings signed by @gryt/crypto, so a pass means a client's own check would pass too. */ + +/* eslint-disable @typescript-eslint/no-require-imports */ +const gryt = { + ...(require("@gryt/crypto/mls-person-key") as typeof import("@gryt/crypto/dist/mls-person-key")), + ...(require("@gryt/crypto/dm-key-binding") as typeof import("@gryt/crypto/dist/dm-key-binding")), + ...(require("@gryt/crypto/dm-keys") as typeof import("@gryt/crypto/dist/dm-keys")), +}; +/* eslint-enable @typescript-eslint/no-require-imports */ + +const HOST = "person.test:5001"; +const SCOPE = "srv:person-test" as IdentityScope; +const OTHER_SCOPE = "srv:somewhere-else" as IdentityScope; + +interface Reply { + ok: boolean; + error?: string; + changed?: boolean; +} + +interface Identity { + privateKey: CryptoKey; + publicJwk: JsonWebKey; +} + +interface Member { + serverUserId: string; + seed: Uint8Array; + identity: Identity; + received: (event: string) => unknown[]; + publish: (binding: unknown, token?: string) => Promise; + fetchMembers: () => Promise; +} + +let dir: string; +const clientsInfo: Clients = {}; +const sockets = new Map boolean }>(); +const io = { to: () => ({ emit() {} }), emit() {}, sockets: { sockets } }; + +async function newIdentity(): Promise { + const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const { kty, crv, x, y } = await crypto.subtle.exportKey("jwk", pair.publicKey); + return { privateKey: pair.privateKey, publicJwk: { kty, crv, x, y } }; +} + +async function dmBinding(m: Member, scope = SCOPE): Promise { + return gryt.signDmKeyBinding({ + dmPublicKey: gryt.deriveDmKeyPair(m.seed, scope).publicKey, + scope, + identityPrivateKey: m.identity.privateKey, + identityPublicJwk: m.identity.publicJwk, + }); +} + +async function personBinding(m: Member, opts: { scope?: IdentityScope; signer?: Identity } = {}): Promise { + const scope = opts.scope ?? SCOPE; + const signer = opts.signer ?? m.identity; + return gryt.signPersonKeyBinding({ + personPublicKey: gryt.derivePersonKeyPair(m.seed, scope).publicKey, + scope, + identityPrivateKey: signer.privateKey, + identityPublicJwk: signer.publicJwk, + }); +} + +let seq = 0; +async function connect(nickname: string): Promise { + seq += 1; + const clientId = `person-socket-${seq}`; + const grytUserId = `account-person-${seq}`; + const user = await upsertUser(grytUserId, nickname); + await setServerRole(user.server_user_id, "member"); + + const emitted: { event: string; payload: unknown }[] = []; + const emit = (event: string, payload?: unknown) => { + emitted.push({ event, payload }); + return true; + }; + sockets.set(clientId, { emit }); + clientsInfo[clientId] = { + serverUserId: user.server_user_id, grytUserId, nickname, color: "#666666", + isMuted: false, isDeafened: false, streamID: "", hasJoinedChannel: false, voiceChannelId: "", isAFK: false, + cameraEnabled: false, cameraStreamID: "", screenShareEnabled: false, screenShareVideoStreamID: "", + screenShareAudioStreamID: "", isServerMuted: false, isServerDeafened: false, + } as Clients[string]; + await refreshClientPermissions(clientsInfo, clientId); + + const ctx = { + io, + socket: { id: clientId, handshake: { headers: { host: HOST }, address: "127.0.0.1" }, emit, join() {}, leave() {} }, + clientId, + serverId: "person-test", + clientsInfo, + sfuClient: null, + getClientIp: () => `10.8.0.${seq}`, + clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + const handlers = { ...registerMlsPersonKeyHandlers(ctx), ...registerMemberHandlers(ctx) }; + const accessToken = generateAccessToken({ grytUserId, serverUserId: user.server_user_id, nickname, serverHost: HOST, tokenVersion: 0 }); + + return { + serverUserId: user.server_user_id, + seed: new Uint8Array(randomBytes(32)), + identity: await newIdentity(), + received: (event) => emitted.filter((e) => e.event === event).map((e) => e.payload), + publish: (binding, token = accessToken) => + new Promise((resolve) => { + void handlers["mls:person:publish"]({ accessToken: token, binding }, resolve); + }), + fetchMembers: async () => { + await handlers["members:fetch"](); + }, + }; +} + +/** A member who has already published a real DM key binding, as a client does on arrival. */ +async function withDmKey(nickname: string): Promise { + const m = await connect(nickname); + await setUserDmKeyBinding(m.serverUserId, await dmBinding(m)); + return m; +} + +async function stored(m: Member): Promise { + return (await getUserByServerId(m.serverUserId))?.person_key_binding ?? null; +} + +type Row = { serverUserId: string; personKeyBinding?: string | null }; +const rowOf = (list: unknown, m: Member) => (list as Row[]).find((r) => r.serverUserId === m.serverUserId); + +async function waitFor(read: () => T | undefined, ms = 2000): Promise { + const until = Date.now() + ms; + for (;;) { + const value = read(); + if (value !== undefined) return value; + if (Date.now() > until) throw new Error("timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-person-key-")); + process.env.DATA_DIR = dir; + process.env.JWT_SECRET = "test-secret"; + await initSqlite(); + await createServerConfigIfNotExists(); +}); + +after(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("mls:person:publish", () => { + it("stores a binding signed by the key behind the DM key binding", async () => { + const alice = await withDmKey("Alice"); + const binding = await personBinding(alice); + + assert.deepEqual(await alice.publish(binding), { ok: true, changed: true }); + assert.equal(await stored(alice), binding, "handed back byte for byte, since peers verify the signature"); + }); + + it("reaches both copies of the member list", async () => { + const alice = await withDmKey("Alice two"); + const bob = await withDmKey("Bob"); + // Settle the list first, so the only thing left to move the dedupe hash is the binding. + broadcastMemberList(io as never, clientsInfo, "person-test"); + await waitFor(() => bob.received("members:list").find((l) => rowOf(l, alice))); + const before = bob.received("members:list").length; + + const binding = await personBinding(alice); + assert.equal((await alice.publish(binding)).ok, true); + + // The broadcast, which only goes out if the field is in the dedupe hash. + const broadcast = await waitFor(() => + bob.received("members:list").slice(before).find((l) => rowOf(l, alice)?.personKeyBinding === binding), + ); + assert.ok(broadcast); + + // members:fetch, which a client asks for on connect. + await bob.fetchMembers(); + const fetched = bob.received("members:list").at(-1); + assert.equal(rowOf(fetched, alice)?.personKeyBinding, binding); + assert.equal(rowOf(fetched, bob)?.personKeyBinding, null, "nothing published reads as null, not a key"); + }); + + it("answers changed: false for the same binding again", async () => { + const m = await withDmKey("Repeat"); + const binding = await personBinding(m); + await m.publish(binding); + assert.deepEqual(await m.publish(binding), { ok: true, changed: false }); + }); + + it("refuses one when there is no DM key binding to check it against", async () => { + const m = await connect("No DM key"); + const reply = await m.publish(await personBinding(m)); + assert.equal(reply.error, "no_dm_key"); + assert.equal(await stored(m), null); + }); + + it("refuses one when the stored DM key binding doesn't verify", async () => { + const m = await connect("Junk DM key"); + // dm:key:publish stores anything shaped like a JWT, so this can be in the column. + await setUserDmKeyBinding(m.serverUserId, "eyJhbGciOiJFUzI1NiJ9.eyJzY29wZSI6InNydjpwZXJzb24tdGVzdCJ9.c2ln"); + assert.equal((await m.publish(await personBinding(m))).error, "no_dm_key"); + }); + + it("refuses one signed by a different identity key", async () => { + const m = await withDmKey("Other signer"); + const reply = await m.publish(await personBinding(m, { signer: await newIdentity() })); + assert.equal(reply.error, "wrong_identity"); + assert.equal(await stored(m), null); + }); + + it("refuses one signed for a different scope", async () => { + const m = await withDmKey("Other scope"); + const reply = await m.publish(await personBinding(m, { scope: OTHER_SCOPE })); + assert.equal(reply.error, "wrong_identity"); + assert.equal(await stored(m), null); + }); + + it("refuses a DM key binding passed off as a person key binding", async () => { + const m = await withDmKey("Wrong issuer"); + assert.equal((await m.publish(await dmBinding(m))).error, "invalid_binding"); + }); + + it("refuses a tampered signature", async () => { + const m = await withDmKey("Tampered"); + const [h, p, s] = (await personBinding(m)).split("."); + const flipped = s.startsWith("A") ? `B${s.slice(1)}` : `A${s.slice(1)}`; + assert.equal((await m.publish(`${h}.${p}.${flipped}`)).error, "invalid_binding"); + }); + + it("refuses what isn't a binding at all", async () => { + // A fresh member each, or the rate limit refuses the later ones and hides a missing guard. + for (const bad of ["", "not a jwt", "a.b", `${"a".repeat(5000)}.b.c`, 42, {}, ["a.b.c"]]) { + const m = await withDmKey(`Junk ${seq + 1}`); + const reply = await m.publish(bad); + assert.ok(reply.error === "invalid_binding" || reply.error === "invalid_payload", + `${JSON.stringify(bad).slice(0, 24)} got ${reply.error}`); + assert.equal(await stored(m), null); + } + }); + + it("withdraws it with null", async () => { + const m = await withDmKey("Withdraw"); + await m.publish(await personBinding(m)); + assert.deepEqual(await m.publish(null), { ok: true, changed: true }); + assert.equal(await stored(m), null); + }); + + it("refuses without a valid access token", async () => { + const m = await withDmKey("No token"); + assert.equal((await m.publish(await personBinding(m), "not-a-token")).error, "unauthenticated"); + assert.equal(await stored(m), null); + }); + + it("stores it against the token's member, whatever the payload names", async () => { + const alice = await withDmKey("Alice three"); + const bob = await withDmKey("Bob two"); + // Alice's binding sent with Bob's token checks against Bob's DM key, and fails. + const reply = await bob.publish(await personBinding(alice)); + assert.equal(reply.error, "wrong_identity"); + assert.equal(await stored(alice), null); + assert.equal(await stored(bob), null); + }); + + it("sits beside the DM key binding in buildMemberList", async () => { + const m = await withDmKey("Builder"); + const binding = await personBinding(m); + await m.publish(binding); + const row = (await buildMemberList(clientsInfo)).find((r) => r.serverUserId === m.serverUserId); + assert.equal(row?.personKeyBinding, binding); + assert.ok(row?.dmKeyBinding); + }); +}); diff --git a/src/socket/handlers/mlsPersonKey.ts b/src/socket/handlers/mlsPersonKey.ts new file mode 100644 index 00000000..6771a83c --- /dev/null +++ b/src/socket/handlers/mlsPersonKey.ts @@ -0,0 +1,82 @@ +import consola from "consola"; + +import { getUserByServerId, setUserPersonKeyBinding } from "../../db"; +import { checkPersonKeyBinding } from "../../services/personKeyBinding"; +import { checkRateLimit, type RateLimitRule } from "../../utils/rateLimiter"; +import { requireAuth } from "../middleware/auth"; +import { broadcastMemberList } from "../utils/clients"; +import type { EventHandlerMap, HandlerContext } from "./types"; + +type Reply = Record & { ok: boolean }; +type Ack = (reply: Reply) => void; + +/** Derived from the seed, so it's sent on arrival and rarely again. dm:key:publish's budget. */ +const RL_PERSON_KEY: RateLimitRule = { limit: 5, windowMs: 60_000, scorePerAction: 1, maxScore: 10, scoreDecayMs: 30_000 }; + +const fail = (error: string, message: string, extra: Record = {}): Reply => ({ + ok: false, + error, + message, + ...extra, +}); + +export function registerMlsPersonKeyHandlers(ctx: HandlerContext): EventHandlerMap { + const { io, socket, clientId, clientsInfo, serverId, getClientIp } = ctx; + + return { + /** Publish, replace or (with null) withdraw your person key binding on this server. */ + "mls:person:publish": async (payload: { accessToken?: string; binding?: string | null }, ack: Ack) => { + ack = typeof ack === "function" ? ack : () => {}; + try { + const rl = checkRateLimit("mls:person:publish", clientsInfo[clientId]?.serverUserId, getClientIp(), RL_PERSON_KEY); + if (!rl.allowed) { + ack(fail("rate_limited", `Too fast. Wait ${Math.ceil((rl.retryAfterMs || 0) / 1000)}s.`, { retryAfterMs: rl.retryAfterMs })); + return; + } + if (!payload || typeof payload !== "object") { + ack(fail("invalid_payload", "Invalid payload")); + return; + } + const auth = await requireAuth(socket, payload); + if (!auth) { + ack(fail("unauthenticated", "Sign in to this server first.")); + return; + } + + const binding = payload.binding; + if (binding !== null && typeof binding !== "string") { + ack(fail("invalid_payload", "binding has to be a string, or null to withdraw it.")); + return; + } + + const serverUserId = auth.tokenPayload.serverUserId; + const user = await getUserByServerId(serverUserId); + if (!user) { + ack(fail("unknown_member", "User not found. Please rejoin.")); + return; + } + + if (binding !== null) { + const check = await checkPersonKeyBinding(binding, user.dm_key_binding); + if (!check.ok) { + ack(fail(check.error, check.message)); + return; + } + } + + if (user.person_key_binding === binding) { + ack({ ok: true, changed: false }); + return; + } + await setUserPersonKeyBinding(serverUserId, binding); + ack({ ok: true, changed: true }); + + // The binding is in the member list's dedupe hash, so this reaches people. + broadcastMemberList(io, clientsInfo, serverId); + } catch (err) { + consola.error("mls:person:publish failed", err); + ack(fail("failed", "Could not store the person key binding")); + } + }, + }; +} diff --git a/src/socket/index.ts b/src/socket/index.ts index 8dc24cf4..b17099f0 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -51,6 +51,7 @@ import { registerTypingHandlers } from "./handlers/typing"; import { registerPluginHandlers } from "./handlers/plugins"; import { registerDmKeyHandlers } from "./handlers/dmKeys"; import { registerMlsHandlers } from "./handlers/mls"; +import { registerMlsPersonKeyHandlers } from "./handlers/mlsPersonKey"; import { registerMentionHandlers } from "./handlers/mentions"; import { addressIsOwn, resolveClientIp, trustedProxyHops } from "../config/clientAddress"; @@ -348,6 +349,7 @@ export function socketHandler(io: Server, socket: Socket, sfuClient: SFUClient | ...registerPluginHandlers(ctx), ...registerDmKeyHandlers(ctx), ...registerMlsHandlers(ctx), + ...registerMlsPersonKeyHandlers(ctx), ...registerMentionHandlers(ctx), }; diff --git a/src/socket/utils/clients.ts b/src/socket/utils/clients.ts index 44af9e04..169ea6f7 100644 --- a/src/socket/utils/clients.ts +++ b/src/socket/utils/clients.ts @@ -244,6 +244,8 @@ export async function buildMemberList(clientsInfo: Clients) { /** Passed through untouched: a server vouching for the binding would be vouching for what a peer has to establish for itself. */ dmKeyBinding: user.dm_key_binding, + /** Checked against the DM key binding's signer on the way in (GRYT-1515). */ + personKeyBinding: user.person_key_binding, avatarFileId: user.avatar_file_id || null, avatarColor: user.avatar_file_id ? avatarFiles.get(user.avatar_file_id)?.dominant_color ?? null @@ -297,6 +299,8 @@ export function memberStateHash(members: MemberListEntry[]): string { // A peer holding the old key encrypts to one nobody has, so a new binding // must not sit unsent waiting for something else to move. dmKeyBinding: m.dmKeyBinding, + // Without it a new person key never goes out, and nobody can add that member's devices. + personKeyBinding: m.personKeyBinding, // Redundant with `nickname`, except for a rename back to a previous name, // which leaves that field looking untouched. nicknameChangedAt: m.nicknameChangedAt, diff --git a/src/socket/utils/memberStateHash.test.ts b/src/socket/utils/memberStateHash.test.ts index 4902b00e..3d81df2d 100644 --- a/src/socket/utils/memberStateHash.test.ts +++ b/src/socket/utils/memberStateHash.test.ts @@ -18,6 +18,8 @@ function member(over: Partial = {}): Member { avatarFileId: null, avatarColor: null, avatarWorn: null, + dmKeyBinding: null, + personKeyBinding: null, role: "member", isBot: false, status: "online", @@ -56,6 +58,8 @@ describe("the member list dedupe", () => { ["a new picture", { avatarFileId: "file_2" }], ["a computed avatar colour", { avatarColor: "#6cdac8" }], ["a designed owl", { avatarWorn: "aiac----adab" }], + ["a new DM key", { dmKeyBinding: "a.b.c" }], + ["a new person key", { personKeyBinding: "a.b.c" }], ["a role change", { role: "admin" }], ["going bot", { isBot: true }], ["going offline", { status: "offline" }], diff --git a/yarn.lock b/yarn.lock index 5ee534f2..c1d83b50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -523,6 +523,17 @@ resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.1.tgz#b13bc464ca162c17abf0837fb3a11aeab79e45d1" integrity sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q== +"@gryt/crypto@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@gryt/crypto/-/crypto-0.6.0.tgz#f90a3e7ee9afcb7e32f36cf5b7e2c38c5032b5b9" + integrity sha512-yfKxpPeLBjrfwnginD3vWSxrvyDzPBH80T3E069bE0ItsD5FQ82XmvYXBfql9FQAehLBl4JHJBPxgEO45QNzPw== + dependencies: + "@noble/ciphers" "^2" + "@noble/curves" "^2.3.0" + "@noble/hashes" "^2.3.0" + "@scure/bip39" "^2.3.0" + ts-mls "1.6.4" + "@hpke/common@^1.10.0": version "1.10.1" resolved "https://registry.yarnpkg.com/@hpke/common/-/common-1.10.1.tgz#11f205e5ba24d558c1bd4ac671580d95d488af18" @@ -750,11 +761,35 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== +"@noble/ciphers@^2": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-2.4.0.tgz#04cf9e0f1cd3d521e8e5ca92dc0c02238180f676" + integrity sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw== + +"@noble/curves@^2.3.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.4.0.tgz#367c869bfd790c3a2248152562f255b5f8898b74" + integrity sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew== + dependencies: + "@noble/hashes" "2.4.0" + +"@noble/hashes@2.4.0", "@noble/hashes@^2.3.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.4.0.tgz#e6a3e98edbed3c8659b28bb8f9b18ca02ec8ea4b" + integrity sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA== + "@opentelemetry/api@^1.4.0": version "1.9.1" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== +"@scure/bip39@^2.3.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.4.0.tgz#7b19a76d4dd2f4591c07b446a0f9695f9b1d9545" + integrity sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA== + dependencies: + "@noble/hashes" "2.4.0" + "@smithy/core@^3.33.2", "@smithy/core@^3.33.3": version "3.33.3" resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.33.3.tgz#c1e801fe17160bcbf6c714cf16e832057e6bf79e" From 67dfdbc51c14f2650dbe878b317950a9db372604 Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 28 Sep 2026 09:49:35 +0200 Subject: [PATCH 2/2] Pin @gryt/crypto 0.7.0 (GRYT-1515) The other repos are moving to 0.7.0. verifyPersonKeyBinding and verifyDmKeyBinding haven't changed since 0.6.0. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 68f2214e..15853b63 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "@aws-sdk/client-s3": "^3.994.0", "@aws-sdk/lib-storage": "^3.1106.0", "@aws-sdk/s3-request-presigner": "^3.994.0", - "@gryt/crypto": "^0.6.0", + "@gryt/crypto": "0.7.0", "bonjour-service": "^1.3.0", "consola": "^3.4.2", "dompurify": "^3.4.13", diff --git a/yarn.lock b/yarn.lock index c1d83b50..0e3ea7ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -523,10 +523,10 @@ resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.1.tgz#b13bc464ca162c17abf0837fb3a11aeab79e45d1" integrity sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q== -"@gryt/crypto@^0.6.0": - version "0.6.0" - resolved "https://registry.yarnpkg.com/@gryt/crypto/-/crypto-0.6.0.tgz#f90a3e7ee9afcb7e32f36cf5b7e2c38c5032b5b9" - integrity sha512-yfKxpPeLBjrfwnginD3vWSxrvyDzPBH80T3E069bE0ItsD5FQ82XmvYXBfql9FQAehLBl4JHJBPxgEO45QNzPw== +"@gryt/crypto@0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@gryt/crypto/-/crypto-0.7.0.tgz#fa789a20c25e2563b525e2e6a2ada96a6c4de47b" + integrity sha512-PQzV4cQ3IWEKHZcG7suc7QAHRHMcCPFMKTDiU+82PqlHuLsOY25RKBFa+uQh70W03dRkey9jXtphYGfuO8P3jg== dependencies: "@noble/ciphers" "^2" "@noble/curves" "^2.3.0"