Show Decrypted Messages
Messages arrive with the placeholder text "Encrypted message" and the encrypted text in their custom data. This page decrypts them and shows the real text in the message list, in threads and quoted replies, and in the channel list.
Decrypt once, never in build
Decrypt each message once, when it arrives, and keep the result in memory. Don't decrypt inside a widget's build method or a FutureBuilder in a message builder. build runs many times, for example while the user scrolls or when a reaction arrives, and decryption takes longer the longer a message is. Unless you add cryptography_flutter, decryption runs in Dart on the same thread that draws your UI.
With a cache, each message costs one decryption, and widgets only read the result.
Add a cache for decrypted text
Create lib/encryption/decrypted_text_cache.dart. The cache remembers each message by its id and its nonce. The nonce changes every time the text is encrypted again, so an edited message is decrypted again, while reactions, read receipts and pins don't cause another decryption.
import 'package:cryptography/cryptography.dart';
import 'package:flutter/foundation.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'message_cipher.dart';
/// Decrypted message text, kept in memory so that each message is decrypted only once.
///
/// Listeners are notified once for each batch of messages that adds decrypted text.
class DecryptedTextCache extends ChangeNotifier {
/// Creates a cache that decrypts messages with [cipher].
DecryptedTextCache(this.cipher);
/// The cipher that decrypts messages for this cache.
final MessageCipher cipher;
/// The text shown instead of a message whose encrypted text was changed, or copied from
/// another message.
static const undecryptable = "This message couldn't be decrypted.";
final _texts = <String, String>{};
final _inProgress = <String>{};
var _isDisposed = false;
/// Returns a copy of [message] that shows its decrypted text, and that of the message it
/// quotes.
///
/// A message that hasn't been decrypted yet keeps its placeholder, and so does a message that
/// failed to send, because resending it sends the message exactly as shown.
Message decrypted(Message message) {
if (message.state.isFailed || message.isBouncedWithError) return message;
final cacheKey = _cacheKey(message);
final quoted = message.quotedMessage;
return message.copyWith(
text: cacheKey == null ? message.text : _texts[cacheKey] ?? message.text,
quotedMessage: quoted == null ? null : decrypted(quoted),
);
}
/// Decrypts each of [messages] in the channel [cid], and the messages they quote.
///
/// Messages that aren't encrypted, are already decrypted, or are still decrypting are skipped.
/// A message whose key can't be loaded keeps its placeholder, and is tried again the next time
/// it's passed in, once `ChannelKeys` asks for that key again. A message whose encrypted text
/// was changed shows [undecryptable].
Future<void> decryptAll(String cid, Iterable<Message> messages) async {
final pending = [
for (final message in messages) ...[
message,
if (message.quotedMessage case final quoted?) quoted,
],
];
final results = await Future.wait([for (final message in pending) _decrypt(cid, message)]);
if (_isDisposed || !results.contains(true)) return;
notifyListeners();
}
@override
void dispose() {
_isDisposed = true;
_texts.clear();
super.dispose();
}
// Decrypts one message, and returns whether its text was added to the cache.
Future<bool> _decrypt(String cid, Message message) async {
final cacheKey = _cacheKey(message);
if (cacheKey == null || _texts.containsKey(cacheKey)) return false;
if (!_inProgress.add(cacheKey)) return false;
try {
final text = await cipher.decrypt(cid, message);
if (text == null || _isDisposed) return false;
_texts[cacheKey] = text;
return true;
} on SecretBoxAuthenticationError catch (error, stackTrace) {
// The encrypted text was changed, or copied from another message. Trying again won't help.
_texts[cacheKey] = undecryptable;
FlutterError.reportError(FlutterErrorDetails(exception: error, stack: stackTrace, library: 'encryption'));
return true;
} catch (_) {
// For example, the key couldn't be loaded yet, or the message has a format this version of
// the app doesn't know. The message keeps its placeholder.
return false;
} finally {
_inProgress.remove(cacheKey);
}
}
// The nonce changes every time the text is encrypted, so an edited message is decrypted again,
// while a reaction or a read receipt doesn't cause a new decryption. Null if not encrypted.
static String? _cacheKey(Message message) => switch (message.extraData[MessageCipher.field]) {
{'v': MessageCipher.formatVersion, 'iv': final String nonce} => '${message.id}/$nonce',
_ => null,
};
}Create one cache next to the cipher from the previous page:
final textCache = DecryptedTextCache(cipher);When the user logs out, remove ChatApp from the screen first, then call textCache.dispose() and channelKeys.close(), so no keys or decrypted text stay in memory for the next user.
Decrypt the messages of a channel
Create lib/channel_page.dart. This page shows a channel with the SDK's StreamChannelPage, which already handles replies, quoted replies, edits and threads. Around it, the page loads the channel's key, again whenever the key changes, and passes the channel's messages and thread replies to the cache as they arrive:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'encryption/channel_keys.dart';
import 'encryption/decrypted_text_cache.dart';
/// A page that shows one encrypted channel.
///
/// Needs a [StreamChannel] for [channel] above it.
class ChannelPage extends StatefulWidget {
/// Creates a page for [channel] that decrypts its messages into [textCache].
const ChannelPage({super.key, required this.channel, required this.channelKeys, required this.textCache});
/// The channel to show.
final Channel channel;
/// The keys for the channel, loaded when the page opens.
final ChannelKeys channelKeys;
/// The cache that decrypts and holds the channel's messages.
final DecryptedTextCache textCache;
@override
State<ChannelPage> createState() => _ChannelPageState();
}
class _ChannelPageState extends State<ChannelPage> {
late final StreamSubscription<List<Message>> _messagesSubscription;
late final StreamSubscription<Map<String, List<Message>>> _threadsSubscription;
late final StreamSubscription<Object?> _keyVersionSubscription;
@override
void initState() {
super.initState();
final channel = widget.channel;
final cid = channel.cid!;
// Load the key for new messages now, and again when the channel's key changes, so it's
// ready when the user taps Send.
widget.channelKeys.currentKey(channel).ignore();
_keyVersionSubscription = channel.extraDataStream
.map((extraData) => extraData['key_version'])
.distinct()
.listen((_) => widget.channelKeys.currentKey(channel).ignore());
_messagesSubscription = channel.state!.messagesStream.listen(
(messages) => widget.textCache.decryptAll(cid, messages),
);
_threadsSubscription = channel.state!.threadsStream.listen(
(threads) => widget.textCache.decryptAll(cid, threads.values.expand((replies) => replies)),
);
}
@override
void dispose() {
_messagesSubscription.cancel();
_threadsSubscription.cancel();
_keyVersionSubscription.cancel();
super.dispose();
}
// StreamChannelPage wires up replies, edits and threads. Its composers, including the one on
// the thread page, encrypt through the messageComposer builder given to StreamChat.
@override
Widget build(BuildContext context) => const StreamChannelPage();
}Open it inside a StreamChannel for the channel. The channel list below shows how.
Show decrypted text in the channel list
The channel list shows the placeholder by default. Create lib/encryption/decrypting_preview_formatter.dart with a subclass of StreamMessagePreviewFormatter that reads from the cache, so the list and screen readers show the decrypted text:
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'decrypted_text_cache.dart';
/// A message preview formatter that shows decrypted text in the channel list.
///
/// Screen readers read the decrypted text too. A channel whose last message hasn't been
/// decrypted yet shows the placeholder.
class DecryptingPreviewFormatter extends StreamMessagePreviewFormatter {
/// Creates a formatter that reads decrypted text from [textCache].
const DecryptingPreviewFormatter(this.textCache);
/// The cache that holds the decrypted text.
final DecryptedTextCache textCache;
@override
TextSpan formatMessage(
BuildContext context,
Message message, {
bool showCaption = true,
ChannelModel? channel,
User? currentUser,
}) {
return super.formatMessage(
context,
textCache.decrypted(message),
showCaption: showCaption,
channel: channel,
currentUser: currentUser,
);
}
@override
String formatMessageSemanticsLabel(
BuildContext context,
Message message, {
ChannelModel? channel,
User? currentUser,
bool showCaption = true,
}) {
return super.formatMessageSemanticsLabel(
context,
textCache.decrypted(message),
channel: channel,
currentUser: currentUser,
showCaption: showCaption,
);
}
}The cache only holds messages that have been decrypted. Create lib/channel_list_page.dart. It decrypts the newest messages of each loaded channel when the list changes, and each new or edited message as it arrives, so channels the user hasn't opened yet also show real previews. It rebuilds the list when the cache changes, and opens a channel when the user taps it:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'channel_page.dart';
import 'encryption/channel_keys.dart';
import 'encryption/decrypted_text_cache.dart';
/// The list of the user's channels, with decrypted previews.
class ChannelListPage extends StatefulWidget {
/// Creates a channel list for the user [client] is connected as.
const ChannelListPage({super.key, required this.client, required this.channelKeys, required this.textCache});
/// The Stream client, connected as the logged-in user.
final StreamChatClient client;
/// The keys from your server, passed on to each channel page.
final ChannelKeys channelKeys;
/// The cache that decrypts and holds message text.
final DecryptedTextCache textCache;
@override
State<ChannelListPage> createState() => _ChannelListPageState();
}
class _ChannelListPageState extends State<ChannelListPage> {
late final _controller = StreamChannelListController(
client: widget.client,
filter: Filter.in_('members', [widget.client.state.currentUser!.id]),
);
late final StreamSubscription<Event> _messageEvents;
@override
void initState() {
super.initState();
_controller.addListener(_decryptPreviews);
// A new message in the channel at the top of the list doesn't change the list, so the
// listener above doesn't run. Decrypt every new or edited message as it arrives instead.
_messageEvents = widget.client.on(EventType.messageNew, EventType.messageUpdated).listen((event) {
if (event case Event(cid: final cid?, message: final message?)) {
widget.textCache.decryptAll(cid, [message]);
}
});
}
@override
void dispose() {
_messageEvents.cancel();
_controller.removeListener(_decryptPreviews);
_controller.dispose();
super.dispose();
}
// Decrypts the newest messages of every loaded channel when channels load or move, so their
// previews can show real text.
void _decryptPreviews() {
if (!_controller.value.isSuccess) return;
for (final channel in _controller.currentItems) {
final newest = channel.state?.messages.reversed.take(3) ?? const <Message>[];
widget.textCache.decryptAll(channel.cid!, newest);
}
}
void _openChannel(Channel channel) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => StreamChannel(
channel: channel,
child: ChannelPage(channel: channel, channelKeys: widget.channelKeys, textCache: widget.textCache),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chats')),
body: ListenableBuilder(
listenable: widget.textCache,
builder: (context, _) => StreamChannelListView(controller: _controller, onChannelTap: _openChannel),
),
);
}
}The preview shows the newest message, but skips error and ephemeral messages, and other users' shadowed messages. Decrypting the newest three covers the common cases. Each channel needs its keys, so the first time the list loads, the app makes one key request per channel with encrypted messages. See the note in Set up your server for long channel lists.
Put it together
Create lib/chat_app.dart. StreamChat goes above the Navigator, in MaterialApp.builder, so every page you push can reach it. It brings together everything from this guide:
- the
messageComposerbuilder from the previous page, which encrypts every composer - a
messageItembuilder that shows decrypted text wherever a message appears, including threads, quoted replies and the long-press menu - the preview formatter, and server drafts turned off
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'channel_list_page.dart';
import 'encryption/channel_keys.dart';
import 'encryption/decrypted_text_cache.dart';
import 'encryption/decrypting_preview_formatter.dart';
import 'encryption/message_cipher.dart';
import 'encryption/message_limits.dart';
/// The app, with encryption added to every composer and every message.
class ChatApp extends StatelessWidget {
/// Creates the app for a logged-in user.
const ChatApp({
super.key,
required this.client,
required this.channelKeys,
required this.cipher,
required this.textCache,
});
/// The Stream client, connected as the logged-in user.
final StreamChatClient client;
/// The keys from your server.
final ChannelKeys channelKeys;
/// The cipher that encrypts new messages.
final MessageCipher cipher;
/// The cache that holds decrypted text.
final DecryptedTextCache textCache;
@override
Widget build(BuildContext context) {
return MaterialApp(
// StreamChat goes above the Navigator, so every page you push can reach it.
builder: (context, child) => StreamChat(
client: client,
configData: StreamChatConfigurationData(
draftMessagesEnabled: false,
messagePreviewFormatter: DecryptingPreviewFormatter(textCache),
),
componentBuilders: StreamComponentBuilders(
extensions: streamChatComponentBuilders(
messageComposer: (context, props) => DefaultStreamMessageComposer(
props: _encrypting(context, StreamChannel.of(context).channel, props),
),
messageItem: (context, props) => ListenableBuilder(
listenable: textCache,
builder: (context, _) => DefaultStreamMessageItem(
props: props.copyWith(message: textCache.decrypted(props.message)),
),
),
),
),
child: child,
),
home: ChannelListPage(client: client, channelKeys: channelKeys, textCache: textCache),
);
}
// 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;
}
}After the user logs in, start the app with the objects from this guide:
runApp(ChatApp(client: client, channelKeys: channelKeys, cipher: cipher, textCache: textCache));Messages that haven't been decrypted yet show the placeholder until they are. The message widget gets the decrypted copy, so users can read, copy, reply to and edit readable text. When they edit, preMessageSending encrypts the new text again.
A message that failed to send, for example while offline, keeps showing "Encrypted message" with "Message failed to send" until it's sent. That's on purpose: the SDK resends a failed message exactly as it's shown, so showing the decrypted text would resend it unencrypted. The real text comes back once the message is sent.
Because the message widget holds readable text, test every message action (send, edit, reply, quote, thread reply, react, pin, flag, and retry after going offline) with a network proxy such as Proxyman or Charles. Check that no readable text leaves the app.
Check it works: open a channel. Messages, quoted replies and threads show the real text, the channel list shows the real text of each channel's last message, and the server still has only "Encrypted message". Then send a message from another device to the channel at the top of the list: its preview shows the real text without opening the channel.
When a channel's key changes
When your server changes a channel's key, Stream sends a channel.updated event and the SDK updates the channel's key_version. An open channel page sees the change and fetches the new key right away. Otherwise ChannelKeys fetches it the next time the user taps Send, and the composer asks them to tap Send again once it's loaded. Older messages keep their key id, so the app can still decrypt them.
Next step
Read the limitations and go-live checklist before you release.