Secure Messaging Chat App
This example builds an end-to-end encrypted group chat app on Sui. The client encrypts messages with AES-256-GCM before they leave the browser, routes them through an offchain relayer that never sees plaintext, and archives them to Walrus for decentralized persistence. Onchain Move contracts manage group membership, 7 granular permission types, and versioned encryption keys through Seal threshold encryption.
When to use this pattern
Use this pattern when you need to:
- Build a messaging or collaboration app where the server never sees plaintext content.
- Manage group membership and granular permissions (send, read, edit, delete, rotate keys) onchain.
- Implement key rotation so removed members lose access to future messages while existing members retain access to history.
- Route encrypted data through an offchain service that enforces onchain permissions without decrypting the payload.
- Store encrypted files and messages on Walrus for decentralized persistence with automatic archival.
What you learn
This example teaches:
- Permissioned groups: A
PermissionedGroup<Messaging>shared object tracks members and their permissions onchain. The relayer caches these permissions and enforces them on every API call. - Envelope encryption: The client generates a random AES-256-GCM key (the DEK), encrypts it with Seal, and stores the encrypted DEK onchain in an
EncryptionHistoryobject. The client encrypts messages with the DEK locally. Seal key servers release the DEK only to members withMessagingReaderpermission. - Key rotation: When a member is removed, the admin atomically revokes permissions and rotates the DEK in a single transaction. The removed member can no longer decrypt new messages.
- Relayer pattern: An offchain Rust service accepts encrypted messages, verifies sender signatures, checks onchain permissions through a cached membership store, and batches messages to Walrus for archival.
- File attachments: Each file is individually encrypted with the group DEK, uploaded to Walrus through the publisher API, and referenced by a
quiltPatchIdin the message metadata.
Prerequisites
- Prerequisites
-
Download and install an IDE. The following are recommended, as they offer Move extensions:
-
VSCode, corresponding Move extension
-
Emacs, corresponding Move extension
-
Vim, corresponding Move extension
-
Zed, corresponding Move extension
Alternatively, you can use the Move web IDE, which does not require a download. It does not support all functions necessary for this guide, however.
-
-
Node.js 18 or later
-
Rust toolchain (for the relayer service)
-
A Sui wallet (Slush Wallet or another compatible wallet)
This example has 3 layers and 5 actors. The diagram below shows the components and the data flow between them.
The React frontend handles wallet connection, message composition, client-side encryption and decryption, and group management. The relayer service is a Rust/Axum HTTP server that stores encrypted messages in memory, verifies sender signatures, checks onchain permissions through a cached membership store, and batches messages to Walrus for archival. Walrus stores encrypted message quilts for decentralized persistence.
The messaging Move package manages group creation, member permissions, encryption key history, and the Seal access-control gate.
Seal key servers hold threshold encryption keys and release DEK shares only when the seal_approve_reader function confirms the caller has MessagingReader permission.
The app uses a 2-layer encryption scheme called envelope encryption:
-
DEK layer (Seal): When a group is created, the SDK generates a random AES-256 key called the Data Encryption Key (DEK). The SDK encrypts the DEK with Seal and stores the encrypted DEK onchain in an
EncryptionHistoryobject. Each key rotation appends a new version. -
Message layer (AES-256-GCM): To send a message, the client fetches the current encrypted DEK from the chain, decrypts it through Seal (which verifies
MessagingReaderpermission), and uses the plaintext DEK to encrypt the message content with AES-256-GCM. The relayer only ever sees the ciphertext.
This design means Seal handles key management and access control, while the actual message encryption uses fast symmetric crypto. The relayer, Walrus, and anyone observing the network can see encrypted bytes but never the plaintext.
Setup
Follow these steps to set up the example locally.
Step 1: Clone the repo
$ git clone https://github.com/MystenLabs/sui-stack-messaging.git
$ cd sui-stack-messaging
Step 2: Build the TypeScript SDK
The chat app depends on the local @mysten/sui-stack-messaging package:
$ cd ts-sdks
$ pnpm install
$ pnpm build
Step 3: Configure the frontend
$ cd ../chat-app
$ pnpm install
$ cp .env.example .env
Edit .env with your network and service configuration. By default
VITE_SUI_NETWORK=testnet
VITE_SUI_RPC_URL=https://fullnode.testnet.sui.io:443
VITE_SUI_GRAPHQL_URL=https://sui-testnet.mystenlabs.com/graphql
VITE_RELAYER_URL=http://localhost:3000
VITE_WALRUS_PUBLISHER_URL=https://publisher.walrus-testnet.walrus.space
VITE_WALRUS_AGGREGATOR_URL=https://aggregator.walrus-testnet.walrus.space
VITE_WALRUS_EPOCHS=5
VITE_SEAL_KEY_SERVER_OBJECT_IDS=COMMA_SEPARATED_SEAL_SERVER_IDS
Configure the VITE_SEAL_KEY_SERVER_OBJECT_IDS using the list of verified key servers. You must provide at least 2 server object IDs.
Step 4: Start the relayer
$ cd relayer
$ cp .env.example .env # fill in SUI_RPC_URL (https://fullnode.testnet.sui.io:443 for testnet) and GROUPS_PACKAGE_ID (Deployed Groups SDK package ID)
The deployed Groups SDK package IDs for Mainnet and Testnet are:
| Network | Groups SDK package ID | | Testnet | 0xba8a26d42bc8b5e5caf4dac2a0f7544128d5dd9b4614af88eec1311ade11de79 | | Mainnet | 0x541840ae7df705d1c6329c22415ed61f9140a18b79b13c1c9dc7415b115c1ba8 |
Then, run the relayer:
$ cargo run
The relayer starts on http://localhost:3000 by default. Leave it running, and open a separate terminal.
Step 5: Start the frontend
In a new terminal, navigate back into the example's directory and start the app's frontend:
$ cd sui-stack-messaging/chat-app
$ npm run dev
Run the example
Open http://localhost:5173 in a browser and connect your wallet. Slush is the recommended wallet for this application. If using Slush, you must use a passphrase or imported wallet. If you use a wallet with zkLogin (such as login with Google), then you get the error Invalid public key format: Unknown signature scheme flag: 0x05. If you use another wallet like Suilet, it might not be supported and once logged in, the app might be blank.
Click + New in the sidebar to create a group. Enter a group name and optionally add member addresses. You must add at least your wallet address as a group member, otherwise you cannot message the group. The app creates a PermissionedGroup<Messaging> onchain, generates an encrypted DEK through Seal, and stores the encryption history onchain.
Select the group from the sidebar to open the chat. Type a message and press Enter. The frontend encrypts the message with the group DEK, signs it with your wallet, and sends the ciphertext to the relayer. Other group members see the message appear in real time through polling, and their clients decrypt it locally.
To test file attachments, click Attach files and select up to 10 files (5MB each). The frontend encrypts each file individually and uploads the ciphertext to Walrus before sending the message.
Key code highlights
The following steps walk through the flow:
-
The sender types a message and presses Enter. The frontend fetches the current encrypted DEK from the
EncryptionHistoryobject onchain. -
The frontend sends the encrypted DEK to Seal key servers along with a
seal_approve_readertransaction. Seal dry-runs the transaction, confirms the sender hasMessagingReaderpermission, and returns key shares. The frontend combines the shares to recover the plaintext DEK. -
The frontend encrypts the message text with AES-256-GCM using the DEK and a random 12-byte nonce. It signs the ciphertext with the wallet and POSTs the encrypted payload to the relayer.
-
The relayer verifies the sender's signature, checks
MessagingSenderpermission against its cached membership store, assigns a message order number, and stores the ciphertext in memory. A background service batches pending messages to Walrus as encrypted quilts. -
The recipient's frontend polls the relayer for new messages. It receives the ciphertext, fetches the DEK through the same Seal flow (with its own
MessagingReaderpermission check), decrypts the message locally, and verifies the sender's signature.
Errors can occur at the Seal step (session key expired, permission revoked), the relayer step (signature invalid, permission denied), or the Walrus step (upload timeout). The SDK retries transient failures and surfaces persistent errors through the useMessages hook's error state.
The following snippets are the parts of the code worth reading carefully.
Seal access-control policy
The seal_approve_reader function is the onchain gate that Seal key servers call before releasing DEK shares. It checks that the caller has MessagingReader permission in the group.
move/packages/sui_stack_messaging/sources/seal_policies.move. You probably need to run `pnpm prebuild` and restart the site.The function validates the 40-byte identity (32-byte group ID + 8-byte key version), confirms the encryption history belongs to the correct group, and verifies the caller holds MessagingReader permission. If any check fails, the transaction aborts and Seal denies the key shares.
Encryption key rotation
The rotate_encryption_key function appends a new encrypted DEK version to the onchain history. It requires EncryptionKeyRotator permission.
move/packages/sui_stack_messaging/sources/messaging.move. You probably need to run `pnpm prebuild` and restart the site.Key rotation creates a new DEK version. Future messages encrypt with the new version. Removed members can still decrypt old messages (they had the old DEK cached) but cannot access the new DEK because Seal checks their permission at decryption time.
Encryption history storage
The EncryptionHistory struct stores versioned encrypted DEKs as a TableVec on a shared object.
move/packages/sui_stack_messaging/sources/encryption_history.move. You probably need to run `pnpm prebuild` and restart the site.Each entry in the deks table is an encrypted DEK blob (up to 1024 bytes). The key version is the index into this table. The identity format [group_id (32 bytes)][key_version (8 bytes)] ensures each DEK maps to exactly 1 group and version.
Messaging client setup
The MessagingClientProvider initializes the SDK client with Seal, Walrus, and relayer configuration.
chat-app/src/providers/MessagingClientContext.tsx. You probably need to run `pnpm prebuild` and restart the site.The provider creates a SuiStackMessagingClient that composes the Sui client with Seal (for DEK encryption and decryption), a Walrus storage adapter (for file attachments), and an HTTP relayer transport. The DappKitSigner bridges the wallet's signing capability to the SDK's signer interface.
Sending and receiving messages
The useMessages hook manages the full message lifecycle: fetching history, subscribing for updates, sending, editing, and deleting.
chat-app/src/hooks/useMessages.ts. You probably need to run `pnpm prebuild` and restart the site.The hook calls client.sendMessage() which encrypts the text with AES-256-GCM using the group DEK, signs the ciphertext, and POSTs it to the relayer. For real-time updates, it calls client.subscribe() which polls the relayer and automatically decrypts incoming messages using the cached DEK.
Troubleshooting
The following sections address common issues with this example.
Relayer connection fails
Symptom: The frontend shows a network error or messages do not send.
Cause: The relayer is not running, VITE_RELAYER_URL points to the wrong address, or the Vite dev proxy is misconfigured.
Fix: Confirm the relayer is running on http://localhost:3000 with curl http://localhost:3000/health_check. Verify VITE_RELAYER_URL in .env matches the relayer address. Restart the Vite dev server after changing .env.
Permission denied on send or read
Symptom: The relayer returns a 403 error when sending or fetching messages.
Cause: The relayer's membership cache does not include the caller's permissions, or the caller's permissions were revoked onchain.
Fix: Verify your membership with sui client object GROUP_ID and check the permissions for your address. If recently added, the relayer's membership sync might have a delay. Restart the relayer to force a cache refresh.
Decryption fails
Symptom: Messages appear as encrypted blobs instead of readable text, or the SDK throws a decryption error.
Cause: The session key expired (default TTL is 10 minutes), the caller lost MessagingReader permission, or the Seal key servers are unreachable.
Fix: Refresh the page to generate a new session key. Verify your permissions are still active. Check that the Seal key server object IDs in .env are correct and the servers are reachable.
Group does not appear in sidebar
Symptom: After creating a group or being added to one, it does not show in the sidebar.
Cause: The group discovery hook queries MemberAdded events through GraphQL. The event indexer might lag behind the chain, or the GraphQL endpoint is unreachable.
Fix: Wait a few seconds and refresh. Verify VITE_SUI_GRAPHQL_URL is correct. The group is also cached in localStorage, so clearing chat-app-groups from localStorage and refreshing forces a fresh query.
File attachment upload fails
Symptom: Sending a message with attachments fails or shows an uploading files message indefinitely.
Cause: The file exceeds the 5MB per-file or 50MB total limit, the Walrus publisher is unreachable, or the publisher URL is misconfigured.
Fix: Check file sizes (max 5MB each, 10 files, 50MB total). Verify VITE_WALRUS_PUBLISHER_URL is correct and reachable with curl PUBLISHER_URL/v1/health. Increase VITE_WALRUS_EPOCHS if storage duration is too short.
Error: Invalid key servers or threshold 2 for 1 key servers for package 0x04...
Cause: Configured key servers are invalid, unreachable, or only one was specified.
Fix: Check that the Seal key server object IDs in .env are correct, there are at least 2, and the servers are reachable.
Error: Invalid public key format: Unknown signature scheme flag: 0x05
Cause: zkLogin is not supported by this application, and the wallet connected to the app uses zkLogin.
Fix: Use a passphrase or imported key wallet instead.
Application disappears after connecting your wallet
Cause: You've connected an unsupported wallet to the application.
Fix: Restart the application and connect a known supported wallet, such as Slush, to the application instead.