Encrypt Messages
Your app encrypts each message's text just before it's sent, with the channel's current key from your server. Stream then only stores and delivers encrypted text, with a placeholder as the visible message text.
Before you start
You need:
- the Stream Chat Flutter SDK, version 10 or later
- the key endpoint on your server
- the
cryptographyandhttppackages
flutter pub add cryptography httpThe cryptography package runs in pure Dart by default. Add cryptography_flutter as well to use the platform's native AES-GCM, which is faster for long messages. No code changes are needed.
On iOS, the Stream Chat Flutter SDK needs a deployment target of iOS 14 or later. Depending on your Flutter version, flutter create can set an older target. If it does, raise it to 14 in Xcode, and in ios/Podfile if your project has one. If you test against a key server on your computer over plain http, Android and iOS block the request unless you allow cleartext traffic for that address. Only do that in debug builds.
Before you send the first encrypted message, check two settings:
- Server drafts: turn them off with
draftMessagesEnabled: false. Drafts are saved as typed, on the server and on the phone, before your app encrypts them. Show decrypted messages shows where. - Offline storage: it's kept on the phone and Stream never sees it, and new messages are saved encrypted. But after you react to, pin or delete a message, the SDK can save the readable text shown on screen on the phone until the message next updates. If your app must not store readable text on the phone, don't add offline storage.
Add the encryption class
Create lib/encryption/message_cipher.dart. This class encrypts a message's text before it's sent, and decrypts it after it arrives. It gets its keys from two functions, so you can change where keys come from without changing the class.
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A key, together with the id saved next to each message encrypted with it.
typedef KeyWithId = ({String id, SecretKey key});
/// Encrypts the text of messages before they are sent, and decrypts it after they arrive.
///
/// The encrypted text is saved in the message's custom data under `enc`, and the message text
/// is replaced with a placeholder.
class MessageCipher {
/// Creates a cipher that gets its keys from [currentKey] and [keyById].
MessageCipher({required this.currentKey, required this.keyById});
/// Returns the key to encrypt new messages in a channel with.
final Future<KeyWithId> Function(Channel channel) currentKey;
/// Returns the key with a given id, to decrypt a message in a channel.
final Future<SecretKey> Function(String cid, String keyId) keyById;
/// The custom data field that holds a message's encrypted text.
static const field = 'enc';
/// The version of the format this cipher writes, saved with every message as `v`.
static const formatVersion = 1;
static const _placeholder = 'Encrypted message';
final _aes = AesGcm.with256bits();
/// Returns a copy of [message] with its text encrypted.
///
/// The copy's text is a placeholder that keeps the message's mentions, so mentioned users are
/// still notified. If [message] has no text, the copy has no encrypted text either, so an edit
/// that removes the text doesn't keep showing the old one.
Future<Message> encrypt(Channel channel, Message message) async {
final text = message.text;
if (text == null || text.isEmpty) {
if (!message.extraData.containsKey(field)) return message;
return message.copyWith(extraData: {...message.extraData}..remove(field));
}
final key = await currentKey(channel);
final box = await _aes.encrypt(
utf8.encode(text),
secretKey: key.key,
nonce: _aes.newNonce(),
aad: _boundTo(channel.cid!, message),
);
return message.copyWith(
text: [_placeholder, ..._mentionTokens(message)].join(' '),
extraData: {
...message.extraData,
field: {
'v': formatVersion,
'kid': key.id,
'iv': base64.encode(box.nonce),
'ct': base64.encode(box.concatenation(nonce: false)),
},
},
);
}
/// Returns the decrypted text of [message], or null if [message] isn't encrypted.
///
/// Throws a [FormatException] if the encrypted text is in a format this cipher doesn't know,
/// and a [SecretBoxAuthenticationError] if it was changed, or copied from another message.
Future<String?> decrypt(String cid, Message message) async {
final encrypted = message.extraData[field];
if (encrypted == null) return null;
if (encrypted case {
'v': formatVersion,
'kid': final String keyId,
'iv': final String nonce,
'ct': final String ciphertext,
}) {
final box = SecretBox.fromConcatenation(
[...base64.decode(nonce), ...base64.decode(ciphertext)],
nonceLength: _aes.nonceLength,
macLength: _aes.macAlgorithm.macLength,
);
final key = await keyById(cid, keyId);
final bytes = await _aes.decrypt(box, secretKey: key, aad: _boundTo(cid, message));
return utf8.decode(bytes);
}
throw FormatException('Message ${message.id} has encrypted text in an unknown format.');
}
// Ties the encrypted text to one message, so it can't be copied into another.
List<int> _boundTo(String cid, Message message) => utf8.encode('$cid|${message.id}');
// The SDK drops every mention whose @-token isn't in the message text, so the placeholder
// keeps the tokens of the mentions that are still in the text. The composer doesn't forget a
// mention when its @-token is deleted, so the text decides, as it does in the SDK.
List<String> _mentionTokens(Message message) {
final text = message.text ?? '';
bool mentions(String token) =>
RegExp('(?<![A-Za-z0-9_])@${RegExp.escape(token)}(?![A-Za-z0-9_])').hasMatch(text);
return [
for (final user in message.mentionedUsers)
if (mentions(user.id) || mentions(user.name)) '@${user.id}',
if ((message.mentionedChannel ?? false) && mentions('channel')) '@channel',
if ((message.mentionedHere ?? false) && mentions('here')) '@here',
for (final role in message.mentionedRoles ?? const <String>[])
if (mentions(role)) '@$role',
for (final group in message.mentionedGroups ?? const <UserGroup>[])
if (mentions(group.name)) '@${group.name}',
];
}
}What the class does, and why:
- It moves the encrypted text into the message's custom data, under
enc, and replaces the text with the placeholder "Encrypted message". Anything that doesn't decrypt, such as push notifications, shows the placeholder. - It keeps mentions in the placeholder. The SDK only sends a mention (of a user,
@channel,@here, a role or a group) if its@-token is in the message text. Without the tokens, mentioned users wouldn't be notified. The placeholder only keeps the tokens that are still in the real text, so a mention the user typed and then deleted isn't sent. Stream can see who was mentioned, but not what was written. To hide mentions as well, remove_mentionTokensfrom the placeholder, and accept that mentions stop working. - It uses a new random nonce for every message. Never reuse a nonce with the same key: that can let others read your messages.
- It turns text into bytes with
utf8.encode, notcodeUnits, which breaks emoji and many languages. Encrypted bytes are stored as base64. - It ties each message to its channel id and message id, so encrypted text can't be copied into another message. The SDK creates the message id before your app encrypts, so it's always there.
- It saves a format version (
v) with every message, so you can change the format later and still read old messages. - It removes the encrypted text when a message has no text, for example when an edit leaves only attachments, so the old text doesn't keep showing.
Limit message length
Custom data is limited to 5 KB per message, and encrypted text takes about a third more space than the original. Create lib/encryption/message_limits.dart with checks for the composer. The composer's default check only makes sure the message isn't empty, and replacing it drops that check, so this file has it too:
import 'dart:convert';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Whether [message] has anything to send: text, attachments or a poll.
bool hasContent(Message message) =>
(message.text?.trim().isNotEmpty ?? false) || message.attachments.isNotEmpty || message.pollId != null;
/// Whether the encrypted text of [message] fits in the message's custom data.
///
/// Encrypted text takes about a third more space than the original, and custom data is limited
/// to 5 KB. 3,000 bytes of text leaves room for the rest of the message's custom data.
bool fitsInCustomData(Message message) => utf8.encode(message.text ?? '').length <= 3000;The next section passes them to every composer.
Get keys from your server
Create lib/encryption/channel_keys.dart. This class asks your server for keys and keeps them in memory while the app runs. Your server decides which key is current. The channel's key_version only tells the app when to ask again, so new messages use the new key from the first one sent after the change reaches the app. Requests time out after 10 seconds. When a key for decrypting is refused, unknown or unreadable, it isn't asked for again for a minute, while a request that fails on the network is tried again the next time a key is needed.
Never put a key in a message, a log or analytics.
import 'dart:async';
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:http/http.dart' as http;
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'message_cipher.dart';
/// A client for the key endpoint on your app's own server.
///
/// Keys are kept in memory until [close] is called, and are never written to disk. Requests for
/// the same keys are shared between callers.
class ChannelKeys {
/// Creates a client for the key endpoint at [endpoint].
///
/// The [authorization] function is called before every request, and its result is sent as the
/// `Authorization` header.
ChannelKeys({required this.endpoint, required this.authorization});
/// The URL of the key endpoint, such as `https://example.com/channel-keys`.
final Uri endpoint;
/// Returns the `Authorization` header value for [endpoint].
///
/// The value comes from your app's own login, such as a session token, not from the Stream
/// token.
final Future<String> Function() authorization;
static const _timeout = Duration(seconds: 10);
static const _retryUnknownKeysAfter = Duration(minutes: 1);
final _http = http.Client();
final _keys = <String, SecretKey>{}; // '$cid/$keyId' -> key
final _currentKeys = <String, ({int keyVersion, String id})>{}; // cid -> current key id
final _requests = <String, Future<String>>{}; // running requests, shared between callers
final _unknownKeyIds = <String, DateTime>{}; // '$cid/$keyId' -> when to ask for it again
var _isClosed = false;
/// Whether the key for new messages in [channel] is loaded, so [currentKey] returns at once.
bool hasCurrentKey(Channel channel) => _currentKeys[channel.cid]?.keyVersion == _keyVersion(channel);
/// Returns the key to encrypt new messages in [channel] with.
///
/// The endpoint decides which key is current. The channel's `key_version` only tells this
/// class when to ask again, because channel owners can change it.
///
/// Throws a [StateError] if the endpoint refuses the request, for example because the current
/// user isn't a member of [channel], and a [TimeoutException] or an [http.ClientException] if
/// the endpoint can't be reached.
Future<KeyWithId> currentKey(Channel channel) async {
final cid = channel.cid!;
final keyVersion = _keyVersion(channel);
var id = switch (_currentKeys[cid]) {
(keyVersion: final loadedFor, :final id) when loadedFor == keyVersion => id,
_ => null,
};
if (id == null) {
id = await _load(cid, cid);
// A request that started before the key version changed returns the previous key.
if (_versionOf(id) < keyVersion) id = await _load(cid, cid);
_currentKeys[cid] = (keyVersion: keyVersion, id: id);
}
return (id: id, key: _keys['$cid/$id']!);
}
/// Returns the key named [keyId], to decrypt a message in the channel [cid].
///
/// Throws a [StateError] if the endpoint refuses the request or doesn't know the key, and a
/// [FormatException] if its response can't be read. After either, the key isn't asked for again
/// for a minute, so a message with an unknown key id can't make the app call the endpoint on
/// every update. A request that fails on the network is tried again the next time.
Future<SecretKey> keyById(String cid, String keyId) async {
final cacheKey = '$cid/$keyId';
if (_keys[cacheKey] case final key?) return key;
final retryAt = _unknownKeyIds[cacheKey];
if (retryAt == null || DateTime.now().isAfter(retryAt)) {
try {
// Most messages use a key of the current master secret, which one request returns. It's
// shared with currentKey, so opening a channel makes one request.
await _load(cid, cid);
// Older messages can use an older master secret, which needs its own request.
if (!_keys.containsKey(cacheKey)) await _load(cacheKey, cid, keyId: keyId);
} on StateError {
_unknownKeyIds[cacheKey] = DateTime.now().add(_retryUnknownKeysAfter);
rethrow;
} on FormatException {
_unknownKeyIds[cacheKey] = DateTime.now().add(_retryUnknownKeysAfter);
rethrow;
}
if (_keys[cacheKey] case final key?) return key;
_unknownKeyIds[cacheKey] = DateTime.now().add(_retryUnknownKeysAfter);
}
throw StateError('The key endpoint has no key $keyId for $cid.');
}
/// Closes the connection to the key endpoint and forgets every key.
///
/// Call it when the user logs out. Requests that are still running don't store their keys,
/// and this object can't be used afterwards.
void close() {
_isClosed = true;
_keys.clear();
_currentKeys.clear();
_unknownKeyIds.clear();
_http.close();
}
static int _keyVersion(Channel channel) => switch (channel.extraData['key_version']) {
final int version => version,
_ => 1,
};
// The channel key version in a key id such as '2.5'.
static int _versionOf(String keyId) => int.tryParse(keyId.split('.').last) ?? 0;
// Fetches keys, sharing the request with other callers that use the same requestKey.
// Returns the id of the channel's current key.
Future<String> _load(String requestKey, String cid, {String? keyId}) {
return _requests[requestKey] ??= _fetch(cid, keyId: keyId).whenComplete(() {
_requests.remove(requestKey);
});
}
Future<String> _fetch(String cid, {String? keyId}) async {
if (_isClosed) throw StateError('ChannelKeys is closed.');
final response = await _http
.get(
endpoint.replace(queryParameters: {'cid': cid, if (keyId != null) 'kid': keyId}),
headers: {'Authorization': await authorization()},
)
.timeout(_timeout);
if (_isClosed) throw StateError('ChannelKeys is closed.');
if (response.statusCode != 200) {
throw StateError(
'The key endpoint returned HTTP ${response.statusCode} for $cid. '
'Check that the user is logged in to the app and is a member of the channel.',
);
}
// The response must include the current key, unless it's for one older key.
if (jsonDecode(response.body) case {
'current': final String current,
'keys': final Map<String, Object?> keys,
} when keyId != null || keys[current] is String) {
for (final MapEntry(key: id, value: key) in keys.entries) {
if (key is String) _keys['$cid/$id'] = SecretKey(base64.decode(key));
}
return current;
}
throw FormatException('The key endpoint returned a response without current and its key.', response.body);
}
}After the user logs in to your app, create one ChannelKeys and one MessageCipher. yourSessionToken() stands for however your app gets its own login token:
final channelKeys = ChannelKeys(
endpoint: Uri.parse('https://example.com/channel-keys'),
authorization: () async => 'Bearer ${await yourSessionToken()}',
);
final cipher = MessageCipher(currentKey: channelKeys.currentKey, keyById: channelKeys.keyById);When the user logs out, call channelKeys.close(), so no keys stay in memory for the next user.
Check it works: in a debug build, call await channelKeys.currentKey(channel) for a channel you're a member of. You get a key id such as 1.1.
Encrypt every composer
Encrypt in one place, for every composer in your app: the channel page, the thread page, and the SDK's own pages such as StreamChannelPage and StreamThreadPage. Pass a messageComposer builder to StreamChat that changes the props of every composer:
StreamChat(
client: client,
componentBuilders: StreamComponentBuilders(
extensions: streamChatComponentBuilders(
messageComposer: (context, props) => DefaultStreamMessageComposer(
props: _encrypting(context, StreamChannel.of(context).channel, props),
),
),
),
child: child,
);
// Encrypts what every composer sends: in channels, in threads and in the SDK's own pages.
MessageComposerProps _encrypting(BuildContext context, Channel channel, MessageComposerProps props) {
return props.copyWith(
preMessageSending: (message) => cipher.encrypt(channel, message),
validator: (message) => _canSend(context, channel, message),
ogPreviewFilter: (_, _) => false, // don't send typed links out for previews
disableAttachments: true, // files would be uploaded unencrypted
enableVoiceRecording: false, // recordings would be uploaded unencrypted
);
}
// Holds the send back until the text fits and the channel's key is loaded. The composer keeps
// the text, and, unless the message is empty, the user is told why.
bool _canSend(BuildContext context, Channel channel, Message message) {
if (!hasContent(message)) return false;
final messenger = ScaffoldMessenger.maybeOf(context);
void tell(String text) => messenger
?..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(text)));
if (!fitsInCustomData(message)) {
tell('This message is too long to send encrypted.');
return false;
}
if (channelKeys.hasCurrentKey(channel)) return true;
tell('Loading the encryption key. Tap Send again in a moment.');
channelKeys.currentKey(channel).then(
(_) {},
// For example, the device is offline, or the user is no longer a member of the channel.
// A block body: `then` throws if the error handler returns a value.
onError: (Object _) {
tell("Couldn't load the encryption key, so this message can't be sent yet.");
},
);
return false;
}What the builder changes:
preMessageSendingencrypts the message. It runs for new messages, replies, thread replies and edits, before the SDK adds the message to its own state.validatorholds the send back until the text fits and the channel's key is loaded. When the key is missing, tapping Send keeps the typed text, starts loading the key and tells the user to tap Send again, or that the key couldn't be loaded. The channel page loads the key when it opens and when the channel's key changes, so this is rare. The composer clears its text beforepreMessageSendingruns, so this check is what keeps a failed key request from losing the typed text: with the key loaded, encrypting never waits for the network. With server drafts off, the composer only callsvalidatorwhen the user taps Send, so these snackbars only appear then.ogPreviewFilterturns off link previews, so typed links aren't sent out to fetch previews.disableAttachmentsandenableVoiceRecordingturn off files and voice recordings. They would be uploaded unencrypted, and the channel type only rejects them after they've been sent to Stream.
Don't also set preMessageSending on a composer yourself: the builder replaces it. If your code sends or edits messages anywhere else, for example with channel.sendMessage or channel.updateMessage, call cipher.encrypt there too. Don't encrypt in a network interceptor instead: by then the SDK has already stored the readable message in its own state.
Show decrypted messages shows where StreamChat goes in your app.
Check it works: send a message and a thread reply, then fetch them on your server with await serverClient.getMessage(messageId). Their text is "Encrypted message", and they have an enc field. Your app also shows the placeholder for now. The next page fixes that.
Next step
Show decrypted messages in the message list and the channel list.