Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Symmetric encryption #192

Merged
merged 62 commits into from Nov 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
d02a652
Refactor classes into separate files
ben-xD Oct 6, 2021
caab4d2
Merge remote-tracking branch 'origin/feature/push-notifications-dart'…
ben-xD Oct 7, 2021
67acb68
Add CipherParams parameters and deserialize it correctly
ben-xD Oct 7, 2021
d9c4a3d
- Decode and use mode from ChannelOptions on Android
ben-xD Oct 7, 2021
4e54081
Temporarily remove cipher related code, to be re-added in a separate PR
ben-xD Oct 27, 2021
28080bf
Merge remote-tracking branch 'origin/main' into bug/channel-options
ben-xD Oct 27, 2021
fe83147
Continuating of recent commit: 4e540819c2600e296ee912febabe8431c6fc341c
ben-xD Oct 28, 2021
4072c44
Remove unnecessary code change
ben-xD Oct 29, 2021
8d6a36a
Revert "Continuating of recent commit: 4e540819c2600e296ee912febabe84…
ben-xD Oct 29, 2021
33e26b1
Read cipher from channel options in native side
ben-xD Oct 29, 2021
f8e3346
Merge branch 'bug/fix-runtime-crash-for-channel-options' into feature…
ben-xD Oct 29, 2021
69db673
WIP
ben-xD Oct 29, 2021
50887f3
Complete symmetric encryption on Android
ben-xD Nov 2, 2021
ddf01f9
WIP: Symmetric encryption on iOS
ben-xD Nov 2, 2021
426baca
Manually decode base64 on Android platform to overcome missing `getPa…
ben-xD Nov 2, 2021
6c44ad7
Require key to be non-null in interface
ben-xD Nov 2, 2021
2893d70
Merge remote-tracking branch 'origin/main' into feature/symmetric-enc…
ben-xD Nov 3, 2021
a6b5d73
Add generateRandomKey method on both iOS and Android
ben-xD Nov 3, 2021
b282d05
Revert changes to AblyFlutterReader
ben-xD Nov 3, 2021
084a2be
Implement and validate Android encryption works
ben-xD Nov 4, 2021
74cf012
Merge branch 'refactor/simplify-main-state' into feature/symmetric-en…
ben-xD Nov 4, 2021
4a868f3
Use EncryptedMessageService in example app correctly
ben-xD Nov 4, 2021
30cecd7
Add Publish encrypted messages with rest on example app
ben-xD Nov 4, 2021
d597a4c
Call connect only if not connected already
ben-xD Nov 5, 2021
dc26813
Run `flutter format .`
ben-xD Nov 5, 2021
497067f
Android: Add more documentation to CipherParamsStorage
ben-xD Nov 5, 2021
4a8b38c
iOS: Remove unused code
ben-xD Nov 5, 2021
13f8b9d
Formatting: Remove unused code, fix import style
ben-xD Nov 5, 2021
84ce348
Remove duplicate class
ben-xD Nov 5, 2021
dbe5f7e
Validate messages can be successfully encrypted and decrypted between…
ben-xD Nov 5, 2021
28f2996
Update comment
ben-xD Nov 5, 2021
81426f5
Remove unused code, fix state in example app encrypted messages UI, a…
ben-xD Nov 5, 2021
06fe63b
Run `flutter format .`
ben-xD Nov 5, 2021
afe25d5
Add readme documentation for message encryption
ben-xD Nov 8, 2021
6cd1382
Remove warning: symmetric encryption is not support
ben-xD Nov 8, 2021
fc50251
Simplification: replace 2 platform methods with usage of 1 existing p…
ben-xD Nov 8, 2021
5bac6af
Add additional docs to symmetric encryption
ben-xD Nov 8, 2021
0898c07
Merge remote-tracking branch 'origin/refactor/class-move' into featur…
ben-xD Nov 8, 2021
73786b0
Fix failing test by importing and improve documentation minimally
ben-xD Nov 8, 2021
e5c372e
Merge branch 'main' into feature/symmetric-encryption
QuintinWillison Nov 8, 2021
127856d
Apply suggestions from code review
ben-xD Nov 8, 2021
9b42541
Remove extra forward slash in doc
ben-xD Nov 8, 2021
fb9cb6a
Remove unnecessary blank line
ben-xD Nov 8, 2021
3c13d0f
"Dart-side" → "Dart side" and fix language
ben-xD Nov 8, 2021
68bb531
Improve docs language
ben-xD Nov 8, 2021
c08f148
Finish Javadoc comment on CIpherParamsStorage
ben-xD Nov 8, 2021
def6c2d
Apply suggestions from code review
ben-xD Nov 8, 2021
3faa10d
Move isolated bullet points into previous bullet points
ben-xD Nov 8, 2021
8ecfce0
Remove unused code. This code stopped being used in fc50251f67bcf6b15…
ben-xD Nov 8, 2021
61718ae
Replace bullet points with numbers
ben-xD Nov 8, 2021
2b6e91b
Add warning about hashing a password to get a key, and more documenta…
ben-xD Nov 9, 2021
a6ac0c3
Assert length of key is 256 bits
ben-xD Nov 9, 2021
7288f1b
Update README.md language
ben-xD Nov 9, 2021
e20a14d
Formatting (use `'` instead of `"`)
ben-xD Nov 9, 2021
bba9f4c
Remove key creation guidance / extra detail
ben-xD Nov 9, 2021
0acd964
Add support for 128 bit keys in symmetric encryption
ben-xD Nov 9, 2021
5106a99
Improve UI appearance (bolding, capitalization)
ben-xD Nov 9, 2021
9534782
Remove outdated comment
ben-xD Nov 9, 2021
7bfb9a7
Fix key length check (it was counting in bits, not bytes)
ben-xD Nov 9, 2021
d64a097
Merge remote-tracking branch 'origin/enhancement/update-dependencies'…
ben-xD Nov 9, 2021
3ac1957
Run `flutter pub get`
ben-xD Nov 9, 2021
457e594
Merge branch 'enhancement/update-dependencies' into feature/symmetric…
ben-xD Nov 9, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Expand Up @@ -43,7 +43,6 @@ You might also need to upgrade [gradle distribution](https://developer.android.c

Features that we do not currently support, but we do plan to add in the future:

- Symmetric encryption ([#104](https://github.com/ably/ably-flutter/issues/104))
- Ably token generation ([#105](https://github.com/ably/ably-flutter/issues/105))
- REST and Realtime Stats ([#106](https://github.com/ably/ably-flutter/issues/106))
- Custom transportParams ([#108](https://github.com/ably/ably-flutter/issues/108))
Expand Down Expand Up @@ -518,6 +517,27 @@ channel
);
```

### Symmetric Encryption

When a key is provided to the library, the `data` attribute of all messages is encrypted and decrypted automatically using that key. The secret key is never transmitted to Ably. See https://www.ably.com/documentation/realtime/encryption.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excess vertical whitespace. We never need more than one blank line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh thanks, fixed: fb9cb6a

1. Create a key by calling `ably.Crypto.generateRandomKey()` (or retrieve one from your server using your own secure API). The same key needs to be used to encrypt and decrypt the messages.
2. Create a `CipherParams` instance by passing a key to `final cipherParams = await ably.Crypto.getDefaultParams(key: key);` - the key can be a Base64-encoded `String`, or a `Uint8List`
3. Create a `RealtimeChannelOptions` or `RestChannelOptions` from this key: e.g. `final channelOptions = ably.RealtimeChannelOptions(cipher: cipherParams);`. Alternatively, if you are only setting CipherParams on ChannelOptions, you could skip creating the `CipherParams` instance: `ably.RestChannelOptions.withCipherKey(cipherKey)` or `ably.RealtimeChannelOptions.withCipherKey(cipherKey)`.
4. Set these options on your channel: `realtimeClient.channels.get(channelName).setOptions(channelOptions);`
5. Use your channel as normal, such as by publishing messages or subscribing for messages.

Overall, it would like this:
```dart
final key = ...; // from your server, from password or create random
final cipherParams = ably.Crypto.getDefaultParams(key: key);
final channelOptions = ably.RealtimeChannelOptions(cipherParams: cipherParams);
final channel = realtime.channels.get("your channel name");
await channel.setOptions(channelOptions);
```

Take a look at [`encrypted_message_service.dart`](example/lib/encrypted_messaging_service.dart) for an example of how to implement end-to-end encrypted messages over Ably. There are several options to choose from when you have decided to your encrypt your messages.

### Push Notifications

See [PushNotifications.md](PushNotifications.md) for detailed information on using PN with this plugin.
Expand Down
Expand Up @@ -10,10 +10,13 @@

import com.google.firebase.messaging.RemoteMessage;

import javax.crypto.Cipher;

import io.ably.flutter.plugin.generated.PlatformConstants;
import io.ably.flutter.plugin.push.RemoteMessageCallback;
import io.ably.flutter.plugin.push.PushActivationEventHandlers;
import io.ably.flutter.plugin.push.PushMessagingEventHandlers;
import io.ably.flutter.plugin.util.CipherParamsStorage;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
Expand Down Expand Up @@ -55,7 +58,7 @@ public static void registerWith(Registrar registrar) {
}

private void setupChannels(BinaryMessenger messenger, Context applicationContext) {
final MethodCodec codec = createCodec();
final MethodCodec codec = createCodec(new CipherParamsStorage());

final StreamsChannel streamsChannel = new StreamsChannel(messenger, "io.ably.flutter.stream", codec);
streamsChannel.setStreamHandlerFactory(arguments -> new AblyEventStreamHandler(applicationContext));
Expand All @@ -79,8 +82,8 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
System.out.println("Ably Plugin onDetachedFromEngine");
}

private static MethodCodec createCodec() {
return new StandardMethodCodec(new AblyMessageCodec());
private static MethodCodec createCodec(CipherParamsStorage cipherParamsStorage) {
return new StandardMethodCodec(new AblyMessageCodec(cipherParamsStorage));
}

@Override
Expand Down
72 changes: 39 additions & 33 deletions android/src/main/java/io/ably/flutter/plugin/AblyMessageCodec.java
Expand Up @@ -3,6 +3,7 @@
import androidx.annotation.Nullable;

import com.google.firebase.messaging.RemoteMessage;

import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
Expand All @@ -14,8 +15,11 @@
import java.util.HashMap;
import java.util.Map;

import javax.crypto.Cipher;

import io.ably.flutter.plugin.generated.PlatformConstants;
import io.ably.flutter.plugin.types.PlatformClientOptions;
import io.ably.flutter.plugin.util.CipherParamsStorage;
import io.ably.flutter.plugin.util.Consumer;
import io.ably.lib.push.LocalDevice;
import io.ably.lib.push.Push;
Expand All @@ -38,9 +42,9 @@
import io.ably.lib.types.ErrorInfo;
import io.ably.lib.types.Message;
import io.ably.lib.types.MessageExtras;
import io.ably.lib.types.PaginatedResult;
import io.ably.lib.types.Param;
import io.ably.lib.types.PresenceMessage;
import io.ably.lib.util.Crypto;
import io.flutter.plugin.common.StandardMessageCodec;

public class AblyMessageCodec extends StandardMessageCodec {
Expand Down Expand Up @@ -82,9 +86,11 @@ T decode(Map<String, Object> jsonMap) {

private Map<Byte, CodecPair> codecMap;
private static final Gson gson = new Gson();
private final CipherParamsStorage cipherParamsStorage;

public AblyMessageCodec() {
public AblyMessageCodec(CipherParamsStorage cipherParamsStorage) {
final AblyMessageCodec self = this;
this.cipherParamsStorage = cipherParamsStorage;
codecMap = new HashMap<Byte, CodecPair>() {
ikbalkaya marked this conversation as resolved.
Show resolved Hide resolved
{
put(PlatformConstants.CodecTypes.ablyMessage,
Expand Down Expand Up @@ -135,6 +141,8 @@ public AblyMessageCodec() {
new CodecPair<>(self::encodePushChannelSubscription, null));
put(PlatformConstants.CodecTypes.remoteMessage,
new CodecPair<>(self::encodeRemoteMessage, null));
put(PlatformConstants.CodecTypes.cipherParams,
new CodecPair<>(self::encodeCipherParams, self::decodeCipherParams));
}
};
}
Expand Down Expand Up @@ -189,6 +197,11 @@ private Byte getType(Object value) {
return PlatformConstants.CodecTypes.pushChannelSubscription;
} else if (value instanceof RemoteMessage) {
return PlatformConstants.CodecTypes.remoteMessage;
} else if (value instanceof Crypto.CipherParams) {
return PlatformConstants.CodecTypes.cipherParams;
} else if (value instanceof ChannelOptions) {
ikbalkaya marked this conversation as resolved.
Show resolved Hide resolved
// Encoding it into a RealtimeChannelOptions instance, because it extends RestChannelOptions
return PlatformConstants.CodecTypes.realtimeChannelOptions;
}
return null;
}
Expand Down Expand Up @@ -356,25 +369,20 @@ private Auth.TokenRequest decodeTokenRequest(Map<String, Object> jsonMap) {

private ChannelOptions decodeRestChannelOptions(Map<String, Object> jsonMap) {
if (jsonMap == null) return null;
final Object cipher = jsonMap.get(PlatformConstants.TxRealtimeChannelOptions.cipher);
try {
return createChannelOptions(cipher);
} catch (AblyException e) {
System.out.println("Exception while decoding RestChannelOptions: " + e);
return null;
ChannelOptions options = new ChannelOptions();
options.cipherParams = decodeCipherParams((Map<String, Object>) jsonMap.get(PlatformConstants.TxRestChannelOptions.cipherParams));
ikbalkaya marked this conversation as resolved.
Show resolved Hide resolved
if (options.cipherParams != null) {
options.encrypted = true;
}
return options;
}

private ChannelOptions decodeRealtimeChannelOptions(Map<String, Object> jsonMap) {
if (jsonMap == null) return null;
final Object cipher = jsonMap.get(PlatformConstants.TxRealtimeChannelOptions.cipher);

ChannelOptions options;
try {
options = createChannelOptions(cipher);
} catch (AblyException e) {
System.out.println("Exception while decoding RealtimeChannelOptions: " + e);
return null;
ChannelOptions options = new ChannelOptions();
options.cipherParams = decodeCipherParams((Map<String, Object>) jsonMap.get(PlatformConstants.TxRealtimeChannelOptions.cipherParams));
ikbalkaya marked this conversation as resolved.
Show resolved Hide resolved
if (options.cipherParams != null) {
options.encrypted = true;
}
options.params = (Map<String, String>) jsonMap.get(PlatformConstants.TxRealtimeChannelOptions.params);
final ArrayList<String> modes = (ArrayList<String>) jsonMap.get(PlatformConstants.TxRealtimeChannelOptions.modes);
Expand All @@ -385,23 +393,21 @@ private ChannelOptions decodeRealtimeChannelOptions(Map<String, Object> jsonMap)
return options;
}

private ChannelOptions createChannelOptions(@Nullable Object cipher) throws AblyException {
if (cipher == null) return new ChannelOptions();
if (cipher instanceof String) {
try {
return ChannelOptions.withCipherKey((String) cipher);
} catch (AblyException ae) {
throw AblyException.fromErrorInfo(new ErrorInfo("Exception while decoding RealtimeChannelOptions as String: " + ae, 400, 40000));
}
} else if (cipher instanceof byte[]) {
try {
return ChannelOptions.withCipherKey((byte[]) cipher);
} catch (AblyException ae) {
throw AblyException.fromErrorInfo(new ErrorInfo("Exception while decoding RealtimeChannelOptions as byte array: " + ae, 400, 40000));
}
} else {
throw AblyException.fromErrorInfo(new ErrorInfo("CipherKey must either be a String or a Byte Array.", 400, 40000));
}
private Map<String, Object> encodeCipherParams(Crypto.CipherParams cipherParams) {
if (cipherParams == null) return null;
final Integer handle = cipherParamsStorage.getHandle(cipherParams);
HashMap<String, Object> jsonMap = new HashMap<>();

// All other properties in CipherParams are package private, so cannot be exposed to Dart side.
jsonMap.put(PlatformConstants.TxCipherParams.androidHandle, handle);

return jsonMap;
}

private Crypto.CipherParams decodeCipherParams(@Nullable Map<String, Object> cipherParamsDictionary) {
if (cipherParamsDictionary == null) return null;
final Integer cipherParamsHandle = (Integer) cipherParamsDictionary.get(PlatformConstants.TxCipherParams.androidHandle);
return cipherParamsStorage.from(cipherParamsHandle);
}

private ChannelMode[] createChannelModesArray(ArrayList<String> modesString) {
Expand Down
Expand Up @@ -9,6 +9,7 @@

import com.google.firebase.messaging.RemoteMessage;

import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
Expand All @@ -34,6 +35,8 @@
import io.ably.lib.types.ErrorInfo;
import io.ably.lib.types.Message;
import io.ably.lib.types.Param;
import io.ably.lib.util.Base64Coder;
import io.ably.lib.util.Crypto;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;

Expand All @@ -52,7 +55,9 @@ public interface HotRestartCallback {
@Nullable
private RemoteMessage remoteMessageFromUserTapLaunchesApp;

public AblyMethodCallHandler(final MethodChannel channel, final HotRestartCallback hotRestartCallback, final Context applicationContext) {
public AblyMethodCallHandler(final MethodChannel channel,
final HotRestartCallback hotRestartCallback,
final Context applicationContext) {
this.channel = channel;
this.applicationContext = applicationContext;
this.hotRestartCallback = hotRestartCallback;
Expand Down Expand Up @@ -102,6 +107,10 @@ public AblyMethodCallHandler(final MethodChannel channel, final HotRestartCallba
// paginated results
_map.put(PlatformConstants.PlatformMethod.nextPage, this::getNextPage);
_map.put(PlatformConstants.PlatformMethod.firstPage, this::getFirstPage);

// Encryption
ikbalkaya marked this conversation as resolved.
Show resolved Hide resolved
_map.put(PlatformConstants.PlatformMethod.cryptoGetParams, this::cryptoGetParams);
_map.put(PlatformConstants.PlatformMethod.cryptoGenerateRandomKey, this::cryptoGenerateRandomKey);
}

// MethodChannel.Result wrapper that responds on the platform thread.
Expand Down Expand Up @@ -239,9 +248,9 @@ private void setRestChannelOptions(
this.<AblyFlutterMessage<Map<String, Object>>>ablyDo(message, (ablyLibrary, messageData) -> {
final Map<String, Object> map = messageData.message;
final String channelName = (String) map.get(PlatformConstants.TxTransportKeys.channelName);
final ChannelOptions options = (ChannelOptions) map.get(PlatformConstants.TxTransportKeys.options);
final ChannelOptions channelOptions = (ChannelOptions) map.get(PlatformConstants.TxTransportKeys.options);
try {
ablyLibrary.getRest(messageData.handle).channels.get(channelName, options);
ablyLibrary.getRest(messageData.handle).channels.get(channelName, channelOptions);
} catch (AblyException ae) {
handleAblyException(result, ae);
}
Expand Down Expand Up @@ -751,6 +760,40 @@ private void getFirstPage(@NonNull MethodCall call, @NonNull MethodChannel.Resul
_ably.getPaginatedResult(pageHandle).first(this.paginatedResponseHandler(result, pageHandle));
}

private void cryptoGetParams(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
final Map<String, Object> message = (Map<String, Object>) call.arguments;
final String algorithm = (String) message.get(PlatformConstants.TxCryptoGetParams.algorithm);
final byte[] keyData = getKeyData(message.get(PlatformConstants.TxCryptoGetParams.key));
if (keyData == null) {
result.error("40000", "A key must be set for encryption, being either a base64 encoded key, or a byte array.", null);
return;
}

try {
result.success(Crypto.getParams(algorithm, keyData));
} catch (NoSuchAlgorithmException e) {
result.error("40000", "cryptoGetParams: No algorithm found. " + e, e);
}
}

private byte[] getKeyData(Object key) {
if (key == null) {
return null;
}
if (key instanceof String) {
return Base64Coder.decode((String) key);
} else if (key instanceof byte[]) {
return (byte[]) key;
} else {
return null;
}
}

private void cryptoGenerateRandomKey(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
final Integer keyLength = (Integer) call.arguments;
result.success(Crypto.generateRandomKey(keyLength));
}

// Extracts the message from an AblyFlutterMessage.
//
// It also passed the ablyLibrary argument, which you can just get with _ably without using this method
Expand Down
Expand Up @@ -34,6 +34,7 @@ static final public class CodecTypes {
public static final byte errorInfo = (byte) 150;
public static final byte connectionStateChange = (byte) 151;
public static final byte channelStateChange = (byte) 152;
public static final byte cipherParams = (byte) 153;
}

static final public class PlatformMethod {
Expand Down Expand Up @@ -89,6 +90,8 @@ static final public class PlatformMethod {
public static final String onRealtimeChannelMessage = "onRealtimeChannelMessage";
public static final String nextPage = "nextPage";
public static final String firstPage = "firstPage";
public static final String cryptoGetParams = "cryptoGetParams";
public static final String cryptoGenerateRandomKey = "cryptoGenerateRandomKey";
}

static final public class TxTransportKeys {
Expand Down Expand Up @@ -173,13 +176,19 @@ static final public class TxClientOptions {
}

static final public class TxRestChannelOptions {
public static final String cipher = "cipher";
public static final String cipherParams = "cipherParams";
}

static final public class TxRealtimeChannelOptions {
public static final String cipher = "cipher";
public static final String params = "params";
public static final String modes = "modes";
public static final String cipherParams = "cipherParams";
}

static final public class TxCipherParams {
public static final String androidHandle = "androidHandle";
public static final String iosAlgorithm = "iosAlgorithm";
public static final String iosKey = "iosKey";
}

static final public class TxTokenDetails {
Expand Down Expand Up @@ -416,4 +425,13 @@ static final public class TxNotification {
public static final String body = "body";
}

static final public class TxCryptoGetParams {
public static final String algorithm = "algorithm";
public static final String key = "key";
}

static final public class TxCryptoGenerateRandomKey {
public static final String keyLength = "keyLength";
}

}
Expand Up @@ -14,6 +14,7 @@
import com.google.firebase.messaging.RemoteMessage;

import io.ably.flutter.plugin.AblyMessageCodec;
import io.ably.flutter.plugin.util.CipherParamsStorage;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.plugin.common.MethodCall;
Expand All @@ -36,7 +37,7 @@ public PushBackgroundIsolateRunner(Context context, FirebaseMessagingReceiver re
this.remoteMessage = message;
flutterEngine = new FlutterEngine(context, null);
DartExecutor executor = flutterEngine.getDartExecutor();
backgroundMethodChannel = new MethodChannel(executor.getBinaryMessenger(), "io.ably.flutter.plugin.background", new StandardMethodCodec(new AblyMessageCodec()));
backgroundMethodChannel = new MethodChannel(executor.getBinaryMessenger(), "io.ably.flutter.plugin.background", new StandardMethodCodec(new AblyMessageCodec(new CipherParamsStorage())));
backgroundMethodChannel.setMethodCallHandler(this);
// Get and launch the users app isolate manually:
executor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault());
Expand Down