Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion src/db/sqlite/pushDevices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), []);
});
Expand Down
14 changes: 10 additions & 4 deletions src/db/sqlite/pushDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ export interface PushDevice {
loud: ReadonlySet<string>;
/** 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. */
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. */
Expand Down Expand Up @@ -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(
Expand All @@ -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,
}));
}

Expand Down
45 changes: 40 additions & 5 deletions src/services/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(), loud: new Set<string>(), 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<string>(), loud: new Set<string>(), everyone: false }] });
const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set<string>(), loud: new Set<string>(), 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<string>(), loud: new Set<string>(), everyone: false }] });
const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set<string>(), loud: new Set<string>(), everyone: false, previewKey: null }] });
h.pusher.notify({ s: client("u1") }, ["u1"], "mention", "conv");
await flush();
assert.equal(h.calls.length, 0);
Expand All @@ -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<string>(), loud: new Set<string>(), everyone: false }, { installId: "tablet-12", capability: CAP_B, muted: new Set<string>(), loud: new Set<string>(), everyone: false }] });
const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set<string>(), loud: new Set<string>(), everyone: false, previewKey: null }, { installId: "tablet-12", capability: CAP_B, muted: new Set<string>(), loud: new Set<string>(), everyone: false, previewKey: null }] });
h.pusher.notify({}, ["u1"], "dm", "conv");
h.pusher.notify({}, ["u1", "u1"], "dm", "conv");
h.pusher.notify({}, ["u1"], "dm", "other");
Expand All @@ -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<string>(), loud: new Set<string>(), everyone: false }] }, status);
const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set<string>(), loud: new Set<string>(), everyone: false, previewKey: null }] }, status);
h.pusher.notify({}, ["u1"], "dm", "conv");
await flush();
await flush();
Expand All @@ -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<string>(), loud: new Set<string>(), everyone: false }] }, 502);
const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set<string>(), loud: new Set<string>(), everyone: false, previewKey: null }] }, 502);
h.pusher.notify({}, ["u1"], "dm", "conv");
await flush();
await flush();
Expand Down
28 changes: 22 additions & 6 deletions src/services/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<PushPreview | null>;
}

export const DEFAULT_PUSH_RELAY = "https://push.gryt.chat";

/** Same shape the relay hands out. Anything else is refused before it is stored. */
Expand Down Expand Up @@ -58,12 +65,15 @@ export function createPusher(deps: PusherDeps) {
const lastSent = new Map<string, number>();
const inFlight = new Set<Promise<void>>();

async function send(device: PushDevice, kind: PushKind): Promise<void> {
async function send(device: PushDevice, kind: PushKind, preview: (() => Promise<PushPreview | null>) | null): Promise<void> {
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.
Expand All @@ -80,10 +90,16 @@ export function createPusher(deps: PusherDeps) {
serverUserIds: Iterable<string>,
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<PushPreview | null> | 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[];
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -129,7 +145,7 @@ export function pushNotify(
serverUserIds: Iterable<string>,
kind: PushKind,
conversationId: string,
accept?: PushAccept,
options?: PushOptions,
): void {
shared ??= createPusher({
relay: configuredRelay(),
Expand All @@ -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. */
Expand Down
69 changes: 69 additions & 0 deletions src/services/pushPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
66 changes: 66 additions & 0 deletions src/services/pushPreview.ts
Original file line number Diff line number Diff line change
@@ -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<PushPreview> {
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 };
}
Loading
Loading