From 586c6cf7b7ac023fabd1fa4dbf868d41a3cb013b Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:35:40 +0100 Subject: [PATCH] Put the conversation and message in a push's sealed preview (GRYT-1976) A push only carried a server tag and the sealed text, so tapping one could only open the server. The phone had nothing else to go on. The conversation id, the message id and a DM flag now go inside the seal, so the relay still can't read them. A thread reply and an MLS message send no message id, and the tap stops at the conversation. Co-Authored-By: Claude Opus 5.5 --- src/services/pushPreview.test.ts | 7 +++++++ src/services/pushPreview.ts | 26 +++++++++++++++++++++--- src/socket/handlers/chat.ts | 3 +++ src/socket/handlers/mls.ts | 2 +- src/socket/handlers/pushAudience.test.ts | 16 +++++++++++++-- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/services/pushPreview.test.ts b/src/services/pushPreview.test.ts index d2bb11c..f3d2c58 100644 --- a/src/services/pushPreview.test.ts +++ b/src/services/pushPreview.test.ts @@ -28,6 +28,13 @@ describe("sealPreview", () => { assert.deepEqual(open(key, CAP, blob), { t: "Alice", s: "#general · Gryt", b: "hey there" }); }); + it("carries where a tap should land, and leaves it out when there's no such place", () => { + const blob = sealPreview(key, CAP, { title: "Alice", body: "hi", conversationId: "dm_1", messageId: "m1", dm: true }); + assert.deepEqual(open(key, CAP, blob), { t: "Alice", b: "hi", o: "dm_1", m: "m1", d: 1 }); + const bare = sealPreview(key, CAP, { title: "Alice", body: "hi", conversationId: "general" }); + assert.deepEqual(open(key, CAP, bare), { t: "Alice", b: "hi", o: "general" }); + }); + it("uses the same tag the relay puts on the push", () => { assert.equal(capabilityTag(CAP), createHash("sha256").update(CAP).digest("hex").slice(0, 16)); }); diff --git a/src/services/pushPreview.ts b/src/services/pushPreview.ts index 897faed..e443f96 100644 --- a/src/services/pushPreview.ts +++ b/src/services/pushPreview.ts @@ -3,12 +3,16 @@ 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}. */ + 0x01 | nonce | ciphertext | tag, base64url; AAD "gryt-push-1|" + capability tag; JSON {t, s?, b, o?, m?, d?}. */ export interface PushPreview { title: string; subtitle?: string; body: string; + /** Where a tap lands: the conversation, the message when one can be jumped to, and whether it's a DM. */ + conversationId?: string; + messageId?: string; + dm?: boolean; } /** 32 random bytes, base64url without padding. */ @@ -33,6 +37,9 @@ export function sealPreview(previewKey: string, capability: string, preview: Pus t: clip(preview.title, TITLE_MAX), ...(preview.subtitle ? { s: clip(preview.subtitle, TITLE_MAX) } : {}), b: clip(preview.body, BODY_MAX), + ...(preview.conversationId ? { o: preview.conversationId } : {}), + ...(preview.messageId ? { m: preview.messageId } : {}), + ...(preview.dm ? { d: 1 } : {}), }); const sealed = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); return Buffer.concat([Buffer.from([VERSION]), nonce, sealed, cipher.getAuthTag()]).toString("base64url"); @@ -58,9 +65,22 @@ function clip(text: string, max: number): string { } /** "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 { +export async function messagePreview(o: { + sender: string | null | undefined; + channelId?: string; + body: string; + conversationId: string; + messageId?: 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 }; + return { + title: o.sender || "Someone", + subtitle: channel ? `#${channel} · ${server}` : server, + body: o.body, + conversationId: o.conversationId, + ...(o.messageId ? { messageId: o.messageId } : {}), + ...(o.channelId ? {} : { dm: true }), + }; } diff --git a/src/socket/handlers/chat.ts b/src/socket/handlers/chat.ts index de43cbb..fea6bc4 100644 --- a/src/socket/handlers/chat.ts +++ b/src/socket/handlers/chat.ts @@ -819,6 +819,9 @@ export function registerChatHandlers(ctx: HandlerContext): EventHandlerMap { sender: user.nickname, channelId: access.kind === "dm" ? undefined : created.conversation_id, body: sealed ? "New direct message" : previewText(created.text, created.attachments?.length ?? 0), + conversationId: created.conversation_id, + // A thread reply isn't in the channel's list, so that tap stops at the channel. + messageId: threadId ? undefined : created.message_id, }); if (access.kind === "dm") { diff --git a/src/socket/handlers/mls.ts b/src/socket/handlers/mls.ts index fca715e..7c8c673 100644 --- a/src/socket/handlers/mls.ts +++ b/src/socket/handlers/mls.ts @@ -771,7 +771,7 @@ export function registerMlsHandlers(ctx: HandlerContext): EventHandlerMap { try { const blockers = await blockersOfSender(self); // 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" }); + const preview = async () => messagePreview({ sender: (await getUserByServerId(self))?.nickname, body: "New direct message", conversationId: payload.conversationId }); 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/pushAudience.test.ts b/src/socket/handlers/pushAudience.test.ts index 5b7a86e..f469873 100644 --- a/src/socket/handlers/pushAudience.test.ts +++ b/src/socket/handlers/pushAudience.test.ts @@ -390,7 +390,13 @@ describe("previews sealed to the phone (GRYT-1688)", () => { 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), { + const opened = openPreview(key, carol.capability, body.preview) as Record; + assert.equal(opened.o, OPEN); + assert.equal(typeof opened.m, "string", "a tap lands on the message"); + assert.equal(opened.d, undefined); + delete opened.o; + delete opened.m; + assert.deepEqual(opened, { t: "Alice", s: "#General · Gryt", b: "@Carol the build is green", }); }); @@ -402,7 +408,13 @@ describe("previews sealed to the phone (GRYT-1688)", () => { 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?" }); + const opened = openPreview(key, dave.capability, body.preview) as Record; + assert.equal(opened.d, 1, "a tap opens it on the Messages tab"); + assert.equal(opened.o, dm); + delete opened.o; + delete opened.m; + delete opened.d; + assert.deepEqual(opened, { t: "Erin", s: "Gryt", b: "lunch?" }); }); it("a phone without a key gets the relay's fixed text, as before", async () => {