diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index dfd63305..380269a9 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -256,6 +256,7 @@ function createSchema(d: DatabaseSync): void { muted TEXT NOT NULL DEFAULT '[]', loud TEXT NOT NULL DEFAULT '[]', everyone INTEGER NOT NULL DEFAULT 0, + preview_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (server_user_id, install_id) @@ -724,6 +725,10 @@ function runMigrations(d: DatabaseSync): void { if (!hasColumn(d, "push_devices", "everyone")) { d.exec("ALTER TABLE push_devices ADD COLUMN everyone INTEGER NOT NULL DEFAULT 0"); } + // The key a phone's previews are sealed to (GRYT-1688). Null for an app that sends none: it gets the fixed text. + if (!hasColumn(d, "push_devices", "preview_key")) { + d.exec("ALTER TABLE push_devices ADD COLUMN preview_key TEXT"); + } const cols = d.prepare("PRAGMA table_info(users)").all() as { name: string }[]; const colNames = new Set(cols.map((c) => c.name)); diff --git a/src/db/sqlite/pushDevices.test.ts b/src/db/sqlite/pushDevices.test.ts index 2c8bc8cc..bd960960 100644 --- a/src/db/sqlite/pushDevices.test.ts +++ b/src/db/sqlite/pushDevices.test.ts @@ -20,7 +20,7 @@ describe("push devices", () => { it("keeps one row per install and replaces its capability", () => { savePushDevice("u1", "install-1", cap("a")); savePushDevice("u1", "install-1", cap("b")); - assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b"), muted: new Set(), loud: new Set(), everyone: false }]); + assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b"), muted: new Set(), loud: new Set(), everyone: false, previewKey: null }]); removePushDevice("u1", "install-1"); assert.deepEqual(listPushDevices("u1"), []); }); diff --git a/src/db/sqlite/pushDevices.ts b/src/db/sqlite/pushDevices.ts index f1b4088a..6e9cf2c4 100644 --- a/src/db/sqlite/pushDevices.ts +++ b/src/db/sqlite/pushDevices.ts @@ -10,6 +10,8 @@ export interface PushDevice { loud: ReadonlySet; /** Whether @everyone and @here wake it, which "Suppress @everyone" turns off. */ everyone: boolean; + /** What its previews are sealed to, so only that phone can read who wrote what (GRYT-1688). */ + previewKey: string | null; } /** What the phone said about its notification settings when it last checked in. */ @@ -17,6 +19,7 @@ export interface PushSettings { muted?: readonly string[]; loud?: readonly string[]; everyone?: boolean; + previewKey?: string | null; } /** More than this and the oldest goes. Nobody has ten phones; a reinstall loop might. */ @@ -46,12 +49,14 @@ export function savePushDevice( const db = getSqliteDb(); const at = toIso(now); db.prepare( - `INSERT INTO push_devices (server_user_id, install_id, capability, muted, loud, everyone, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO push_devices (server_user_id, install_id, capability, muted, loud, everyone, preview_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(server_user_id, install_id) DO UPDATE SET capability = excluded.capability, muted = excluded.muted, - loud = excluded.loud, everyone = excluded.everyone, updated_at = excluded.updated_at`, + loud = excluded.loud, everyone = excluded.everyone, preview_key = excluded.preview_key, updated_at = excluded.updated_at`, ).run( serverUserId, installId, capability, JSON.stringify(settings.muted ?? []), JSON.stringify(settings.loud ?? []), settings.everyone ? 1 : 0, + settings.previewKey ?? null, at, at, ); db.prepare( @@ -66,14 +71,15 @@ export function listPushDevices(serverUserId: string, now = new Date()): PushDev const cutoff = toIso(new Date(now.getTime() - PUSH_DEVICE_STALE_DAYS * DAY_MS)); db.prepare(`DELETE FROM push_devices WHERE server_user_id = ? AND updated_at < ?`).run(serverUserId, cutoff); const rows = db - .prepare(`SELECT install_id, capability, muted, loud, everyone FROM push_devices WHERE server_user_id = ?`) - .all(serverUserId) as { install_id: string; capability: string; muted: string; loud: string; everyone: number }[]; + .prepare(`SELECT install_id, capability, muted, loud, everyone, preview_key FROM push_devices WHERE server_user_id = ?`) + .all(serverUserId) as { install_id: string; capability: string; muted: string; loud: string; everyone: number; preview_key: string | null }[]; return rows.map((r) => ({ installId: r.install_id, capability: r.capability, muted: parseIds(r.muted), loud: parseIds(r.loud), everyone: r.everyone === 1, + previewKey: r.preview_key ?? null, })); } diff --git a/src/services/push.test.ts b/src/services/push.test.ts index 98c99dfb..90f4db70 100644 --- a/src/services/push.test.ts +++ b/src/services/push.test.ts @@ -55,16 +55,51 @@ describe("who is at a screen", () => { }); }); +describe("previews (GRYT-1688)", () => { + const phone = (capability: string, previewKey: string | null) => + ({ installId: `i-${capability.slice(2, 8)}`, capability, muted: new Set(), loud: new Set(), everyone: false, previewKey }); + const KEY = Buffer.alloc(32, 7).toString("base64url"); + + it("builds the preview once for every phone with a key, and seals it to each", async () => { + let built = 0; + const h = harness({ u1: [phone(CAP_A, KEY)], u2: [phone(CAP_B, KEY)] }); + h.pusher.notify({}, ["u1", "u2"], "dm", "conv", { preview: async () => (built++, { title: "Alice", body: "hi" }) }); + await h.pusher.settled(); + assert.equal(built, 1); + const previews = h.calls.map((c) => (JSON.parse(c.body) as { preview?: string }).preview); + assert.ok(previews.every((p) => typeof p === "string" && p.length > 40)); + assert.notEqual(previews[0], previews[1], "each phone gets its own sealing"); + }); + + it("never builds it when no phone has a key, or nobody is pushed", async () => { + let built = 0; + const preview = async () => (built++, { title: "Alice", body: "hi" }); + const h = harness({ u1: [phone(CAP_A, null)] }); + h.pusher.notify({}, ["u1"], "dm", "conv", { preview }); + h.pusher.notify({ s: client("u2") }, ["u2"], "dm", "conv", { preview }); + await h.pusher.settled(); + assert.equal(built, 0); + assert.deepEqual(JSON.parse(h.calls[0].body), { kind: "dm" }); + }); + + it("still pushes the fixed text when building the preview fails", async () => { + const h = harness({ u1: [phone(CAP_A, KEY)] }); + h.pusher.notify({}, ["u1"], "dm", "conv", { preview: async () => { throw new Error("db gone"); } }); + await h.pusher.settled(); + assert.deepEqual(JSON.parse(h.calls[0].body), { kind: "dm" }); + }); +}); + describe("pushing", () => { it("sends the capability in the header and only the kind in the body", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false, previewKey: null }] }); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); assert.deepEqual(h.calls, [{ url: "https://push.test/v1/push", auth: `Bearer ${CAP_A}`, body: JSON.stringify({ kind: "dm" }) }]); }); it("skips somebody who is at a screen, and wakes them once they put the phone away", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false, previewKey: null }] }); h.pusher.notify({ s: client("u1") }, ["u1"], "mention", "conv"); await flush(); assert.equal(h.calls.length, 0); @@ -74,7 +109,7 @@ describe("pushing", () => { }); it("buzzes once per phone per conversation in fifteen seconds", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }, { installId: "tablet-12", capability: CAP_B, muted: new Set(), loud: new Set(), everyone: false }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false, previewKey: null }, { installId: "tablet-12", capability: CAP_B, muted: new Set(), loud: new Set(), everyone: false, previewKey: null }] }); h.pusher.notify({}, ["u1"], "dm", "conv"); h.pusher.notify({}, ["u1", "u1"], "dm", "conv"); h.pusher.notify({}, ["u1"], "dm", "other"); @@ -86,7 +121,7 @@ describe("pushing", () => { it("forgets a capability the relay calls gone or unknown", async () => { for (const status of [404, 410]) { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }, status); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false, previewKey: null }] }, status); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); await flush(); @@ -95,7 +130,7 @@ describe("pushing", () => { }); it("keeps it when the relay is only busy", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false }] }, 502); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set(), loud: new Set(), everyone: false, previewKey: null }] }, 502); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); await flush(); diff --git a/src/services/push.ts b/src/services/push.ts index 813d1178..f84ee1c1 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -2,6 +2,7 @@ import consola from "consola"; import { listPushDevices, removePushCapability, type PushDevice } from "../db"; import type { Clients } from "../types"; +import { sealPreview, type PushPreview } from "./pushPreview"; /** * Waking phones through the push relay (GRYT-1656). The relay gets a capability and @@ -13,6 +14,12 @@ export type PushKind = "mention" | "dm" | "message"; /** Which of an account's phones want this push, by their own settings. Muted is checked on top. */ export type PushAccept = (device: PushDevice) => boolean; +export interface PushOptions { + accept?: PushAccept; + /** Asked once, and only when a phone with a preview key is about to be pushed (GRYT-1688). */ + preview?: () => Promise; +} + export const DEFAULT_PUSH_RELAY = "https://push.gryt.chat"; /** Same shape the relay hands out. Anything else is refused before it is stored. */ @@ -58,12 +65,15 @@ export function createPusher(deps: PusherDeps) { const lastSent = new Map(); const inFlight = new Set>(); - async function send(device: PushDevice, kind: PushKind): Promise { + async function send(device: PushDevice, kind: PushKind, preview: (() => Promise) | null): Promise { try { + const shown = device.previewKey && preview ? await preview() : null; + // Sealed to this phone's key, so the relay forwards it unread. Without one the relay's own text shows. + const sealed = shown && device.previewKey ? sealPreview(device.previewKey, device.capability, shown) : undefined; const res = await deps.fetch(`${deps.relay}/v1/push`, { method: "POST", headers: { authorization: `Bearer ${device.capability}`, "content-type": "application/json" }, - body: JSON.stringify({ kind }), + body: JSON.stringify(sealed ? { kind, preview: sealed } : { kind }), signal: AbortSignal.timeout(TIMEOUT_MS), }); // 404: the relay forgot it. 410: Apple or Google said the phone is gone. @@ -80,10 +90,16 @@ export function createPusher(deps: PusherDeps) { serverUserIds: Iterable, kind: PushKind, conversationId: string, - accept: PushAccept = () => true, + options: PushOptions = {}, ): void { if (!deps.relay) return; const now = deps.now(); + const accept = options.accept ?? (() => true); + let preview: Promise | null = null; + const previewOnce = () => (preview ??= (options.preview?.() ?? Promise.resolve(null)).catch((err) => { + consola.debug("push preview failed", (err as Error).message); + return null; + })); for (const serverUserId of new Set(serverUserIds)) { if (isPresent(clientsInfo, serverUserId)) continue; let devices: PushDevice[]; @@ -98,7 +114,7 @@ export function createPusher(deps: PusherDeps) { const key = `${device.capability}:${conversationId}`; if (now - (lastSent.get(key) ?? 0) < QUIET_MS) continue; lastSent.set(key, now); - const sending = send(device, kind).finally(() => inFlight.delete(sending)); + const sending = send(device, kind, options.preview ? previewOnce : null).finally(() => inFlight.delete(sending)); inFlight.add(sending); } } @@ -129,7 +145,7 @@ export function pushNotify( serverUserIds: Iterable, kind: PushKind, conversationId: string, - accept?: PushAccept, + options?: PushOptions, ): void { shared ??= createPusher({ relay: configuredRelay(), @@ -138,7 +154,7 @@ export function pushNotify( fetch, now: Date.now, }); - shared.notify(clientsInfo, serverUserIds, kind, conversationId, accept); + shared.notify(clientsInfo, serverUserIds, kind, conversationId, options); } /** Tests only: every push so far has reached the relay and been answered. */ diff --git a/src/services/pushPreview.test.ts b/src/services/pushPreview.test.ts new file mode 100644 index 00000000..d2bb11c0 --- /dev/null +++ b/src/services/pushPreview.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { describe, it } from "node:test"; + +import { capabilityTag, PREVIEW_KEY_SHAPE, previewText, sealPreview } from "./pushPreview"; + +const CAP = `p_${"a".repeat(43)}`; + +/** Written from the format, not from the sealing code: what the phone's extension does. */ +function open(previewKey: string, capability: string, blob: string, tag = capabilityTag(capability)): unknown { + const raw = Buffer.from(blob, "base64url"); + assert.equal(raw[0], 1, "version byte"); + const nonce = raw.subarray(1, 13); + const sealed = raw.subarray(13, raw.length - 16); + const authTag = raw.subarray(raw.length - 16); + const decipher = createDecipheriv("aes-256-gcm", Buffer.from(previewKey, "base64url"), nonce); + decipher.setAAD(Buffer.from(`gryt-push-1|${tag}`)); + decipher.setAuthTag(authTag); + return JSON.parse(Buffer.concat([decipher.update(sealed), decipher.final()]).toString("utf8")); +} + +describe("sealPreview", () => { + const key = randomBytes(32).toString("base64url"); + + it("opens with the phone's key into title, subtitle and body", () => { + assert.match(key, PREVIEW_KEY_SHAPE); + const blob = sealPreview(key, CAP, { title: "Alice", subtitle: "#general 路 Gryt", body: "hey there" }); + assert.deepEqual(open(key, CAP, blob), { t: "Alice", s: "#general 路 Gryt", b: "hey there" }); + }); + + it("uses the same tag the relay puts on the push", () => { + assert.equal(capabilityTag(CAP), createHash("sha256").update(CAP).digest("hex").slice(0, 16)); + }); + + it("won't open with another key, or as another capability's push", () => { + const blob = sealPreview(key, CAP, { title: "Alice", body: "hi" }); + assert.throws(() => open(randomBytes(32).toString("base64url"), CAP, blob)); + assert.throws(() => open(key, CAP, blob, capabilityTag(`p_${"b".repeat(43)}`))); + }); + + it("is new ciphertext every time, so two equal messages don't look equal", () => { + const one = sealPreview(key, CAP, { title: "Alice", body: "hi" }); + const two = sealPreview(key, CAP, { title: "Alice", body: "hi" }); + assert.notEqual(one, two); + }); + + it("stays well under what APNs takes, whatever the message", () => { + const blob = sealPreview(key, CAP, { title: "x".repeat(500), subtitle: "y".repeat(500), body: "馃榾".repeat(2000) }); + assert.ok(blob.length < 1500, `${blob.length} characters`); + const opened = open(key, CAP, blob) as { b: string }; + assert.equal([...opened.b].length, 140, "clipped by character, so no emoji is cut in half"); + }); + + it("refuses a key that isn't 32 bytes", () => { + assert.throws(() => sealPreview(Buffer.alloc(16).toString("base64url"), CAP, { title: "a", body: "b" })); + }); +}); + +describe("previewText", () => { + it("is the first line that has anything on it, with mention links shown as their label", () => { + assert.equal(previewText("\n\nhi [@Carol](mention:u_1), look here\nsecond line"), "hi @Carol, look here"); + }); + + it("says what was sent when there's no text", () => { + assert.equal(previewText("", 1), "Sent an attachment"); + assert.equal(previewText(null, 3), "Sent 3 attachments"); + assert.equal(previewText(" "), "New message"); + }); +}); diff --git a/src/services/pushPreview.ts b/src/services/pushPreview.ts new file mode 100644 index 00000000..897faed9 --- /dev/null +++ b/src/services/pushPreview.ts @@ -0,0 +1,66 @@ +import { createCipheriv, createHash, randomBytes } from "node:crypto"; + +import { getServerChannel, getServerConfig } from "../db"; + +/* A push's preview, sealed to the phone's key so the relay can't read it (GRYT-1688). AES-256-GCM: + 0x01 | nonce | ciphertext | tag, base64url; AAD "gryt-push-1|" + capability tag; JSON {t, s?, b}. */ + +export interface PushPreview { + title: string; + subtitle?: string; + body: string; +} + +/** 32 random bytes, base64url without padding. */ +export const PREVIEW_KEY_SHAPE = /^[A-Za-z0-9_-]{43}$/; + +const VERSION = 1; +const TITLE_MAX = 64; +const BODY_MAX = 140; + +/** The relay puts the same tag on every push, so the phone knows the server, and seals bind to it. */ +export function capabilityTag(capability: string): string { + return createHash("sha256").update(capability).digest("hex").slice(0, 16); +} + +export function sealPreview(previewKey: string, capability: string, preview: PushPreview): string { + const key = Buffer.from(previewKey, "base64url"); + if (key.length !== 32) throw new Error("a preview key is 32 bytes"); + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + cipher.setAAD(Buffer.from(`gryt-push-1|${capabilityTag(capability)}`)); + const plain = JSON.stringify({ + t: clip(preview.title, TITLE_MAX), + ...(preview.subtitle ? { s: clip(preview.subtitle, TITLE_MAX) } : {}), + b: clip(preview.body, BODY_MAX), + }); + const sealed = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + return Buffer.concat([Buffer.from([VERSION]), nonce, sealed, cipher.getAuthTag()]).toString("base64url"); +} + +/** A message's first line as it reads: a mention link shows its label, and runs of space collapse. */ +export function previewText(markdown: string | null | undefined, attachmentCount = 0): string { + const readable = (markdown ?? "") + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .split("\n") + .map((line) => line.replace(/\s+/g, " ").trim()) + .find((line) => line.length > 0); + if (readable) return clip(readable, BODY_MAX); + if (attachmentCount === 1) return "Sent an attachment"; + if (attachmentCount > 1) return `Sent ${attachmentCount} attachments`; + return "New message"; +} + +/** By code point, so an emoji is never cut in half. */ +function clip(text: string, max: number): string { + const chars = [...text]; + return chars.length <= max ? text : `${chars.slice(0, max - 1).join("")}鈥; +} + +/** "Alice", "#general 路 Gryt", and the first line. A DM leaves the channel out. */ +export async function messagePreview(o: { sender: string | null | undefined; channelId?: string; body: string }): Promise { + const cfg = await getServerConfig().catch(() => null); + const server = cfg?.display_name || process.env.SERVER_NAME || "Gryt"; + const channel = o.channelId ? (await getServerChannel(o.channelId).catch(() => null))?.name : null; + return { title: o.sender || "Someone", subtitle: channel ? `#${channel} 路 ${server}` : server, body: o.body }; +} diff --git a/src/socket/handlers/chat.ts b/src/socket/handlers/chat.ts index f059ab4c..42126aab 100644 --- a/src/socket/handlers/chat.ts +++ b/src/socket/handlers/chat.ts @@ -59,6 +59,7 @@ import { MESSAGE_MAX_LENGTH, MESSAGE_TOO_LONG, SEALED_MAX_LENGTH } from "../../u import { applyAutoRoles } from "../../services/autoRoles"; import { findMentions, type MentionableMember } from "../../services/mentions"; import { pushNotify } from "../../services/push"; +import { messagePreview, previewText } from "../../services/pushPreview"; import { canonicalizeMentions, type CanonicalMentions, type MentionRights } from "../../services/mentionSyntax"; import { massMentionAudience } from "../../services/massMentions"; import { mayInChannel, mayViewChannel, visibleChannelIds } from "../../services/channelPermissions"; @@ -739,11 +740,18 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { It still sent this, and without the echo its row never settles. */ if (nonce && !recipients.includes(clientId)) socket.emit("chat:new", { ...enriched, nonce }); + // What a phone shows for this message, built only if one is about to be pushed (GRYT-1688). + const pushPreview = () => messagePreview({ + sender: user.nickname, + channelId: access.kind === "dm" ? undefined : created.conversation_id, + body: sealed ? "New direct message" : previewText(created.text, created.attachments?.length ?? 0), + }); + if (access.kind === "dm") { try { const blockers = await blockersOfSender(auth.tokenPayload.serverUserId); const others = access.memberIds.filter((id) => id !== auth.tokenPayload.serverUserId && !blockers.has(id)); - pushNotify(clientsInfo, others, "dm", created.conversation_id); + pushNotify(clientsInfo, others, "dm", created.conversation_id, { preview: pushPreview }); } catch (err) { consola.warn("dm push failed", created.message_id, err); } @@ -834,8 +842,11 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { if (kind === "user" && !(await mayViewChannel(created.conversation_id, id))) continue; (kind === "user" || kind === "role" ? woken : crowd).push(id); } - pushNotify(clientsInfo, woken, "mention", created.conversation_id); - pushNotify(clientsInfo, crowd, "mention", created.conversation_id, (device) => device.everyone); + pushNotify(clientsInfo, woken, "mention", created.conversation_id, { preview: pushPreview }); + pushNotify(clientsInfo, crowd, "mention", created.conversation_id, { + accept: (device) => device.everyone, + preview: pushPreview, + }); } } catch (err) { consola.warn("recording mentions failed", created.message_id, err); @@ -855,7 +866,10 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { loud.push(id); } const conversationId = created.conversation_id; - pushNotify(clientsInfo, loud, "message", conversationId, (device) => device.loud.has(conversationId)); + pushNotify(clientsInfo, loud, "message", conversationId, { + accept: (device) => device.loud.has(conversationId), + preview: pushPreview, + }); } catch (err) { consola.warn("message push failed", created.message_id, err); } diff --git a/src/socket/handlers/mls.ts b/src/socket/handlers/mls.ts index b9e463e8..2f235a9a 100644 --- a/src/socket/handlers/mls.ts +++ b/src/socket/handlers/mls.ts @@ -43,6 +43,7 @@ import { mayInChannel } from "../../services/channelPermissions"; import { fileReadVerdict } from "../../services/fileAccess"; import { asBytes, parseGroupMessage, parseKeyPackage, parseWelcome } from "../../services/mlsWire"; import { pushNotify } from "../../services/push"; +import { messagePreview } from "../../services/pushPreview"; import { SEALED_MAX_LENGTH } from "../../utils/messageLimits"; import { appendCachedMessage } from "../utils/messageCache"; import { formatMlsPlaceholder, SYSTEM_SENDER_ID } from "../utils/systemMessages"; @@ -709,7 +710,9 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap { if (application && payload.placeholder !== false) { try { const blockers = await blockersOfSender(self); - pushNotify(clientsInfo, memberIds.filter((id) => id !== self && !blockers.has(id)), "dm", payload.conversationId); + // Who and which server, never what: the server can't read an MLS message (GRYT-1688). + const preview = async () => messagePreview({ sender: (await getUserByServerId(self))?.nickname, body: "New direct message" }); + pushNotify(clientsInfo, memberIds.filter((id) => id !== self && !blockers.has(id)), "dm", payload.conversationId, { preview }); } catch (err) { consola.warn("mls push failed", payload.conversationId, err); } diff --git a/src/socket/handlers/push.ts b/src/socket/handlers/push.ts index 434dcc2b..48be8d0a 100644 --- a/src/socket/handlers/push.ts +++ b/src/socket/handlers/push.ts @@ -2,6 +2,7 @@ import consola from "consola"; import { removePushDevice, savePushDevice } from "../../db"; import { CAPABILITY_SHAPE, pushEnabled } from "../../services/push"; +import { PREVIEW_KEY_SHAPE } from "../../services/pushPreview"; import { checkRateLimit, RateLimitRule } from "../../utils/rateLimiter"; import { requireAuth } from "../middleware/auth"; import type { EventHandlerMap, HandlerContext } from "./types"; @@ -35,7 +36,7 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { return { "push:register": async ( - payload: { accessToken: string; installId?: unknown; capability?: unknown; muted?: unknown; all?: unknown; everyone?: unknown }, + payload: { accessToken: string; installId?: unknown; capability?: unknown; muted?: unknown; all?: unknown; everyone?: unknown; previewKey?: unknown }, ack: Ack, ) => { ack = typeof ack === "function" ? ack : () => {}; @@ -48,7 +49,9 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { } const muted = idsFrom(payload.muted); const loud = idsFrom(payload.all); - if (!muted || !loud || (payload.everyone !== undefined && typeof payload.everyone !== "boolean")) { + const previewKey = payload.previewKey; + if (!muted || !loud || (payload.everyone !== undefined && typeof payload.everyone !== "boolean") + || (previewKey !== undefined && (typeof previewKey !== "string" || !PREVIEW_KEY_SHAPE.test(previewKey)))) { return ack({ ok: false, error: "invalid_payload" }); } const auth = await requireAuth(socket, payload); @@ -59,6 +62,7 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { muted, loud: loud.filter((id) => !quiet.has(id)), everyone: payload.everyone === true, + previewKey: typeof previewKey === "string" ? previewKey : null, }); ack({ ok: true }); } catch (err) { diff --git a/src/socket/handlers/pushAudience.test.ts b/src/socket/handlers/pushAudience.test.ts index d9280fd5..711bee84 100644 --- a/src/socket/handlers/pushAudience.test.ts +++ b/src/socket/handlers/pushAudience.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createDecipheriv, createHash, randomBytes } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; @@ -107,11 +108,16 @@ async function member(name: string, roleId: string): Promise { } /** Through the handler, as the phone does it. A bare list is what it mutes. */ -async function register(m: Member, settings: string[] | { muted?: string[]; all?: string[]; everyone?: boolean } = {}): Promise { - const { muted, all, everyone } = Array.isArray(settings) ? { muted: settings, all: undefined, everyone: undefined } : settings; +async function register( + m: Member, + settings: string[] | { muted?: string[]; all?: string[]; everyone?: boolean; previewKey?: string } = {}, +): Promise { + const { muted, all, everyone, previewKey } = Array.isArray(settings) + ? { muted: settings, all: undefined, everyone: undefined, previewKey: undefined } + : settings; const reply = await new Promise((resolve) => m.handlers["push:register"]( - { accessToken: m.accessToken, installId: `install-${m.name}`, capability: m.capability, muted, all, everyone }, + { accessToken: m.accessToken, installId: `install-${m.name}`, capability: m.capability, muted, all, everyone, previewKey }, resolve, ), ); @@ -362,6 +368,58 @@ describe("every message, for a phone at All", () => { }); }); +/** What the phone's extension does with a preview, written from the format. */ +function openPreview(previewKey: string, capability: string, blob: string): unknown { + const raw = Buffer.from(blob, "base64url"); + const decipher = createDecipheriv("aes-256-gcm", Buffer.from(previewKey, "base64url"), raw.subarray(1, 13)); + decipher.setAAD(Buffer.from(`gryt-push-1|${createHash("sha256").update(capability).digest("hex").slice(0, 16)}`)); + decipher.setAuthTag(raw.subarray(raw.length - 16)); + return JSON.parse(Buffer.concat([decipher.update(raw.subarray(13, raw.length - 16)), decipher.final()]).toString("utf8")); +} + +describe("previews sealed to the phone (GRYT-1688)", () => { + const key = randomBytes(32).toString("base64url"); + + it("a mention shows who, where, and the line, and only the phone's key opens it", async () => { + await register(carol, { previewKey: key }); + away(carol); + await send(alice, OPEN, "@Carol the build is green"); + assert.deepEqual(await woken(), ["Carol:mention"]); + const body = JSON.parse(hits[0].body) as { kind: string; preview: string }; + assert.ok(!hits[0].body.includes("green"), "the relay could read the message"); + assert.deepEqual(openPreview(key, carol.capability, body.preview), { + t: "Alice", s: "#General 路 Gryt", b: "@Carol the build is green", + }); + }); + + it("a direct message leaves the channel out", async () => { + await register(dave, { previewKey: key }); + const dm = await openDm(erin, dave); + away(dave); + await send(erin, dm, "lunch?"); + assert.deepEqual(await woken(), ["Dave:dm"]); + const body = JSON.parse(hits[0].body) as { preview: string }; + assert.deepEqual(openPreview(key, dave.capability, body.preview), { t: "Erin", s: "Gryt", b: "lunch?" }); + }); + + it("a phone without a key gets the relay's fixed text, as before", async () => { + away(carol); + await send(alice, OPEN, line("@Carol no key")); + assert.deepEqual(await woken(), ["Carol:mention"]); + assert.deepEqual(JSON.parse(hits[0].body), { kind: "mention" }); + }); + + it("refuses a key that isn't 32 bytes of base64url", async () => { + const reply = await new Promise((resolve) => + carol.handlers["push:register"]( + { accessToken: carol.accessToken, installId: "install-Carol", capability: carol.capability, previewKey: "short" }, + resolve, + ), + ); + assert.deepEqual(reply, { ok: false, error: "invalid_payload" }); + }); +}); + describe("direct messages", () => { it("wake the other person and never the sender", async () => { const dm = await openDm(alice, dave);