Previously in this series: What Is the Matrix SDK? A Flutter Developer's Guide
matrix-dart-sdk with persistent key storageWhat this article does NOT cover (reserved for later in the series):
By the end, you will have a production-grade encrypted chat screen in Flutter, and you will understand exactly what the encryption is doing at every layer.
| Layer | Technology |
|---|---|
| Client framework | Flutter / Dart |
| SDK | matrix-dart-sdk 6.x (package:matrix) |
| Encryption primitives | libolm (Olm/Megolm ratified implementations) |
| Homeserver | Synapse (reference), Conduit (Rust), or Dendrite (Go) |
| Device push | FCM / APNs (transport only — your Sygnal gateway controls dispatch) |
| Key storage | SQLite via ClientDatabase (persisted to device) |
Matrix is not a service. Matrix is an open protocol. You run a homeserver, or you connect to one. The SDK is your client into that protocol — it handles the HTTP/WebSocket sync loop, room state management, and crucially, all Olm/Megolm cryptographic operations at the client layer.
In an E2EE room, the homeserver is a dumb relay. It receives encrypted payloads and forwards them. It cannot read the message content. It can still see metadata — room membership, event timestamps, device identifiers. This is a protocol-level constraint, not a bug.
Insert diagram showing the client-side SDK layers (Client → Room → OlmEngine → Database) connected via HTTPS/WS to a Synapse homeserver, with encrypted payloads flowing through.
The SDK maintains a persistent sync connection to the homeserver via a long-polling WebSocket. Every time the server has new events, it pushes them to the client. The SDK processes each event: if it is encrypted, it routes to the Olm/Megolm engine for decryption before any event stream emits.
Sync loop (simplified):
Client → GET /_matrix/client/v3/sync (long poll)
Server → { rooms: { join: { !room:server: { timeline: { events: [...] } } } } }
SDK → Filter events by type
→ If m.room.encrypted → decrypt via Olm/Megolm
→ Parse decrypted content
→ Emit through onTimelineEvent stream
→ UI re-renders
This section is the core of the article. If you read nothing else, read this.
Matrix E2EE uses two separate cryptographic ratchets, each with a distinct purpose.
Olm is a port of the Signal double-ratchet algorithm to the Matrix ecosystem. It provides:
Olm is used exclusively for:
Every Olm session is between exactly two devices (not two users). If Alice has a phone and a laptop, and Bob has a phone, the SDK establishes three Olm sessions: Alice-phone ↔ Bob-phone, Alice-laptop ↔ Bob-phone, and Alice-phone ↔ Alice-laptop (for self-verification).
Olm session establishment:
1. Alice's device generates a Curve25519 key pair + Ed25519 signing key
2. Alice publishes these as device keys via /keys/upload
3. Bob's device downloads Alice's device keys via /keys/query
4. Bob creates an Olm session: his private key + Alice's public key
5. Bob sends a pre-key Olm message (wrapped in m.room.encrypted)
6. Alice receives it, establishes the session on her side
7. Both devices now share a symmetric ratchet for one-to-one messaging
Megolm is a single-direction ratchet designed for group communication. It makes a deliberate tradeoff:
| Property | Olm | Megolm |
|---|---|---|
| Participants | 2 devices | N devices in a room |
| Forward secrecy | Yes | No — one leaked key decrypts all past messages |
| Future secrecy | Yes | Partial — new session on membership changes |
| Performance overhead | Per-recipient encryption | Single encryption, key shared to all |
| Key rotation | Every message | Configurable (default: every 100 messages or every week) |
The tradeoff is explicit and documented by the Matrix team. Megolm sacrifices forward secrecy because encrypting every message N times (once per recipient) does not scale to rooms with hundreds of members. Instead, the SDK encrypts each message once with the Megolm session key, then encrypts the session key itself using each recipient's Olm session.
Insert diagram showing: plaintext → Megolm ciphertext → Olm-wrapped per-recipient → m.room.encrypted event → homeserver → recipients → unwrap → decrypt → plaintext.
Message encryption pipeline (group room):
Plaintext: "Hello, world!"
Step 1: Megolm encrypt
Encrypt plaintext with current Megolm session key
(AES-256 + HMAC-SHA256)
Result: m.megolm.v1.aes-sha2 ciphertext
Step 2: Wrap Megolm key per recipient
For each device in room:
Encrypt Megolm session key with that device's Olm session
Result: olm.v1.curve25519-aes-sha2 ciphertext
Step 3: Assemble m.room.encrypted event
{
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "<megolm encrypted blob>",
"session_id": "<current megolm session id>",
"sender_key": "<sender's curve25519 key>",
"device_id": "<sender's device id>"
}
Step 4: Send to homeserver as POST /rooms/{roomId}/send/m.room.encrypted
Server stores and relays. Server cannot decrypt.
Step 5: Each recipient receives event
SDK checks algorithm → m.megolm.v1.aes-sha2
Looks up Megolm session by session_id
If session key known → decrypt directly
If session key unknown → request via /keys/claim (Olm)
Plaintext: "Hello, world!" ✓
When the SDK initializes for the first time on a device:
ClientDatabase./keys/upload so other devices can establish Olm sessions.Insert diagram showing: first init → generate account keys → upload device + one-time keys → establish Olm sessions → negotiate Megolm sessions → encrypt/decrypt messages → periodic key export.
Where keys live on disk:
ClientDatabase (SQLite)
├── account_data # Ed25519 identity key, Curve25519 key
├── olm_sessions # Established Olm sessions (recipient device → session)
├── megolm_inbound # Megolm sessions for rooms the device is in
├── megolm_outbound # Current Megolm session for each room
├── device_keys # Known device keys for all users in the room
├── cross_signing # Cross-signing keys (master, self-signed, user-signing)
└── trust_state # Which devices are verified or blocked
Key persistence is not optional for production. If the database is lost, the device cannot decrypt past messages (because the Olm sessions that wrapped the Megolm keys are gone). The SDK provides exportKeys() and importKeys() for backup — call them periodically and let the user store the file.
// Export encryption keys for backup
final exported = await client.exportKeys(
password: 'user-provided-passphrase',
);
// Import on another device or after restore
await client.importKeys(
exported,
password: 'user-provided-passphrase',
);
// Configure persistent database on init
final client = Client(
'MyChatApp',
databaseBuilder: (client) => ClientDatabase(
'chat_app.db',
exportDir: await getApplicationDocumentsDirectory(),
),
);
Matrix uses cross-signing to establish trust between devices without requiring users to verify every single device manually.
Master signing key (offline, stored in SDK)
├── Self-signing key (signs user's own devices)
│ └── Device A ✓, Device B ✓
└── User-signing key (signs other users' masters)
└── Bob's master key ✓
When Alice logs into a new phone: the SDK generates a new device key pair. Alice's existing laptop (already verified) signs the new phone's key using the self-signing key. Other users see the new phone as "verified" because it chains up to Alice's master key.
If cross-signing is not set up, users must verify each device manually using emoji or number comparison (the m.sas.v1 verification flow).
// Check cross-signing state
if (!client.crossSigning.isSetup) {
await client.crossSigning.setup();
}
// Verify another user's device
final userIdentity = client.userIdentity(UserId('@bob:server.com'));
await userIdentity?.requestVerification();
// Listen for incoming verification requests
client.onVerificationRequest.stream.listen((request) {
// Show emoji comparison dialog
// request.accept() or request.reject()
});
What happens when an untrusted device exists in a room: the SDK still encrypts messages for that device (the protocol sends Megolm keys to all devices in the room), but the application can choose to show a warning. The SDK does not block sending — that is an application-layer decision.
dependencies:
matrix: ^6.0.0
path_provider: ^2.0.0
import 'package:matrix/matrix.dart';
import 'package:path_provider/path_provider.dart';
Client createClient() {
return Client(
'MyChatApp',
databaseBuilder: (client) => ClientDatabase(
'chat_app.db',
exportDir: getApplicationDocumentsDirectory(),
),
);
}
The databaseBuilder parameter is required for production use. Without it, the SDK operates in memory-only mode — keys are lost when the app process dies, and the device becomes unable to decrypt past messages after restart.
Future<void> login(Client client) async {
try {
final response = await client.login(
LoginType.mLoginPassword,
password: 'user-password',
user: '@user:matrix.org',
initialDeviceDisplayName: 'My Phone',
);
} on MatrixException catch (e) {
// e.errorCode: M_FORBIDDEN, M_LIMIT_EXCEEDED, M_UNKNOWN
throw e;
}
}
Future<bool> restoreSession(Client client) async {
try {
await client.checkHomeserver(Uri.parse('https://matrix.org'));
await client.restoreLogin(
DatabaseApi(
'chat_app.db',
exportDir: await getApplicationDocumentsDirectory(),
),
);
return client.isLogged;
} on MatrixException {
return false; // Token expired — prompt for re-login
}
}
Device ID management: on first login, the server assigns a device ID. On subsequent logins, reuse the same device ID to avoid orphaned Olm sessions (other devices have established sessions with the old device ID that will now fail).
Future<Room> createEncryptedDirectRoom(Client client, String userId) async {
final room = await client.createRoom(
name: 'Direct Chat',
invite: [userId],
isDirect: true,
);
// For direct rooms, encryption is enabled automatically
return room;
}
Future<Room> createEncryptedGroupRoom(Client client, List<String> userIds) async {
final room = await client.createRoom(
name: 'Group Chat',
invite: userIds,
preset: CreateRoomPreset.privateChat,
);
// IMPORTANT: enableEncryption() MUST be called before the first
// state event is sent to the room. After encryption is enabled,
// it cannot be disabled.
await room.enableEncryption();
return room;
}
Warning: callingenableEncryption()after messages have already been sent creates a partially-unencrypted room. Messages before them.room.encryptionstate event are stored as plaintext on the homeserver. The protocol does not allow encryption to be toggled off once enabled.
When you call room.sendTextEvent(), the SDK performs the full encryption pipeline internally:
Future<void> sendEncryptedMessage(Room room, String text) async {
try {
// The SDK internally:
// 1. Serializes text into m.text content
// 2. Encrypts with Megolm outbound session key
// 3. Wraps Megolm key per recipient's Olm session
// 4. Sends m.room.encrypted event to homeserver
await room.sendTextEvent(text);
} on MatrixException catch (e) {
if (e.errorCode == 'M_TOO_LARGE') {
// Message exceeds server limit
} else if (e.isRateLimitError) {
// Retry with exponential backoff
}
}
}
You do not interact with the crypto layer directly for basic messaging. The SDK exposes it through client.olm if you need low-level access. For 99% of use cases, the high-level Room API is sufficient.
To verify encryption is working:
client.onTimelineEvent.stream.listen((event) {
if (event.type == EventTypes.Message && event.content != null) {
// event.content is the DECRYPTED plaintext
// event.originalContent is the raw m.room.encrypted JSON
print('Decrypted message from ${event.senderId}: ${event.body}');
}
});
If decryption fails, event.content is null and event.error is set. Handle this case — show a "decryption failed" indicator rather than silently dropping it.
class EncryptedChatScreen extends StatefulWidget {
final Room room;
const EncryptedChatScreen({required this.room});
@override
State<EncryptedChatScreen> createState() => _EncryptedChatScreenState();
}
class _EncryptedChatScreenState extends State<EncryptedChatScreen> {
late Timeline timeline;
final TextEditingController _textController = TextEditingController();
StreamSubscription? _timelineSubscription;
@override
void initState() {
super.initState();
_initTimeline();
}
Future<void> _initTimeline() async {
timeline = await widget.room.getTimeline();
_timelineSubscription = timeline.onUpdate.stream.listen((_) {
if (mounted) setState(() {});
});
// Listen for decryption failures
widget.room.client.onTimelineEvent.stream
.where((e) => e.type == EventTypes.Message && e.error != null)
.listen((event) {
debugPrint('Decryption failure: ${event.error}');
});
}
@override
void dispose() {
_timelineSubscription?.cancel();
_textController.dispose();
super.dispose();
}
void _sendMessage() {
final text = _textController.text.trim();
if (text.isEmpty) return;
widget.room.sendTextEvent(text);
_textController.clear();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: timeline.events.length,
itemBuilder: (context, index) {
final event = timeline.events[index];
if (event.type == EventTypes.Message) {
if (event.error != null) {
return _DecryptionFailureTile(event: event);
}
return _MessageTile(event: event);
}
return const SizedBox.shrink();
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: const InputDecoration(
hintText: 'Encrypted message...',
border: OutlineInputBorder(),
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
),
],
),
),
],
);
}
}
class _MessageTile extends StatelessWidget {
final Event event;
const _MessageTile({required this.event});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
child: Text(event.senderId?.characters.first.toUpperCase() ?? '?'),
),
title: Text(event.senderId ?? 'Unknown'),
subtitle: Text(event.body ?? ''),
);
}
}
class _DecryptionFailureTile extends StatelessWidget {
final Event event;
const _DecryptionFailureTile({required this.event});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.lock, color: Colors.red),
title: const Text('Message could not be decrypted'),
subtitle: Text(event.error?.toString() ?? 'Unknown error'),
);
}
}
// Usage
final room = client.getRoomById('!roomid:server.com');
if (room == null) {
// Room not found — join or create
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EncryptedChatScreen(room: room),
),
);
These are not bugs. They are protocol-level tradeoffs that every production Matrix application must account for.
The homeserver can see which users are in which rooms, when messages are sent, device identifiers, and event types. It cannot see message content or file content.
If a Megolm session key is leaked, an attacker can decrypt every message encrypted under that session. Sessions rotate every 100 messages or every 7 days (configurable).
The protocol provides the mechanism (emoji SAS, QR code, cross-signing), but the application must prompt users. Most users will not verify devices unless the app blocks unverified ones — which causes support tickets.
If ClientDatabase is deleted and no key export exists, the device cannot decrypt past messages.
client.keyBackup.ensureBackup() and offer periodic key exports to users.// Enable server-side key backup
if (!client.keyBackup.isActive) {
await client.keyBackup.ensureBackup();
}
ClientDatabase configured with a real directoryclient.keyBackup.ensureBackup() called after loginclient.crossSigning.setup() on any verified deviceenableEncryption() called before any message is sent in group roomsThis article covered the complete E2EE chat implementation — from the Olm/Megolm encryption pipeline to persistent key management to production considerations.
Next in the series: