diff --git a/README.md b/README.md index e1be709..f974940 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ phone each random ID belongs to. to deliver it. The relay doesn't know who you are, which servers you use, or what anybody wrote. -The notification text is fixed here, so a server can't put anything in it. -It says "Someone mentioned you", "New direct message" or "New message". +The text the relay writes is fixed: "Someone mentioned you", "New direct message" or +"New message". A server can add a preview, but it's sealed to the phone, so only the +phone can read it and the relay just passes it along. Tapping it opens that server in the app, and the messages load from there. Capabilities are stored as SHA-256 hashes. A copy of the database has device tokens @@ -27,12 +28,17 @@ in it but nothing that can make a phone ring. ``` POST /v1/devices {"platform": "ios"|"android", "token": "...", "env": "production"|"sandbox"} → 201 {"capability": "p_..."} -POST /v1/push Authorization: Bearer p_... {"kind": "mention"|"dm"|"message"} +POST /v1/push Authorization: Bearer p_... {"kind": "mention"|"dm"|"message", "preview"?: "..."} → 202, or 404 unknown, 410 the phone is gone, 429, 502 DELETE /v1/push Authorization: Bearer p_... → 204 or 404 GET /healthz ``` +A push can also carry `"preview"`: who wrote, where and the first line, sealed by the +server to a key only the phone has (AES-256-GCM, base64url). The relay checks its shape, +passes it on unread, and still sends its own fixed text. On iOS a notification extension +in the app opens the preview and shows it instead. If it can't, the fixed text stays. + A 410 means Apple or Google said the token is dead. The relay has already forgotten it, and the server should forget the capability too. diff --git a/src/apns.ts b/src/apns.ts index 4ebfd0e..82af9bf 100644 --- a/src/apns.ts +++ b/src/apns.ts @@ -30,8 +30,11 @@ export function apnsPayload(alert: Alert): string { alert: { title: alert.title, body: alert.body }, sound: "default", "thread-id": alert.tag, + // Wakes the phone's notification extension, which swaps in the decrypted preview. + ...(alert.preview ? { "mutable-content": 1 } : {}), }, c: alert.tag, + ...(alert.preview ? { p: alert.preview } : {}), }); } diff --git a/src/app.test.ts b/src/app.test.ts index 7a0a01b..7b0d444 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -3,7 +3,9 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, it } from "node:test"; +import { apnsPayload } from "./apns.ts"; import { createHandler, type Senders } from "./app.ts"; +import { fcmMessage } from "./fcm.ts"; import { capabilityTag, hashCapability } from "./caps.ts"; import { loadConfig } from "./config.ts"; import { Store } from "./db.ts"; @@ -70,6 +72,35 @@ describe("push relay", () => { ]); }); + it("passes a sealed preview through unread, and asks iOS to wake the extension", async () => { + const cap = await register(); + const preview = "AQ" + "x".repeat(60); + const res = await post("/v1/push", { kind: "message", preview }, cap); + assert.equal(res.status, 202); + assert.equal(sent[0].alert.preview, preview); + assert.equal(sent[0].alert.body, "New message", "the fixed text is still there for when the extension can't open it"); + const apns = JSON.parse(apnsPayload(sent[0].alert)); + assert.equal(apns.aps["mutable-content"], 1); + assert.equal(apns.p, preview); + assert.equal((fcmMessage("t", sent[0].alert) as { message: { data: { p: string } } }).message.data.p, preview); + }); + + it("leaves a push without a preview exactly as it was", async () => { + const cap = await register(); + await post("/v1/push", { kind: "dm" }, cap); + assert.equal(sent[0].alert.preview, undefined); + assert.ok(!("mutable-content" in JSON.parse(apnsPayload(sent[0].alert)).aps)); + }); + + it("refuses a preview that isn't the shape the server writes", async () => { + const cap = await register(); + for (const preview of [42, "short", "has spaces in it and is long enough to pass the length check", "x".repeat(2049)]) { + const res = await post("/v1/push", { kind: "dm", preview }, cap); + assert.equal(res.status, 400, JSON.stringify(preview).slice(0, 30)); + } + assert.equal(sent.length, 0); + }); + it("stores a hash of the capability, never the capability", async () => { const cap = await register(); const rows = store.db.prepare("SELECT * FROM devices").all() as Record[]; diff --git a/src/app.ts b/src/app.ts index dfa96f5..4bb05ee 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import type { Config } from "./config.ts"; import type { ApnsEnv, Platform, Store } from "./db.ts"; import { clientIp, header, HttpError, readJson, sendJson } from "./http.ts"; import { RateLimiter } from "./limits.ts"; -import { alertFor, KINDS, type Alert, type Kind, type SendResult } from "./notification.ts"; +import { alertFor, KINDS, PREVIEW_SHAPE, type Alert, type Kind, type SendResult } from "./notification.ts"; export interface Senders { ios?: (token: string, env: ApnsEnv, alert: Alert) => Promise; @@ -58,6 +58,8 @@ export function createHandler(config: Config, store: Store, senders: Senders, lo const body = await readJson(req, MAX_BODY); const kind = body.kind as Kind; if (!KINDS.includes(kind)) throw new HttpError(400, "bad_kind"); + const preview = body.preview; + if (preview !== undefined && (typeof preview !== "string" || !PREVIEW_SHAPE.test(preview))) throw new HttpError(400, "bad_preview"); const now = clock(); if (!perIp.take(ip, now)) throw new HttpError(429, "too_many_pushes"); @@ -66,7 +68,7 @@ export function createHandler(config: Config, store: Store, senders: Senders, lo if (!device) throw new HttpError(404, "unknown_capability"); if (!perDevice.take(capHash, now)) throw new HttpError(429, "too_many_pushes"); - const alert = alertFor(kind, capabilityTag(cap)); + const alert = alertFor(kind, capabilityTag(cap), preview as string | undefined); const result = device.platform === "ios" ? await senders.ios?.(device.token, device.env, alert) : await senders.android?.(device.token, alert); diff --git a/src/fcm.ts b/src/fcm.ts index 35bc019..ddb80f7 100644 --- a/src/fcm.ts +++ b/src/fcm.ts @@ -21,7 +21,7 @@ export function fcmMessage(deviceToken: string, alert: Alert): object { token: deviceToken, notification: { title: alert.title, body: alert.body }, android: { priority: "high", notification: { channel_id: "messages", sound: "default" } }, - data: { c: alert.tag }, + data: { c: alert.tag, ...(alert.preview ? { p: alert.preview } : {}) }, }, }; } diff --git a/src/notification.ts b/src/notification.ts index 4b75691..acf2399 100644 --- a/src/notification.ts +++ b/src/notification.ts @@ -8,16 +8,21 @@ export interface Alert { body: string; /** Lets the phone tell which of its servers this came from. */ tag: string; + /** Sealed by the server to the phone (GRYT-1688). Passed through unread; the text above is the fallback. */ + preview?: string; } +/** base64url of a version byte, a nonce, ciphertext and tag. Its shape only: the relay never opens it. */ +export const PREVIEW_SHAPE = /^[A-Za-z0-9_-]{40,2048}$/; + const BODY: Record = { mention: "Someone mentioned you", dm: "New direct message", message: "New message", }; -export function alertFor(kind: Kind, tag: string): Alert { - return { title: "Gryt", body: BODY[kind], tag }; +export function alertFor(kind: Kind, tag: string, preview?: string): Alert { + return { title: "Gryt", body: BODY[kind], tag, ...(preview ? { preview } : {}) }; } export type SendResult = { ok: true } | { ok: false; gone: boolean; reason: string };