
The Server Can't Read Your Direct Messages
Direct messages shipped unencrypted and said so. Two weeks later they were encrypted on your machine, and twice along the way it quietly stopped working.
Title: The server can't read your direct messages
Date: 9 September 2026
Author: Sivert Gullberg Hansen
Keywords: end-to-end encryption, ECDSA, key verification, direct messages, @noble/curves
Abstract
Direct messages shipped in 1.7 unencrypted, with a line above every conversation saying the server could read it. About two weeks later they were encrypted on your machine before they left it: every message gets its own key, wrapped for each person in the conversation, and attachments are encrypted before upload. Getting there turned up three ways encryption could fail without anything on screen saying so. About half of all signatures would have been rejected at random. Members who joined before the feature shipped never published a key. And an account on two devices looked like a swapped key. All three are fixed, and the tests that missed them now can't. A short code you compare in person covers what remembering a key can't: that the first key was ever theirs. Channels aren't encrypted, and don't claim to be.
1 Introduction
Your server can't read your direct messages. It doesn't have the key. Messages are encrypted on your machine before they leave it, and what the server stores is something it has no way to open. There's no setting for this and no way to turn it off.
That wasn't true when direct messages shipped in 1.7 [1]. Every conversation had a line above it then, saying the server could read what was in it. That line was honest, and it took about two weeks to make it unnecessary. This post answers three questions:
- How are direct messages encrypted?
- How did encryption fail without anyone noticing, and how is that caught now?
- How do you know a key really belongs to the person you're talking to?
2 Design
2.1 A key per message
Every message gets its own key. That key is then encrypted separately for each person in the conversation, using the public key they published, and those encrypted copies travel with the message. The server stores all of it and can open none of it.
Attachments work the same way. They used to go up as ordinary files, get a thumbnail made, and be served to anyone with the link, so a private conversation could have public photographs in it. Now the file is encrypted before it's uploaded and its key sits inside the message.
2.2 Everyone or nobody
Gryt only encrypts when everyone in the conversation has published a key it will accept. Encrypting for four people out of five leaves the fifth staring at a conversation they can't read while everyone else sees it working, which is worse than not encrypting at all. When it can't encrypt, the line above the messages comes back and says why.
2.3 One crypto library on both apps
The desktop app used the browser's built-in crypto [2]. The phone can't:
crypto.subtle doesn't exist in React Native, so mobile was already using
@noble/curves [3]. Running two implementations of the same format is how you get two
apps that can't read each other's messages, with each sender seeing their own text
just fine. So the desktop moved to noble as well. Nothing about the message format
changed.
3 Results: three silent failures
Table 1 lists what we found. None of them showed an error.
Table 1: Ways encryption stopped working without saying so.
| Failure | Who it hit | What it looked like |
|---|---|---|
High-s signatures rejected | About half of all clients, at random | "The signature does not check out" |
| Existing members never published a key | Everyone who joined before the feature shipped | "They haven't published a key yet" |
| Each device made its own signing key | Anyone signed in on two devices | A key that kept changing, so others stopped encrypting |
3.1 Half of every signature would have been rejected
After the move to noble the test vectors failed. ECDSA has two valid signatures for
every message: s, and order - s. Noble rejects the high one by default. That's a
sensible default for a blockchain, where a signature you can rewrite and keep valid
means a transaction you can replay under a second id [4]. Gryt doesn't identify
anything by its signature, so the default is wrong here.
The browser's crypto doesn't normalise, and neither does JOSE [5]. So about half of all ES256 signatures come out high. Leaving noble's default on would have rejected roughly half of every client's published keys, at random. The other half would have worked perfectly.
A bug like that survives a lot of testing. It would have turned up months later as
"encryption sometimes doesn't work", with nothing anyone could reproduce. It only
came out now because the one test vector happened to have a high s, which was luck.
The check signs 24 of them now and fails unless at least one comes out high, so a
future run can't pass on a lucky sample.
3.2 Nobody who was already a member published a key
Publishing ran from the code that handles joining a server. A client that already has a token never joins again. It reconnects instead, and reconnecting runs different code. So encryption worked for anyone who joined a server after the feature shipped, and for nobody who was already in one.
Nothing failed. No key left the device, nothing errored, and the message box said the other person hadn't published a key yet, which was true. That made it look like their problem rather than a bug.
There was a test meant to catch this. It checked that both code paths called the publish function, and both of them did. The function returned early before sending anything. The test reads the function itself now, and fails if anything can return before the key goes out.
3.3 Two devices looked like a swapped key
Your client remembers two things about everyone you talk to: their message key, and a fingerprint of the key that vouched for it. If either changes, your client stops encrypting, because a key that changed is what a swapped key looks like.
Your message key comes from your seed, so every device you own produces the same one. The signing key didn't work that way. Each device generated its own, so a laptop and a phone signed into one account vouched for the same message key with two different signatures. Whichever spoke last was what the server held, and everyone else watched the fingerprint flip back and forth. Their clients refused to encrypt, which is exactly what they should have done. The people most likely to own two devices were the ones it was off for.
3.4 A warning that was almost always wrong
For a while, joining a server could tell you it was showing a message key that wasn't yours, and that you should treat your direct messages as readable by it.
It went off because of timing. Your client publishes its key when it joins, and the first member list usually arrives before that has landed, so for about a second the key genuinely doesn't match. The warning had no dismiss button and no expiry, so that one second stayed on screen for the rest of the session. It also pointed at the wrong thing. A hostile server can cause this. What actually caused it, nearly every time, was signing in on a second device.
It waits five seconds now, disappears as soon as the keys agree, and mentions the second device first.
4 Comparing a code in person
Remembering someone's key catches that key changing later. It can't tell you the first one was ever theirs, because the server is what introduced the two of you, and anything you check through the server has the same problem. The only way around it is for the two of you to compare a short code somewhere the server isn't: on the phone, or standing in the same room.
The code is built from all four values involved, both message keys and both fingerprints. Change any one of them and the two codes come out different, and the server can't do anything about that, because it never sees you compare. The four are sorted before they're hashed, so both of you get the same code without having to agree who counts as first. Two honest people reading out codes that didn't match would conclude they were being attacked, which is worse than not checking at all.
The code is digits rather than words. Your identity backup is already 24 words, and eight more words next to them would read as a second recovery phrase: something to keep secret and type into a box. This is the opposite. You read it out loud to someone.
The button says "read this to them", and afterwards the card says "compared and matched". That's what the two of you did, rather than something Gryt is claiming to have checked for you. The mark disappears the moment either key changes. If you compared a code last year and their key has changed since, that comparison tells you nothing.
Servers have a fingerprint of their own, shown in settings (Figure 1).
Gryt's Server identities settings, listing one known server at 127.0.0.1:5003, trusted since 9 September 2026, with the six groups of its identity fingerprint underneath
Figure 1: Server identities in settings: one known server, when it was first trusted, and its fingerprint.
5 Discussion
5.1 Limitations
- Channels aren't encrypted, and don't claim to be. A direct message is between two people who picked each other. A channel is a room someone administers: people join it, leave it and get removed from it, and the server has to hand the history to whoever turned up yesterday. That's a different problem and this doesn't solve it. So if you're hosting: your server can read what's said in its channels. It can't read a direct message between two of its members.
- The first key is taken on trust unless you compare a code (Section 4).
- The softer warning doesn't fix the cause. The cause was that a second device made a whole new identity, and the message password in 1.9 [6] is what fixes that.
6 Conclusion
How are direct messages encrypted? A key per message, wrapped for each person in the conversation, with attachments encrypted before upload. Only when everyone has a key, and never for some people and not others.
How did it fail without anyone noticing? Three ways: high signatures rejected at random, existing members never publishing, and two devices looking like a swapped key. Each failure looked like somebody else's problem. The tests that missed them now check the actual signatures and the function that sends the key.
How do you know a key is theirs? Only by comparing a code with them somewhere the server isn't.
The code is in client and @gryt/crypto if you want to read it rather than take my word for it.
References
- Gryt changelog. 1.7.0.
- W3C. Web Cryptography API. https://www.w3.org/TR/WebCryptoAPI/
- Miller, P. noble-curves. https://github.com/paulmillr/noble-curves
- Wuille, P. BIP 62: Dealing with malleability. https://github.com/bitcoin/bips/blob/master/bip-0062.mediawiki
- Jones, M. JSON Web Algorithms (JWA), ES256. RFC 7518. https://www.rfc-editor.org/rfc/rfc7518
- Gryt changelog. 1.9.0.