Skip to content

Seal a push preview to the phone: who wrote, where, and the first line (GRYT-1688) - #286

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-1688-encrypted-previews
Oct 7, 2026
Merged

sivert-io merged 1 commit into
mainfrom
claude/GRYT-1688-encrypted-previews

Conversation

@sivert-io

@sivert-io sivert-io commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

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 Gryt-chat/push#3 and the phone half is Gryt-chat/mobile#316. The format is written down in Gryt-chat/docs#160.

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.

🤖 Generated with Claude Code

…e (GRYT-1688)

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 <[email protected]>
@sivert-io
sivert-io merged commit fa184e7 into main Oct 7, 2026
6 checks passed
@sivert-io
sivert-io deleted the claude/GRYT-1688-encrypted-previews branch October 7, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant