From 49220fbf775111c5e33e60c2f6146e8e1d46d09a Mon Sep 17 00:00:00 2001 From: Sivert Date: Wed, 7 Oct 2026 18:35:36 +0200 Subject: [PATCH] Seal a push preview to the phone: who wrote, where, and the first line (GRYT-1688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushes said only "Gryt 路 New message". Now a phone that sends `previewKey` with `push:register` gets "Alice", "#general 路 Gryt" and the first line. The relay, Apple and Google pass it on unread. - `previewKey` is 32 random bytes the phone made for this server, stored in a new `push_devices.preview_key` column. - `sealPreview` (`services/pushPreview.ts`) encrypts `{t, s, b}` with AES-256-GCM: `0x01`, a 12-byte nonce, the ciphertext and the tag, as base64url. The additional data is `gryt-push-1|` plus the capability's tag, so a blob only opens as that phone's push. Fresh nonce every time. - The preview is built at most once per message, and only when a phone with a key is about to be pushed. If building it fails, the push still goes out with the fixed text. - MLS DMs show the sender and the server, and the body stays "New direct message", since the server can't read them. Sealed (non-MLS) DMs likewise. The relay half is in Gryt-chat/push and the phone half in Gryt-chat/mobile (linked below). The format is in the docs (Gryt-chat/docs, linked below). What to look at: - `src/db/sqlite`: the new column and its migration. Old phones send no key and get the fixed text, unchanged. - `pushPreview.ts`: the crypto. It's Node's `aes-256-gcm`, and nothing hand-rolled beyond laying out the bytes. - A preview is message text leaving the server, encrypted, to Apple's and Google's push services. Only the phone that made the key can open it. The relay can't, and the server already had the text. Tests: - `pushPreview.test.ts` opens the blob with an independent implementation written from the format. It also checks that another key or another phone's tag doesn't open it, that two seals of one message differ, that long text clips by character and stays far under the APNs limit, and how the first line reads. - `pushAudience.test.ts` covers a mention and a DM end to end through the real handlers, and checks the message text isn't in what the relay received. A phone without a key gets `{ kind }` only. - `push.test.ts` checks the preview is built once for many phones, never when no phone has a key, and that a failure still pushes. - The same blob opens in Swift with CryptoKit through the phone's actual extension source. 2,046 tests pass. Co-Authored-By: Claude Opus 5.5 --- src/db/sqlite/connection.ts | 5 ++ src/db/sqlite/pushDevices.test.ts | 2 +- src/db/sqlite/pushDevices.ts | 14 +++-- src/services/push.test.ts | 45 ++++++++++++++-- src/services/push.ts | 28 +++++++--- src/services/pushPreview.test.ts | 69 ++++++++++++++++++++++++ src/services/pushPreview.ts | 66 +++++++++++++++++++++++ src/socket/handlers/chat.ts | 22 ++++++-- src/socket/handlers/mls.ts | 5 +- src/socket/handlers/push.ts | 8 ++- src/socket/handlers/pushAudience.test.ts | 64 ++++++++++++++++++++-- 11 files changed, 302 insertions(+), 26 deletions(-) create mode 100644 src/services/pushPreview.test.ts create mode 100644 src/services/pushPreview.ts 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);