Repository navigation
Seal a push preview to the phone: who wrote, where, and the first line (GRYT-1688) - #286
Merged
Merged
Conversation
…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]>
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pushes said only "Gryt · New message". Now a phone that sends
previewKeywithpush:registergets "Alice", "#general · Gryt" and the first line. The relay, Apple and Google pass it on unread.previewKeyis 32 random bytes the phone made for this server, stored in a newpush_devices.preview_keycolumn.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 isgryt-push-1|plus the capability's tag, so a blob only opens as that phone's push. Fresh nonce every time.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'saes-256-gcm, and nothing hand-rolled beyond laying out the bytes.Tests:
pushPreview.test.tsopens 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.tscovers 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.tschecks the preview is built once for many phones, never when no phone has a key, and that a failure still pushes.2,046 tests pass.
🤖 Generated with Claude Code