How Softchat works
Softchat is end-to-end encrypted chat over Nostr relays. A relay stores and forwards sealed envelopes; it never sees who wrote a message, what it says or which conversation it belongs to. Everything that makes that true lives in one Rust library, and every app, in every language, calls the same code.
You can watch every step on this page happen live in the playground.
The pieces
- your appScreens and platformUI, notifications, files, HTTP, background work and where the key is kept.
- SDKIdentityHolds the secret key, signs, encrypts and decrypts. The key never leaves it.
- SDKEvents and envelopesBuilds chat events and wraps each one in two encrypted layers.
- SDKRelay sessionDecides every frame to send and what every received frame means.
- SDKAccount databaseOn Android: stores verified envelopes and answers the app's reads.
- networkRelayStores envelopes for each recipient and forwards new ones.
The SDK is the same Rust code compiled for Rust, Android, Swift and the browser (WebAssembly). The app keeps what belongs to the platform: the boundary guide lists both sides.
Sending a message
- SDKWrite the rumorA kind-14 chat event with the text, the other members and an optional reply. It is never signed.
- SDKSeal itEncrypt the rumor for one recipient (NIP-44) inside a kind-13 seal that the author signs.
- SDKGift-wrap itEncrypt the seal again inside a kind-1059 wrap signed by a fresh random key.
- SDKOne wrap per personA wrap for every member, and one for the author's other devices.
- relay sessionPublishSend the wraps in one EVENTS batch and track each one until the relay's OK.
Each person gets their own wrap, encrypted only for them. The relay can see that someone received an envelope, but not from whom: the wrap's signer is a key used once and thrown away, and the times on the wrap and the seal are moved back by a random amount of up to two days.
Three layers
Signed by a one-time key. Tagged with the recipient only. The content is ciphertext.
Signed by the author's real key. No tags. The content is ciphertext for the recipient.
The text, the members and the reply. Unsigned, so a leaked rumor cannot be proven to come from its author.
Receiving
- relay sessionAuthenticateAnswer the relay's NIP-42 challenge with a signed event, so it serves this key's inbox only.
- relay sessionCatch upCompare what the device has with what the relay has (Negentropy) and fetch only what is missing.
- relay sessionStay liveKeep one subscription open for new envelopes; it asks for no stored ones.
- SDKUnwrap and checkVerify the wrap, decrypt it, verify the seal, decrypt the rumor, and require the seal's signer to be the rumor's author.
- accountStoreKeep the authenticated event and the envelope's ID, and update what it changes: deletions, the conversation's newest message and count, search.
- your appShow itRead the conversation list and pages again when the account's revision changes.
Anything that fails a check is set aside with its reason and never reaches the conversation. Only a message's author can edit or delete it, so a deletion signed by someone else changes nothing; the newest edit by the author wins; a reaction its author takes back disappears. A deletion can arrive before the message it deletes: the message is stored as deleted.
Catching up without downloading everything again
The first time a key connects, the device has nothing and the relay has the whole history. Negentropy compares the two sets with a few small messages and returns the IDs the device is missing. The relay session then asks for them 500 at a time, waiting for each batch to be stored before asking for the next, so a phone never receives more than it can process. Large histories are compared in pages of the newest events first. When synchronization completes, the account saves a checkpoint; the next sync starts from there, three days back to cover envelopes dated in the past.
The account database
On Android the SDK can own the app's database. It keeps each authenticated event exactly, and the state derived from it apart:
- EventsEach message, edit, reaction or deletion once, however many wraps carried it (push, relay, another device), with its exact rumor.
- Envelope IDsEvery wrap's ID and time, to recognise repeats and to synchronize. The wrap itself is not kept.
- ConversationsMembers, read position, newest message and message count, updated as events arrive.
- Use-case readsConversation list, pages, search and counts read these rows through indexes.
A conversation is identified by the hash of its members' keys, so nobody can add a message to a conversation they are not in. Work that depends on the whole history, such as which message is the newest one still shown or how many there are, is done once when an event arrives, so reading the conversation list costs the same with ten messages or ten thousand. Writes happen on one connection inside transactions; reads use a second, read-only connection, so a screen that refreshes while thousands of envelopes are being stored does not slow them down. Each committed write moves the account's revision forward, and that is the signal for the app to read again.
The connection
The SDK can run the connection itself on Android and other native platforms: TLS WebSockets, optional Noise encryption pinned to the relay's key, NIP-42 authentication, reconnection with backoff and delivery tracking. In the browser the page opens the WebSocket and the relay session still decides every frame, which is how the playground works.
How it was checked
The same generated history, with edits, reactions taken back, forged deletions and duplicate envelopes, was synced by the earlier Android app (its own Kotlin client and Room database) and by the app on this SDK, on a phone, from a relay running on that phone. With 6,000 messages in 120 conversations both showed exactly the same thing. The review lists the method, the numbers and what is still open.