diff --git a/package.json b/package.json index 3dc551fb..15853b63 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.7.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..0e3ea7ef 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.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" + "@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"