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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions src/apns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
});
}

Expand Down
31 changes: 31 additions & 0 deletions src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>[];
Expand Down
6 changes: 4 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendResult>;
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/fcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}) },
},
};
}
Expand Down
9 changes: 7 additions & 2 deletions src/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Kind, string> = {
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 };
Loading