79 Commits

Author SHA1 Message Date
e29ee07015 release: Tag 0.4.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-07 22:16:15 +01:00
cc31475671 fix: Ratchets are not removed when using removeAllRatchets
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-01-07 22:14:10 +01:00
de85ab7955 chore: Bump version
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-01-02 13:57:15 +01:00
a08c654295 feat: Do not fetch bundles for our own device 2023-01-02 13:56:22 +01:00
3825232ebe Merge pull request #24 from PapaTutuWawa/feat/omemomanager
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
OmemoManager.
2023-01-01 16:59:00 +01:00
32c0c5a4f0 feat: Update CHANGELOG and README 2023-01-01 16:55:15 +01:00
092ce36410 feat: Add last features for use in moxxmpp 2023-01-01 16:49:19 +01:00
4085631804 fix: Make logging less verbose 2022-12-27 12:49:11 +01:00
6a9e98665d fix: Track new KEX entries using a ratchet key 2022-12-27 12:47:45 +01:00
5f844dafda feat: Add getDeviceId 2022-12-27 12:43:50 +01:00
480de1ce8f test: Port the sending/receiving for the OmemoManager 2022-12-27 12:43:50 +01:00
5e6b54aab5 feat: Better guard against failed lookups 2022-12-27 12:43:50 +01:00
6c4dd62c5a feat: _decryptMessage explicitly tells us that a ratchet was created 2022-12-27 12:43:50 +01:00
54eeb816eb feat: Export OmemoManager 2022-12-27 12:43:50 +01:00
e35f65aff9 style: Fix style issues 2022-12-27 12:43:50 +01:00
4dc3cfb2b1 feat: Deprecate OmemoSessionManager 2022-12-27 12:43:50 +01:00
bca4840ca6 test: Add a test to ensure the library cannot get stuck 2022-12-27 12:43:50 +01:00
caf841c53e feat: Add more documentation 2022-12-27 12:43:50 +01:00
5bc1136ec0 feat: Implement getting fingerprints 2022-12-27 12:43:50 +01:00
d37a4bd719 chore: Bump version 2022-12-27 12:43:50 +01:00
b48665c357 feat: Begin work on the OmemoManager interface 2022-12-27 12:43:50 +01:00
06707d1a34 feat: Compute the fingerprint of a bundle 2022-12-27 12:43:18 +01:00
4346b31637 feat: Add funding
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-27 01:47:08 +01:00
fe1ba99b14 feat: Indicate whether a ratchet has been created or not
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-09 20:41:53 +01:00
797bf69856 fix: Crash when calling getDevicesTrust for unknown Jid
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-12-09 18:42:23 +01:00
afad5056c0 release: Bump version to 0.3.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2022-11-05 13:50:45 +01:00
c68471349a fix: Reuse old key exchange when the ratchet is unacked
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-10-22 12:41:41 +02:00
1472624b1d fix: Use stanza receival timestamps to guard against stale kex messages
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-10-02 19:23:58 +02:00
0826d043d5 feat: Attempt to detect already decrypted messages 2022-10-02 17:03:39 +02:00
2aa3674c4b fix: Fix receiving an old key exchange breaking decryption
This was mostly caused by Dart not copying values but referencing
them. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.

We know make some assumptions about received key exchanges, so this
needs some field testing.
2022-10-02 14:56:20 +02:00
7c3a9a75df chore: Let pub ignore the protobuf build artifacts
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-09-29 15:24:33 +02:00
bc6f98bcd8 release: Version 0.3.1
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2022-09-29 15:24:33 +02:00
a107dfad87 ci: Fix issue with duplicate naming
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2022-09-28 13:18:39 +02:00
96771cf317 feat: Allow getting the device's fingerprint 2022-09-25 12:50:59 +02:00
bea433e377 fix: React to all decryption errors with ratchet restoration 2022-09-25 11:35:12 +02:00
47948fa6ea build: Add Woodpecker CI 2022-09-22 21:33:22 +02:00
a23dd30eee chore: Commit the protobuf artifacts 2022-09-18 15:56:09 +02:00
b4c14a9769 fix: Guard against a crash in the critical section 2022-09-15 23:33:07 +02:00
b69acdd936 fix: Fix replaceOnetimePrekey mutating the device's id 2022-09-15 23:31:56 +02:00
3d8c82fe5b docs: Update README 2022-09-15 13:52:29 +02:00
1e7f66ccc3 release: Bump to 0.3.0 2022-09-15 13:51:18 +02:00
0480e9156f fix: Fix occurence of not using synchronized's return 2022-09-15 13:46:52 +02:00
96d9c55c87 fix: Make fromJson* functions work when reading JSON from a String 2022-09-15 13:41:33 +02:00
49c847a96b feat: Remove toJson and fromJson from OmemoSessionManager 2022-09-15 13:22:50 +02:00
cf5331a026 feat: Introduce logging for logging purposes 2022-09-15 13:17:30 +02:00
c1d8073af0 refactor: Remove BTBV's loadState method 2022-09-15 13:06:14 +02:00
438012d8f8 fix: Hopefully fix all tests being flaky
It seems that the varint encoding function would not work for
some integers as input. This should in theory fix this issue. Since
the SPK IDs are randomly between 0 and 2**32 - 1, it makes sense that
the tests fail only sometimes.
2022-09-14 23:50:54 +02:00
79704da99c fix: Fix issues with the maps being unmodifiable 2022-09-14 22:02:50 +02:00
4341797f14 fix: Commit the restored ratchet 2022-09-14 22:02:35 +02:00
c5c579810e fix: Restore the ratchets in case of an error
This means that if the ratchet fails to decrypt a message, from the
outside it will be as if that one message had never been received.
Thus, the ratchet can be used normally. This is to guard against
messages that are received again.
2022-09-14 21:58:41 +02:00
8991599a0b feat: Allow removing all ratchets for a given Jid 2022-09-11 17:26:54 +02:00
dad938b0e1 feat: Allow initializing the BTBV trust manager in the constructor 2022-09-11 13:43:07 +02:00
ff52c82039 feat: Help with serializing and deserializing the BTVT manager 2022-09-11 13:33:45 +02:00
12e09947f6 feat: Implement enabling and disabling devices 2022-09-11 12:34:31 +02:00
d530358359 chore: Lower meta's minimum version 2022-09-09 17:40:16 +02:00
0e370a8e19 refactor: Use synchronized's return 2022-09-09 17:39:28 +02:00
b6aa28e1ee release: Bump version 2022-08-19 17:00:44 +02:00
2e10842c54 feat: Make accepted ratchets unacknowledged by default 2022-08-19 16:59:24 +02:00
0e2af1f2a3 feat: Add a function to check if a ratchet is acknowledged 2022-08-19 16:58:23 +02:00
80e1b20f27 fix: Fix crash when calling getUnacknowledgedRatchets for a new Jid 2022-08-18 16:21:53 +02:00
f68e45af26 docs: Update README 2022-08-18 15:35:07 +02:00
345596923e release: Bump version to 0.2.0 2022-08-18 15:33:49 +02:00
d5d4aa9014 feat: Add getDeviceId and getDeviceBundle 2022-08-18 15:30:31 +02:00
ee7b09bdb0 feat: Ratchets should overwrite each other 2022-08-18 15:20:32 +02:00
73613e266f feat: Allow regerating a device's id 2022-08-18 15:08:05 +02:00
0a03483aaf feat: Allow regenerating one's device identity 2022-08-18 15:02:17 +02:00
fda06cef55 feat: Implement acknowledging ratchet sessions 2022-08-16 14:02:04 +02:00
800b53b11f style: Fix linter warnings 2022-08-16 13:46:08 +02:00
5a097e4d2a feat: Allow removing a ratchet session 2022-08-16 13:27:21 +02:00
710b3c9497 feat: Allow serialising and deserialising without the ratchets 2022-08-16 12:57:16 +02:00
f540a80ec2 docs: Remove trust manager serialization from OmemoSessionManager 2022-08-16 12:54:15 +02:00
44ab31aebb release: Bump version to 0.1.3 2022-08-11 12:06:02 +02:00
e4f1d7d4b0 docs: Fix indent in README 2022-08-11 12:04:40 +02:00
7600804aa1 test: Uncomment those tests 2022-08-11 12:02:33 +02:00
a4589b6e09 feat: Allow access to the device map 2022-08-11 12:02:21 +02:00
d0986a4608 fix: Fix ratchet only working for one message 2022-08-11 11:57:33 +02:00
683a76cc80 test: Add test for sending multiple OMEMO messages 2022-08-10 17:24:35 +02:00
dad707f71d release: Sigh, bump to 0.1.2 2022-08-09 16:40:41 +02:00
419be8af4d Revert "meta: Lower Dart SDK requirement"
This reverts commit fafc4f2320.
2022-08-09 16:40:19 +02:00
42 changed files with 3733 additions and 269 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
ko_fi: papatutuwawa

3
.gitignore vendored
View File

@@ -12,6 +12,3 @@ pubspec.lock
# NixOS # NixOS
.direnv .direnv
.envrc .envrc
# Protobuf build artifacts
lib/protobuf/*.dart

View File

@@ -5,6 +5,6 @@ line-length=72
[title-trailing-punctuation] [title-trailing-punctuation]
[title-hard-tab] [title-hard-tab]
[title-match-regex] [title-match-regex]
regex=^(feat|fix|test|release|chore|security|docs|refactor|meta):.*$ regex=^(feat|fix|test|release|chore|security|docs|refactor|style):.*$
[body-trailing-whitespace] [body-trailing-whitespace]
[body-first-line-empty] [body-first-line-empty]

1
.pubignore Normal file
View File

@@ -0,0 +1 @@
lib/protobuf

11
.woodpecker.yml Normal file
View File

@@ -0,0 +1,11 @@
pipeline:
lint:
image: dart:2.18.1
commands:
- dart pub get
- dart analyze --fatal-infos --fatal-warnings
test:
image: dart:2.18.1
commands:
- dart pub get
- dart test

View File

@@ -4,3 +4,49 @@
- Implement the Double Ratchet, X3DH and OMEMO specific bits - Implement the Double Ratchet, X3DH and OMEMO specific bits
- Add a Blind-Trust-Before-Verification TrustManager - Add a Blind-Trust-Before-Verification TrustManager
- Supported OMEMO version: 0.8.3 - Supported OMEMO version: 0.8.3
## 0.1.3
- Fix bug with the Double Ratchet causing only the initial message to be decryptable
- Expose `getDeviceMap` as a developer usable function
## 0.2.0
- Add convenience functions `getDeviceId` and `getDeviceBundle`
- Creating a new ratchet with an id for which we already have a ratchet will now overwrite the old ratchet
- Ratchet now carry an "acknowledged" attribute
## 0.2.1
- Add `isRatchetAcknowledged`
- Ratchets that are created due to accepting a kex are now unacknowledged
## 0.3.0
- Implement enabling and disabling ratchets via the TrustManager interface
- Fix deserialization of the various objects
- Remove the BTBV TrustManager's loadState method. Just use the constructor
- Allow removing all ratchets for a given Jid
- If an error occurs while decrypting the message, the ratchet will now be reset to its prior state
- Fix a bug within the Varint encoding function. This should fix some occasional UnknownSignedPrekeyExceptions
- Remove OmemoSessionManager's toJson and fromJson. Use toJsonWithoutSessions and fromJsonWithoutSessions. Restoring sessions is not out-of-scope for that function
## 0.3.1
- Fix a bug that caused the device's id to change when replacing a OPK
- Every decryption failure now causes the ratchet to be restored to a pre-decryption state
- Add method to get the device's fingerprint
## 0.4.0
- Deprecate `OmemoSessionManager`. Use `OmemoManager` instead.
- Implement queued access to the ratchets inside the `OmemoManager`.
- Implement heartbeat messages.
- [BREAKING] Rename `Device` to `OmemoDevice`.
## 0.4.1
- Fix fetching the current device and building a ratchet session with it when encrypting for our own JID
## 0.4.2
- Fix removeAllRatchets not removing, well, all ratchets. In fact, it did not remove any ratchet.

View File

@@ -28,7 +28,7 @@ Include `omemo_dart` in your `pubspec.yaml` like this:
dependencies: dependencies:
omemo_dart: omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: ^0.1.0 version: ^0.4.0
# [...] # [...]
# [...] # [...]
@@ -56,3 +56,9 @@ messages' formatting.
Licensed under the MIT license. Licensed under the MIT license.
See `LICENSE`. See `LICENSE`.
## Support
If you like what I do and you want to support me, feel free to donate to me on Ko-Fi.
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/assets/repo/kofi.png" height="36" style="height: 36px; border: 0px;"></img>](https://ko-fi.com/papatutuwawa)

View File

@@ -10,3 +10,7 @@ linter:
analyzer: analyzer:
exclude: exclude:
- "lib/protobuf/*.dart" - "lib/protobuf/*.dart"
# TODO: Remove once OmemoSessionManager is gone
- "test/omemo_test.dart"
- "example/omemo_dart_example.dart"
- "test/serialisation_test.dart"

View File

@@ -65,7 +65,7 @@ void main() async {
// a new session. Let's also assume that Bob only has one device. We may, however, // a new session. Let's also assume that Bob only has one device. We may, however,
// add more bundles to newSessions, if we know of more. // add more bundles to newSessions, if we know of more.
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
], ],
); );
@@ -119,6 +119,8 @@ void main() async {
aliceDevice.id, aliceDevice.id,
// The deserialised keys // The deserialised keys
keys, keys,
// Since the message was not delayed, we use the current time
DateTime.now().millisecondsSinceEpoch,
); );
// All Bob has to do now is replace the OMEMO wrapper element // All Bob has to do now is replace the OMEMO wrapper element

View File

@@ -10,8 +10,10 @@ export 'src/omemo/encrypted_key.dart';
export 'src/omemo/encryption_result.dart'; export 'src/omemo/encryption_result.dart';
export 'src/omemo/events.dart'; export 'src/omemo/events.dart';
export 'src/omemo/fingerprint.dart'; export 'src/omemo/fingerprint.dart';
export 'src/omemo/omemomanager.dart';
export 'src/omemo/ratchet_map_key.dart'; export 'src/omemo/ratchet_map_key.dart';
export 'src/omemo/sessionmanager.dart'; export 'src/omemo/sessionmanager.dart';
export 'src/omemo/stanza.dart';
export 'src/trust/base.dart'; export 'src/trust/base.dart';
export 'src/trust/btbv.dart'; export 'src/trust/btbv.dart';
export 'src/x3dh/x3dh.dart'; export 'src/x3dh/x3dh.dart';

263
lib/protobuf/schema.pb.dart Normal file
View File

@@ -0,0 +1,263 @@
///
// Generated code. Do not modify.
// source: schema.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides,camel_case_types,constant_identifier_names,directives_ordering,library_prefixes,non_constant_identifier_names,prefer_final_fields,return_of_invalid_type,unnecessary_const,unnecessary_import,unnecessary_this,unused_import,unused_shown_name
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class OMEMOMessage extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'OMEMOMessage', createEmptyInstance: create)
..a<$core.int>(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'n', $pb.PbFieldType.QU3)
..a<$core.int>(2, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'pn', $pb.PbFieldType.QU3)
..a<$core.List<$core.int>>(3, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'dhPub', $pb.PbFieldType.QY)
..a<$core.List<$core.int>>(4, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'ciphertext', $pb.PbFieldType.OY)
;
OMEMOMessage._() : super();
factory OMEMOMessage({
$core.int? n,
$core.int? pn,
$core.List<$core.int>? dhPub,
$core.List<$core.int>? ciphertext,
}) {
final _result = create();
if (n != null) {
_result.n = n;
}
if (pn != null) {
_result.pn = pn;
}
if (dhPub != null) {
_result.dhPub = dhPub;
}
if (ciphertext != null) {
_result.ciphertext = ciphertext;
}
return _result;
}
factory OMEMOMessage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory OMEMOMessage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
OMEMOMessage clone() => OMEMOMessage()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
OMEMOMessage copyWith(void Function(OMEMOMessage) updates) => super.copyWith((message) => updates(message as OMEMOMessage)) as OMEMOMessage; // ignore: deprecated_member_use
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static OMEMOMessage create() => OMEMOMessage._();
OMEMOMessage createEmptyInstance() => create();
static $pb.PbList<OMEMOMessage> createRepeated() => $pb.PbList<OMEMOMessage>();
@$core.pragma('dart2js:noInline')
static OMEMOMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<OMEMOMessage>(create);
static OMEMOMessage? _defaultInstance;
@$pb.TagNumber(1)
$core.int get n => $_getIZ(0);
@$pb.TagNumber(1)
set n($core.int v) { $_setUnsignedInt32(0, v); }
@$pb.TagNumber(1)
$core.bool hasN() => $_has(0);
@$pb.TagNumber(1)
void clearN() => clearField(1);
@$pb.TagNumber(2)
$core.int get pn => $_getIZ(1);
@$pb.TagNumber(2)
set pn($core.int v) { $_setUnsignedInt32(1, v); }
@$pb.TagNumber(2)
$core.bool hasPn() => $_has(1);
@$pb.TagNumber(2)
void clearPn() => clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get dhPub => $_getN(2);
@$pb.TagNumber(3)
set dhPub($core.List<$core.int> v) { $_setBytes(2, v); }
@$pb.TagNumber(3)
$core.bool hasDhPub() => $_has(2);
@$pb.TagNumber(3)
void clearDhPub() => clearField(3);
@$pb.TagNumber(4)
$core.List<$core.int> get ciphertext => $_getN(3);
@$pb.TagNumber(4)
set ciphertext($core.List<$core.int> v) { $_setBytes(3, v); }
@$pb.TagNumber(4)
$core.bool hasCiphertext() => $_has(3);
@$pb.TagNumber(4)
void clearCiphertext() => clearField(4);
}
class OMEMOAuthenticatedMessage extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'OMEMOAuthenticatedMessage', createEmptyInstance: create)
..a<$core.List<$core.int>>(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'mac', $pb.PbFieldType.QY)
..a<$core.List<$core.int>>(2, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'message', $pb.PbFieldType.QY)
;
OMEMOAuthenticatedMessage._() : super();
factory OMEMOAuthenticatedMessage({
$core.List<$core.int>? mac,
$core.List<$core.int>? message,
}) {
final _result = create();
if (mac != null) {
_result.mac = mac;
}
if (message != null) {
_result.message = message;
}
return _result;
}
factory OMEMOAuthenticatedMessage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory OMEMOAuthenticatedMessage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
OMEMOAuthenticatedMessage clone() => OMEMOAuthenticatedMessage()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
OMEMOAuthenticatedMessage copyWith(void Function(OMEMOAuthenticatedMessage) updates) => super.copyWith((message) => updates(message as OMEMOAuthenticatedMessage)) as OMEMOAuthenticatedMessage; // ignore: deprecated_member_use
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static OMEMOAuthenticatedMessage create() => OMEMOAuthenticatedMessage._();
OMEMOAuthenticatedMessage createEmptyInstance() => create();
static $pb.PbList<OMEMOAuthenticatedMessage> createRepeated() => $pb.PbList<OMEMOAuthenticatedMessage>();
@$core.pragma('dart2js:noInline')
static OMEMOAuthenticatedMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<OMEMOAuthenticatedMessage>(create);
static OMEMOAuthenticatedMessage? _defaultInstance;
@$pb.TagNumber(1)
$core.List<$core.int> get mac => $_getN(0);
@$pb.TagNumber(1)
set mac($core.List<$core.int> v) { $_setBytes(0, v); }
@$pb.TagNumber(1)
$core.bool hasMac() => $_has(0);
@$pb.TagNumber(1)
void clearMac() => clearField(1);
@$pb.TagNumber(2)
$core.List<$core.int> get message => $_getN(1);
@$pb.TagNumber(2)
set message($core.List<$core.int> v) { $_setBytes(1, v); }
@$pb.TagNumber(2)
$core.bool hasMessage() => $_has(1);
@$pb.TagNumber(2)
void clearMessage() => clearField(2);
}
class OMEMOKeyExchange extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(const $core.bool.fromEnvironment('protobuf.omit_message_names') ? '' : 'OMEMOKeyExchange', createEmptyInstance: create)
..a<$core.int>(1, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'pkId', $pb.PbFieldType.QU3)
..a<$core.int>(2, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'spkId', $pb.PbFieldType.QU3)
..a<$core.List<$core.int>>(3, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'ik', $pb.PbFieldType.QY)
..a<$core.List<$core.int>>(4, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'ek', $pb.PbFieldType.QY)
..aQM<OMEMOAuthenticatedMessage>(5, const $core.bool.fromEnvironment('protobuf.omit_field_names') ? '' : 'message', subBuilder: OMEMOAuthenticatedMessage.create)
;
OMEMOKeyExchange._() : super();
factory OMEMOKeyExchange({
$core.int? pkId,
$core.int? spkId,
$core.List<$core.int>? ik,
$core.List<$core.int>? ek,
OMEMOAuthenticatedMessage? message,
}) {
final _result = create();
if (pkId != null) {
_result.pkId = pkId;
}
if (spkId != null) {
_result.spkId = spkId;
}
if (ik != null) {
_result.ik = ik;
}
if (ek != null) {
_result.ek = ek;
}
if (message != null) {
_result.message = message;
}
return _result;
}
factory OMEMOKeyExchange.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory OMEMOKeyExchange.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
OMEMOKeyExchange clone() => OMEMOKeyExchange()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
OMEMOKeyExchange copyWith(void Function(OMEMOKeyExchange) updates) => super.copyWith((message) => updates(message as OMEMOKeyExchange)) as OMEMOKeyExchange; // ignore: deprecated_member_use
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static OMEMOKeyExchange create() => OMEMOKeyExchange._();
OMEMOKeyExchange createEmptyInstance() => create();
static $pb.PbList<OMEMOKeyExchange> createRepeated() => $pb.PbList<OMEMOKeyExchange>();
@$core.pragma('dart2js:noInline')
static OMEMOKeyExchange getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<OMEMOKeyExchange>(create);
static OMEMOKeyExchange? _defaultInstance;
@$pb.TagNumber(1)
$core.int get pkId => $_getIZ(0);
@$pb.TagNumber(1)
set pkId($core.int v) { $_setUnsignedInt32(0, v); }
@$pb.TagNumber(1)
$core.bool hasPkId() => $_has(0);
@$pb.TagNumber(1)
void clearPkId() => clearField(1);
@$pb.TagNumber(2)
$core.int get spkId => $_getIZ(1);
@$pb.TagNumber(2)
set spkId($core.int v) { $_setUnsignedInt32(1, v); }
@$pb.TagNumber(2)
$core.bool hasSpkId() => $_has(1);
@$pb.TagNumber(2)
void clearSpkId() => clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get ik => $_getN(2);
@$pb.TagNumber(3)
set ik($core.List<$core.int> v) { $_setBytes(2, v); }
@$pb.TagNumber(3)
$core.bool hasIk() => $_has(2);
@$pb.TagNumber(3)
void clearIk() => clearField(3);
@$pb.TagNumber(4)
$core.List<$core.int> get ek => $_getN(3);
@$pb.TagNumber(4)
set ek($core.List<$core.int> v) { $_setBytes(3, v); }
@$pb.TagNumber(4)
$core.bool hasEk() => $_has(3);
@$pb.TagNumber(4)
void clearEk() => clearField(4);
@$pb.TagNumber(5)
OMEMOAuthenticatedMessage get message => $_getN(4);
@$pb.TagNumber(5)
set message(OMEMOAuthenticatedMessage v) { setField(5, v); }
@$pb.TagNumber(5)
$core.bool hasMessage() => $_has(4);
@$pb.TagNumber(5)
void clearMessage() => clearField(5);
@$pb.TagNumber(5)
OMEMOAuthenticatedMessage ensureMessage() => $_ensure(4);
}

View File

@@ -0,0 +1,7 @@
///
// Generated code. Do not modify.
// source: schema.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides,camel_case_types,constant_identifier_names,directives_ordering,library_prefixes,non_constant_identifier_names,prefer_final_fields,return_of_invalid_type,unnecessary_const,unnecessary_import,unnecessary_this,unused_import,unused_shown_name

View File

@@ -0,0 +1,48 @@
///
// Generated code. Do not modify.
// source: schema.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides,camel_case_types,constant_identifier_names,deprecated_member_use_from_same_package,directives_ordering,library_prefixes,non_constant_identifier_names,prefer_final_fields,return_of_invalid_type,unnecessary_const,unnecessary_import,unnecessary_this,unused_import,unused_shown_name
import 'dart:core' as $core;
import 'dart:convert' as $convert;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use oMEMOMessageDescriptor instead')
const OMEMOMessage$json = const {
'1': 'OMEMOMessage',
'2': const [
const {'1': 'n', '3': 1, '4': 2, '5': 13, '10': 'n'},
const {'1': 'pn', '3': 2, '4': 2, '5': 13, '10': 'pn'},
const {'1': 'dh_pub', '3': 3, '4': 2, '5': 12, '10': 'dhPub'},
const {'1': 'ciphertext', '3': 4, '4': 1, '5': 12, '10': 'ciphertext'},
],
};
/// Descriptor for `OMEMOMessage`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List oMEMOMessageDescriptor = $convert.base64Decode('CgxPTUVNT01lc3NhZ2USDAoBbhgBIAIoDVIBbhIOCgJwbhgCIAIoDVICcG4SFQoGZGhfcHViGAMgAigMUgVkaFB1YhIeCgpjaXBoZXJ0ZXh0GAQgASgMUgpjaXBoZXJ0ZXh0');
@$core.Deprecated('Use oMEMOAuthenticatedMessageDescriptor instead')
const OMEMOAuthenticatedMessage$json = const {
'1': 'OMEMOAuthenticatedMessage',
'2': const [
const {'1': 'mac', '3': 1, '4': 2, '5': 12, '10': 'mac'},
const {'1': 'message', '3': 2, '4': 2, '5': 12, '10': 'message'},
],
};
/// Descriptor for `OMEMOAuthenticatedMessage`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List oMEMOAuthenticatedMessageDescriptor = $convert.base64Decode('ChlPTUVNT0F1dGhlbnRpY2F0ZWRNZXNzYWdlEhAKA21hYxgBIAIoDFIDbWFjEhgKB21lc3NhZ2UYAiACKAxSB21lc3NhZ2U=');
@$core.Deprecated('Use oMEMOKeyExchangeDescriptor instead')
const OMEMOKeyExchange$json = const {
'1': 'OMEMOKeyExchange',
'2': const [
const {'1': 'pk_id', '3': 1, '4': 2, '5': 13, '10': 'pkId'},
const {'1': 'spk_id', '3': 2, '4': 2, '5': 13, '10': 'spkId'},
const {'1': 'ik', '3': 3, '4': 2, '5': 12, '10': 'ik'},
const {'1': 'ek', '3': 4, '4': 2, '5': 12, '10': 'ek'},
const {'1': 'message', '3': 5, '4': 2, '5': 11, '6': '.OMEMOAuthenticatedMessage', '10': 'message'},
],
};
/// Descriptor for `OMEMOKeyExchange`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List oMEMOKeyExchangeDescriptor = $convert.base64Decode('ChBPTUVNT0tleUV4Y2hhbmdlEhMKBXBrX2lkGAEgAigNUgRwa0lkEhUKBnNwa19pZBgCIAIoDVIFc3BrSWQSDgoCaWsYAyACKAxSAmlrEg4KAmVrGAQgAigMUgJlaxI0CgdtZXNzYWdlGAUgAigLMhouT01FTU9BdXRoZW50aWNhdGVkTWVzc2FnZVIHbWVzc2FnZQ==');

View File

@@ -0,0 +1,9 @@
///
// Generated code. Do not modify.
// source: schema.proto
//
// @dart = 2.12
// ignore_for_file: annotate_overrides,camel_case_types,constant_identifier_names,deprecated_member_use_from_same_package,directives_ordering,library_prefixes,non_constant_identifier_names,prefer_final_fields,return_of_invalid_type,unnecessary_const,unnecessary_import,unnecessary_this,unused_import,unused_shown_name
export 'schema.pb.dart';

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:hex/hex.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/crypto.dart';
import 'package:omemo_dart/src/double_ratchet/crypto.dart'; import 'package:omemo_dart/src/double_ratchet/crypto.dart';
@@ -13,7 +14,6 @@ import 'package:omemo_dart/src/protobuf/omemo_message.dart';
const maxSkip = 1000; const maxSkip = 1000;
class RatchetStep { class RatchetStep {
const RatchetStep(this.header, this.ciphertext); const RatchetStep(this.header, this.ciphertext);
final OmemoMessage header; final OmemoMessage header;
final List<int> ciphertext; final List<int> ciphertext;
@@ -21,7 +21,6 @@ class RatchetStep {
@immutable @immutable
class SkippedKey { class SkippedKey {
const SkippedKey(this.dh, this.n); const SkippedKey(this.dh, this.n);
factory SkippedKey.fromJson(Map<String, dynamic> data) { factory SkippedKey.fromJson(Map<String, dynamic> data) {
@@ -54,7 +53,6 @@ class SkippedKey {
} }
class OmemoDoubleRatchet { class OmemoDoubleRatchet {
OmemoDoubleRatchet( OmemoDoubleRatchet(
this.dhs, // DHs this.dhs, // DHs
this.dhr, // DHr this.dhr, // DHr
@@ -67,6 +65,9 @@ class OmemoDoubleRatchet {
this.ik, this.ik,
this.sessionAd, this.sessionAd,
this.mkSkipped, // MKSKIPPED this.mkSkipped, // MKSKIPPED
this.acknowledged,
this.kexTimestamp,
this.kex,
); );
factory OmemoDoubleRatchet.fromJson(Map<String, dynamic> data) { factory OmemoDoubleRatchet.fromJson(Map<String, dynamic> data) {
@@ -81,8 +82,11 @@ class OmemoDoubleRatchet {
'ns': 0, 'ns': 0,
'nr': 0, 'nr': 0,
'pn': 0, 'pn': 0,
'ik_pub': 'base/64/encoded', 'ik_pub': null | 'base/64/encoded',
'session_ad': 'base/64/encoded', 'session_ad': 'base/64/encoded',
'acknowledged': true | false,
'kex_timestamp': int,
'kex': 'base/64/encoded',
'mkskipped': [ 'mkskipped': [
{ {
'key': 'base/64/encoded', 'key': 'base/64/encoded',
@@ -92,11 +96,20 @@ class OmemoDoubleRatchet {
] ]
} }
*/ */
final mkSkipped = <SkippedKey, List<int>>{}; // NOTE: Dart has some issues with just casting a List<dynamic> to List<Map<...>>, as
for (final entry in data['mkskipped']! as List<Map<String, dynamic>>) { // such we need to convert the items by hand.
final key = SkippedKey.fromJson(entry); final mkSkipped = Map<SkippedKey, List<int>>.fromEntries(
mkSkipped[key] = base64.decode(entry['key']! as String); (data['mkskipped']! as List<dynamic>).map<MapEntry<SkippedKey, List<int>>>(
} (entry) {
final map = entry as Map<String, dynamic>;
final key = SkippedKey.fromJson(map);
return MapEntry(
key,
base64.decode(map['key']! as String),
);
},
),
);
return OmemoDoubleRatchet( return OmemoDoubleRatchet(
OmemoKeyPair.fromBytes( OmemoKeyPair.fromBytes(
@@ -117,6 +130,9 @@ class OmemoDoubleRatchet {
), ),
base64.decode(data['session_ad']! as String), base64.decode(data['session_ad']! as String),
mkSkipped, mkSkipped,
data['acknowledged']! as bool,
data['kex_timestamp']! as int,
data['kex'] as String?,
); );
} }
@@ -148,10 +164,21 @@ class OmemoDoubleRatchet {
final Map<SkippedKey, List<int>> mkSkipped; final Map<SkippedKey, List<int>> mkSkipped;
/// The point in time at which we performed the kex exchange to create this ratchet.
/// Precision is milliseconds since epoch.
int kexTimestamp;
/// The key exchange that was used for initiating the session.
final String? kex;
/// Indicates whether we received an empty OMEMO message after building a session with
/// the device.
bool acknowledged;
/// Create an OMEMO session using the Signed Pre Key [spk], the shared secret [sk] that /// Create an OMEMO session using the Signed Pre Key [spk], the shared secret [sk] that
/// was obtained using a X3DH and the associated data [ad] that was also obtained through /// was obtained using a X3DH and the associated data [ad] that was also obtained through
/// a X3DH. [ik] refers to Bob's (the receiver's) IK public key. /// a X3DH. [ik] refers to Bob's (the receiver's) IK public key.
static Future<OmemoDoubleRatchet> initiateNewSession(OmemoPublicKey spk, OmemoPublicKey ik, List<int> sk, List<int> ad) async { static Future<OmemoDoubleRatchet> initiateNewSession(OmemoPublicKey spk, OmemoPublicKey ik, List<int> sk, List<int> ad, int timestamp) async {
final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); final dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
final dhr = spk; final dhr = spk;
final rk = await kdfRk(sk, await omemoDH(dhs, dhr, 0)); final rk = await kdfRk(sk, await omemoDH(dhs, dhr, 0));
@@ -169,6 +196,9 @@ class OmemoDoubleRatchet {
ik, ik,
ad, ad,
{}, {},
false,
timestamp,
'',
); );
} }
@@ -176,7 +206,7 @@ class OmemoDoubleRatchet {
/// Pre Key keypair [spk], the shared secret [sk] that was obtained through a X3DH and /// Pre Key keypair [spk], the shared secret [sk] that was obtained through a X3DH and
/// the associated data [ad] that was also obtained through a X3DH. [ik] refers to /// the associated data [ad] that was also obtained through a X3DH. [ik] refers to
/// Alice's (the initiator's) IK public key. /// Alice's (the initiator's) IK public key.
static Future<OmemoDoubleRatchet> acceptNewSession(OmemoKeyPair spk, OmemoPublicKey ik, List<int> sk, List<int> ad) async { static Future<OmemoDoubleRatchet> acceptNewSession(OmemoKeyPair spk, OmemoPublicKey ik, List<int> sk, List<int> ad, int kexTimestamp) async {
return OmemoDoubleRatchet( return OmemoDoubleRatchet(
spk, spk,
null, null,
@@ -189,6 +219,9 @@ class OmemoDoubleRatchet {
ik, ik,
ad, ad,
{}, {},
true,
kexTimestamp,
null,
); );
} }
@@ -214,9 +247,18 @@ class OmemoDoubleRatchet {
'ik_pub': base64.encode(await ik.getBytes()), 'ik_pub': base64.encode(await ik.getBytes()),
'session_ad': base64.encode(sessionAd), 'session_ad': base64.encode(sessionAd),
'mkskipped': mkSkippedSerialised, 'mkskipped': mkSkippedSerialised,
'acknowledged': acknowledged,
'kex_timestamp': kexTimestamp,
'kex': kex,
}; };
} }
/// Returns the OMEMO compatible fingerprint of the ratchet session.
Future<String> getOmemoFingerprint() async {
final curveKey = await ik.toCurve25519();
return HEX.encode(await curveKey.getBytes());
}
Future<List<int>?> _trySkippedMessageKeys(OmemoMessage header, List<int> ciphertext) async { Future<List<int>?> _trySkippedMessageKeys(OmemoMessage header, List<int> ciphertext) async {
final key = SkippedKey( final key = SkippedKey(
OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519), OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519),
@@ -249,18 +291,18 @@ class OmemoDoubleRatchet {
} }
Future<void> _dhRatchet(OmemoMessage header) async { Future<void> _dhRatchet(OmemoMessage header) async {
pn = header.n!; pn = ns;
ns = 0; ns = 0;
nr = 0; nr = 0;
dhr = OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519); dhr = OmemoPublicKey.fromBytes(header.dhPub!, KeyPairType.x25519);
final newRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0)); final newRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0));
rk = newRk; rk = List.from(newRk);
ckr = newRk; ckr = List.from(newRk);
dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); dhs = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
final newNewRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0)); final newNewRk = await kdfRk(rk, await omemoDH(dhs, dhr!, 0));
rk = newNewRk; rk = List.from(newNewRk);
cks = newNewRk; cks = List.from(newNewRk);
} }
/// Encrypt [plaintext] using the Double Ratchet. /// Encrypt [plaintext] using the Double Ratchet.
@@ -293,7 +335,11 @@ class OmemoDoubleRatchet {
return plaintext; return plaintext;
} }
if (header.dhPub != await dhr?.getBytes()) { final dhPubMatches = listsEqual(
header.dhPub!,
(await dhr?.getBytes()) ?? <int>[],
);
if (!dhPubMatches) {
await _skipMessageKeys(header.pn!); await _skipMessageKeys(header.pn!);
await _dhRatchet(header); await _dhRatchet(header);
} }
@@ -307,12 +353,64 @@ class OmemoDoubleRatchet {
return decrypt(mk, ciphertext, concat([sessionAd, header.writeToBuffer()]), sessionAd); return decrypt(mk, ciphertext, concat([sessionAd, header.writeToBuffer()]), sessionAd);
} }
OmemoDoubleRatchet clone() {
return OmemoDoubleRatchet(
dhs,
dhr,
rk,
cks != null ?
List<int>.from(cks!) :
null,
ckr != null ?
List<int>.from(ckr!) :
null,
ns,
nr,
pn,
ik,
sessionAd,
Map<SkippedKey, List<int>>.from(mkSkipped),
acknowledged,
kexTimestamp,
kex,
);
}
OmemoDoubleRatchet cloneWithKex(String kex) {
return OmemoDoubleRatchet(
dhs,
dhr,
rk,
cks != null ?
List<int>.from(cks!) :
null,
ckr != null ?
List<int>.from(ckr!) :
null,
ns,
nr,
pn,
ik,
sessionAd,
Map<SkippedKey, List<int>>.from(mkSkipped),
acknowledged,
kexTimestamp,
kex,
);
}
@visibleForTesting @visibleForTesting
Future<bool> equals(OmemoDoubleRatchet other) async { Future<bool> equals(OmemoDoubleRatchet other) async {
final dhrMatch = dhr == null ?
other.dhr == null :
// ignore: invalid_use_of_visible_for_testing_member // ignore: invalid_use_of_visible_for_testing_member
final dhrMatch = dhr == null ? other.dhr == null : await dhr!.equals(other.dhr!); other.dhr != null && await dhr!.equals(other.dhr!);
final ckrMatch = ckr == null ? other.ckr == null : listsEqual(ckr!, other.ckr!); final ckrMatch = ckr == null ?
final cksMatch = cks == null ? other.cks == null : listsEqual(cks!, other.cks!); other.ckr == null :
other.ckr != null && listsEqual(ckr!, other.ckr!);
final cksMatch = cks == null ?
other.cks == null :
other.cks != null && listsEqual(cks!, other.cks!);
// ignore: invalid_use_of_visible_for_testing_member // ignore: invalid_use_of_visible_for_testing_member
final dhsMatch = await dhs.equals(other.dhs); final dhsMatch = await dhs.equals(other.dhs);
@@ -328,6 +426,7 @@ class OmemoDoubleRatchet {
ns == other.ns && ns == other.ns &&
nr == other.nr && nr == other.nr &&
pn == other.pn && pn == other.pn &&
listsEqual(sessionAd, other.sessionAd); listsEqual(sessionAd, other.sessionAd) &&
kexTimestamp == other.kexTimestamp;
} }
} }

View File

@@ -1,32 +1,54 @@
abstract class OmemoException {}
/// Triggered during X3DH if the signature if the SPK does verify to the actual SPK. /// Triggered during X3DH if the signature if the SPK does verify to the actual SPK.
class InvalidSignatureException implements Exception { class InvalidSignatureException extends OmemoException implements Exception {
String errMsg() => 'The signature of the SPK does not match the provided signature'; String errMsg() => 'The signature of the SPK does not match the provided signature';
} }
/// Triggered by the Double Ratchet if the computed HMAC does not match the attached HMAC. /// Triggered by the Double Ratchet if the computed HMAC does not match the attached HMAC.
/// Triggered by the Session Manager if the computed HMAC does not match the attached HMAC. /// Triggered by the Session Manager if the computed HMAC does not match the attached HMAC.
class InvalidMessageHMACException implements Exception { class InvalidMessageHMACException extends OmemoException implements Exception {
String errMsg() => 'The computed HMAC does not match the provided HMAC'; String errMsg() => 'The computed HMAC does not match the provided HMAC';
} }
/// Triggered by the Double Ratchet if skipping messages would cause skipping more than /// Triggered by the Double Ratchet if skipping messages would cause skipping more than
/// MAXSKIP messages /// MAXSKIP messages
class SkippingTooManyMessagesException implements Exception { class SkippingTooManyMessagesException extends OmemoException implements Exception {
String errMsg() => 'Skipping messages would cause a skip bigger than MAXSKIP'; String errMsg() => 'Skipping messages would cause a skip bigger than MAXSKIP';
} }
/// Triggered by the Session Manager if the message key is not encrypted for the device. /// Triggered by the Session Manager if the message key is not encrypted for the device.
class NotEncryptedForDeviceException implements Exception { class NotEncryptedForDeviceException extends OmemoException implements Exception {
String errMsg() => 'Not encrypted for this device'; String errMsg() => 'Not encrypted for this device';
} }
/// Triggered by the Session Manager when there is no key for decrypting the message. /// Triggered by the Session Manager when there is no key for decrypting the message.
class NoDecryptionKeyException implements Exception { class NoDecryptionKeyException extends OmemoException implements Exception {
String errMsg() => 'No key available for decrypting the message'; String errMsg() => 'No key available for decrypting the message';
} }
/// Triggered by the Session Manager when the identifier of the used Signed Prekey /// Triggered by the Session Manager when the identifier of the used Signed Prekey
/// is neither the current SPK's identifier nor the old one's. /// is neither the current SPK's identifier nor the old one's.
class UnknownSignedPrekeyException implements Exception { class UnknownSignedPrekeyException extends OmemoException implements Exception {
String errMsg() => 'Unknown Signed Prekey used.'; String errMsg() => 'Unknown Signed Prekey used.';
} }
/// Triggered by the Session Manager when the received Key Exchange message does not meet
/// the requirement that a key exchange, given that the ratchet already exists, must be
/// sent after its creation.
class InvalidKeyExchangeException extends OmemoException implements Exception {
String errMsg() => 'The key exchange was sent before the last kex finished';
}
/// Triggered by the Session Manager when a message's sequence number is smaller than we
/// expect it to be.
class MessageAlreadyDecryptedException extends OmemoException implements Exception {
String errMsg() => 'The message has already been decrypted';
}
/// Triggered by the OmemoManager when we could not encrypt a message as we have
/// no key material available. That happens, for example, when we want to create a
/// ratchet session with a JID we had no session with but fetching the device bundle
/// failed.
class NoKeyMaterialAvailableException extends OmemoException implements Exception {
String errMsg() => 'No key material available to create a ratchet session with';
}

View File

@@ -73,3 +73,7 @@ OmemoKeyPair? decodeKeyPairIfNotNull(String? pk, String? sk, KeyPairType type) {
type, type,
); );
} }
int getTimestamp() {
return DateTime.now().millisecondsSinceEpoch;
}

View File

@@ -9,7 +9,6 @@ const privateKeyLength = 32;
const publicKeyLength = 32; const publicKeyLength = 32;
class OmemoPublicKey { class OmemoPublicKey {
const OmemoPublicKey(this._pubkey); const OmemoPublicKey(this._pubkey);
factory OmemoPublicKey.fromBytes(List<int> bytes, KeyPairType type) { factory OmemoPublicKey.fromBytes(List<int> bytes, KeyPairType type) {
@@ -55,7 +54,6 @@ class OmemoPublicKey {
} }
class OmemoPrivateKey { class OmemoPrivateKey {
const OmemoPrivateKey(this._privkey, this.type); const OmemoPrivateKey(this._privkey, this.type);
final List<int> _privkey; final List<int> _privkey;
final KeyPairType type; final KeyPairType type;
@@ -85,7 +83,6 @@ class OmemoPrivateKey {
/// A generic wrapper class for both Ed25519 and X25519 keypairs /// A generic wrapper class for both Ed25519 and X25519 keypairs
class OmemoKeyPair { class OmemoKeyPair {
const OmemoKeyPair(this.pk, this.sk, this.type); const OmemoKeyPair(this.pk, this.sk, this.type);
/// Create an OmemoKeyPair just from a [type] and the bytes of the private and public /// Create an OmemoKeyPair just from a [type] and the bytes of the private and public

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:hex/hex.dart';
import 'package:omemo_dart/src/keys.dart'; import 'package:omemo_dart/src/keys.dart';
class OmemoBundle { class OmemoBundle {
@@ -43,4 +44,10 @@ class OmemoBundle {
} }
List<int> get spkSignature => base64Decode(spkSignatureEncoded); List<int> get spkSignature => base64Decode(spkSignatureEncoded);
/// Calculates the fingerprint of the bundle (See
/// https://xmpp.org/extensions/xep-0384.html#security § 2).
Future<String> getFingerprint() async {
return HEX.encode(await ik.getBytes());
}
} }

View File

@@ -0,0 +1,2 @@
/// The info used for when encrypting the AES key for the actual payload.
const omemoPayloadInfoString = 'OMEMO Payload';

View File

@@ -0,0 +1,9 @@
import 'package:meta/meta.dart';
import 'package:omemo_dart/src/errors.dart';
@immutable
class DecryptionResult {
const DecryptionResult(this.payload, this.error);
final String? payload;
final OmemoException? error;
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:hex/hex.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:omemo_dart/src/helpers.dart'; import 'package:omemo_dart/src/helpers.dart';
import 'package:omemo_dart/src/keys.dart'; import 'package:omemo_dart/src/keys.dart';
@@ -8,9 +9,8 @@ import 'package:omemo_dart/src/x3dh/x3dh.dart';
/// This class represents an OmemoBundle but with all keypairs belonging to the keys /// This class represents an OmemoBundle but with all keypairs belonging to the keys
@immutable @immutable
class Device { class OmemoDevice {
const OmemoDevice(
const Device(
this.jid, this.jid,
this.id, this.id,
this.ik, this.ik,
@@ -23,7 +23,7 @@ class Device {
); );
/// Deserialize the Device /// Deserialize the Device
factory Device.fromJson(Map<String, dynamic> data) { factory OmemoDevice.fromJson(Map<String, dynamic> data) {
// NOTE: We use the way OpenSSH names their keys, meaning that ik is the Identity // NOTE: We use the way OpenSSH names their keys, meaning that ik is the Identity
// Keypair's private key, while ik_pub refers to the Identity Keypair's public // Keypair's private key, while ik_pub refers to the Identity Keypair's public
// key. // key.
@@ -49,16 +49,25 @@ class Device {
] ]
} }
*/ */
final opks = <int, OmemoKeyPair>{}; // NOTE: Dart has some issues with just casting a List<dynamic> to List<Map<...>>, as
for (final opk in data['opks']! as List<Map<String, dynamic>>) { // such we need to convert the items by hand.
opks[opk['id']! as int] = OmemoKeyPair.fromBytes( final opks = Map<int, OmemoKeyPair>.fromEntries(
base64.decode(opk['public']! as String), (data['opks']! as List<dynamic>).map<MapEntry<int, OmemoKeyPair>>(
base64.decode(opk['private']! as String), (opk) {
final map = opk as Map<String, dynamic>;
return MapEntry(
map['id']! as int,
OmemoKeyPair.fromBytes(
base64.decode(map['public']! as String),
base64.decode(map['private']! as String),
KeyPairType.x25519, KeyPairType.x25519,
),
);
},
),
); );
}
return Device( return OmemoDevice(
data['jid']! as String, data['jid']! as String,
data['id']! as int, data['id']! as int,
OmemoKeyPair.fromBytes( OmemoKeyPair.fromBytes(
@@ -84,7 +93,7 @@ class Device {
} }
/// Generate a completely new device, i.e. cryptographic identity. /// Generate a completely new device, i.e. cryptographic identity.
static Future<Device> generateNewDevice(String jid, { int opkAmount = 100 }) async { static Future<OmemoDevice> generateNewDevice(String jid, { int opkAmount = 100 }) async {
final id = generateRandom32BitNumber(); final id = generateRandom32BitNumber();
final ik = await OmemoKeyPair.generateNewPair(KeyPairType.ed25519); final ik = await OmemoKeyPair.generateNewPair(KeyPairType.ed25519);
final spk = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); final spk = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
@@ -96,7 +105,7 @@ class Device {
opks[i] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); opks[i] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
} }
return Device(jid, id, ik, spk, spkId, signature, null, null, opks); return OmemoDevice(jid, id, ik, spk, spkId, signature, null, null, opks);
} }
/// Our bare Jid /// Our bare Jid
@@ -126,12 +135,12 @@ class Device {
/// This replaces the Onetime-Prekey with id [id] with a completely new one. Returns /// This replaces the Onetime-Prekey with id [id] with a completely new one. Returns
/// a new Device object that copies over everything but replaces said key. /// a new Device object that copies over everything but replaces said key.
@internal @internal
Future<Device> replaceOnetimePrekey(int id) async { Future<OmemoDevice> replaceOnetimePrekey(int id) async {
opks[id] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); opks[id] = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
return Device( return OmemoDevice(
jid, jid,
id, this.id,
ik, ik,
spk, spk,
spkId, spkId,
@@ -145,12 +154,12 @@ class Device {
/// This replaces the Signed-Prekey with a completely new one. Returns a new Device object /// This replaces the Signed-Prekey with a completely new one. Returns a new Device object
/// that copies over everything but replaces the Signed-Prekey and its signature. /// that copies over everything but replaces the Signed-Prekey and its signature.
@internal @internal
Future<Device> replaceSignedPrekey() async { Future<OmemoDevice> replaceSignedPrekey() async {
final newSpk = await OmemoKeyPair.generateNewPair(KeyPairType.x25519); final newSpk = await OmemoKeyPair.generateNewPair(KeyPairType.x25519);
final newSpkId = generateRandom32BitNumber(); final newSpkId = generateRandom32BitNumber();
final newSignature = await sig(ik, await newSpk.pk.getBytes()); final newSignature = await sig(ik, await newSpk.pk.getBytes());
return Device( return OmemoDevice(
jid, jid,
id, id,
ik, ik,
@@ -163,6 +172,23 @@ class Device {
); );
} }
/// Returns a new device that is equal to this one with the exception that the new
/// device's id is a new number between 0 and 2**32 - 1.
@internal
OmemoDevice withNewId() {
return OmemoDevice(
jid,
generateRandom32BitNumber(),
ik,
spk,
spkId,
spkSignature,
oldSpk,
oldSpkId,
opks,
);
}
/// Converts this device into an OmemoBundle that could be used for publishing. /// Converts this device into an OmemoBundle that could be used for publishing.
Future<OmemoBundle> toBundle() async { Future<OmemoBundle> toBundle() async {
final encodedOpks = <int, String>{}; final encodedOpks = <int, String>{};
@@ -182,6 +208,13 @@ class Device {
); );
} }
/// Returns the fingerprint of the current device
Future<String> getFingerprint() async {
// Since the local key is Ed25519, we must convert it to Curve25519 first
final curveKey = await ik.pk.toCurve25519();
return HEX.encode(await curveKey.getBytes());
}
/// Serialise the device information. /// Serialise the device information.
Future<Map<String, dynamic>> toJson() async { Future<Map<String, dynamic>> toJson() async {
/// Serialise the OPKs /// Serialise the OPKs
@@ -211,7 +244,7 @@ class Device {
} }
@visibleForTesting @visibleForTesting
Future<bool> equals(Device other) async { Future<bool> equals(OmemoDevice other) async {
var opksMatch = true; var opksMatch = true;
if (opks.length != other.opks.length) { if (opks.length != other.opks.length) {
opksMatch = false; opksMatch = false;

View File

@@ -1,15 +1,26 @@
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:omemo_dart/src/errors.dart';
import 'package:omemo_dart/src/omemo/encrypted_key.dart'; import 'package:omemo_dart/src/omemo/encrypted_key.dart';
import 'package:omemo_dart/src/omemo/ratchet_map_key.dart';
@immutable @immutable
class EncryptionResult { class EncryptionResult {
const EncryptionResult(this.ciphertext, this.encryptedKeys, this.deviceEncryptionErrors, this.jidEncryptionErrors);
const EncryptionResult(this.ciphertext, this.encryptedKeys); /// The actual message that was encrypted.
/// The actual message that was encrypted
final List<int>? ciphertext; final List<int>? ciphertext;
/// Mapping of the device Id to the key for decrypting ciphertext, encrypted /// Mapping of the device Id to the key for decrypting ciphertext, encrypted
/// for the ratchet with said device Id /// for the ratchet with said device Id.
final List<EncryptedKey> encryptedKeys; final List<EncryptedKey> encryptedKeys;
/// Mapping of a ratchet map keys to a possible exception.
final Map<RatchetMapKey, OmemoException> deviceEncryptionErrors;
/// Mapping of a JID to a possible exception.
final Map<String, OmemoException> jidEncryptionErrors;
/// True if the encryption was a success. This means that we could encrypt for
/// at least one ratchet.
bool isSuccess(int numberOfRecipients) => encryptedKeys.isNotEmpty && jidEncryptionErrors.length < numberOfRecipients;
} }

View File

@@ -5,24 +5,31 @@ abstract class OmemoEvent {}
/// Triggered when a ratchet has been modified /// Triggered when a ratchet has been modified
class RatchetModifiedEvent extends OmemoEvent { class RatchetModifiedEvent extends OmemoEvent {
RatchetModifiedEvent(this.jid, this.deviceId, this.ratchet, this.added);
RatchetModifiedEvent(this.jid, this.deviceId, this.ratchet);
final String jid; final String jid;
final int deviceId; final int deviceId;
final OmemoDoubleRatchet ratchet; final OmemoDoubleRatchet ratchet;
/// Indicates whether the ratchet has just been created (true) or just modified (false).
final bool added;
}
/// Triggered when a ratchet has been removed and should be removed from storage.
class RatchetRemovedEvent extends OmemoEvent {
RatchetRemovedEvent(this.jid, this.deviceId);
final String jid;
final int deviceId;
} }
/// Triggered when the device map has been modified /// Triggered when the device map has been modified
class DeviceMapModifiedEvent extends OmemoEvent { class DeviceListModifiedEvent extends OmemoEvent {
DeviceListModifiedEvent(this.list);
DeviceMapModifiedEvent(this.map); final Map<String, List<int>> list;
final Map<String, List<int>> map;
} }
/// Triggered by the OmemoSessionManager when our own device bundle was modified /// Triggered by the OmemoSessionManager when our own device bundle was modified
/// and thus should be republished. /// and thus should be republished.
class DeviceModifiedEvent extends OmemoEvent { class DeviceModifiedEvent extends OmemoEvent {
DeviceModifiedEvent(this.device); DeviceModifiedEvent(this.device);
final Device device; final OmemoDevice device;
} }

View File

@@ -2,7 +2,6 @@ import 'package:meta/meta.dart';
@immutable @immutable
class DeviceFingerprint { class DeviceFingerprint {
const DeviceFingerprint(this.deviceId, this.fingerprint); const DeviceFingerprint(this.deviceId, this.fingerprint);
final String fingerprint; final String fingerprint;
final int deviceId; final int deviceId;

View File

@@ -0,0 +1,787 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:cryptography/cryptography.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:omemo_dart/src/crypto.dart';
import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart';
import 'package:omemo_dart/src/errors.dart';
import 'package:omemo_dart/src/helpers.dart';
import 'package:omemo_dart/src/keys.dart';
import 'package:omemo_dart/src/omemo/bundle.dart';
import 'package:omemo_dart/src/omemo/constants.dart';
import 'package:omemo_dart/src/omemo/decryption_result.dart';
import 'package:omemo_dart/src/omemo/device.dart';
import 'package:omemo_dart/src/omemo/encrypted_key.dart';
import 'package:omemo_dart/src/omemo/encryption_result.dart';
import 'package:omemo_dart/src/omemo/events.dart';
import 'package:omemo_dart/src/omemo/fingerprint.dart';
import 'package:omemo_dart/src/omemo/ratchet_map_key.dart';
import 'package:omemo_dart/src/omemo/stanza.dart';
import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart';
import 'package:omemo_dart/src/protobuf/omemo_key_exchange.dart';
import 'package:omemo_dart/src/protobuf/omemo_message.dart';
import 'package:omemo_dart/src/trust/base.dart';
import 'package:omemo_dart/src/x3dh/x3dh.dart';
import 'package:synchronized/synchronized.dart';
class _InternalDecryptionResult {
const _InternalDecryptionResult(this.ratchetCreated, this.payload);
final bool ratchetCreated;
final String? payload;
}
class OmemoManager {
OmemoManager(
this._device,
this._trustManager,
this.sendEmptyOmemoMessageImpl,
this.fetchDeviceListImpl,
this.fetchDeviceBundleImpl,
this.subscribeToDeviceListNodeImpl,
);
final Logger _log = Logger('OmemoManager');
/// Functions for connecting with the OMEMO library
/// Send an empty OMEMO:2 message using the encrypted payload @result to
/// @recipientJid.
final Future<void> Function(EncryptionResult result, String recipientJid) sendEmptyOmemoMessageImpl;
/// Fetch the list of device ids associated with @jid. If the device list cannot be
/// fetched, return null.
final Future<List<int>?> Function(String jid) fetchDeviceListImpl;
/// Fetch the device bundle for the device with id @id of jid. If it cannot be fetched, return null.
final Future<OmemoBundle?> Function(String jid, int id) fetchDeviceBundleImpl;
/// Subscribe to the device list PEP node of @jid.
final Future<void> Function(String jid) subscribeToDeviceListNodeImpl;
/// Map bare JID to its known devices
Map<String, List<int>> _deviceList = {};
/// Map bare JIDs to whether we already requested the device list once
final Map<String, bool> _deviceListRequested = {};
/// Map bare a ratchet key to its ratchet. Note that this is also locked by
/// _ratchetCriticalSectionLock.
Map<RatchetMapKey, OmemoDoubleRatchet> _ratchetMap = {};
/// Map bare JID to whether we already tried to subscribe to the device list node.
final Map<String, bool> _subscriptionMap = {};
/// For preventing a race condition in encryption/decryption
final Map<String, Queue<Completer<void>>> _ratchetCriticalSectionQueue = {};
final Lock _ratchetCriticalSectionLock = Lock();
/// The OmemoManager's trust management
final TrustManager _trustManager;
TrustManager get trustManager => _trustManager;
/// Our own keys...
final Lock _deviceLock = Lock();
// ignore: prefer_final_fields
OmemoDevice _device;
/// The event bus of the session manager
final StreamController<OmemoEvent> _eventStreamController = StreamController<OmemoEvent>.broadcast();
Stream<OmemoEvent> get eventStream => _eventStreamController.stream;
/// Enter the critical section for performing cryptographic operations on the ratchets
Future<void> _enterRatchetCriticalSection(String jid) async {
final completer = await _ratchetCriticalSectionLock.synchronized(() {
if (_ratchetCriticalSectionQueue.containsKey(jid)) {
final c = Completer<void>();
_ratchetCriticalSectionQueue[jid]!.addLast(c);
return c;
}
_ratchetCriticalSectionQueue[jid] = Queue();
return null;
});
if (completer != null) {
await completer.future;
}
}
/// Leave the critical section for the ratchets.
Future<void> _leaveRatchetCriticalSection(String jid) async {
await _ratchetCriticalSectionLock.synchronized(() {
if (_ratchetCriticalSectionQueue.containsKey(jid)) {
if (_ratchetCriticalSectionQueue[jid]!.isEmpty) {
_ratchetCriticalSectionQueue.remove(jid);
} else {
_ratchetCriticalSectionQueue[jid]!.removeFirst().complete();
}
}
});
}
Future<String?> _decryptAndVerifyHmac(List<int>? ciphertext, List<int> keyAndHmac) async {
// Empty OMEMO messages should just have the key decrypted and/or session set up.
if (ciphertext == null) {
return null;
}
final key = keyAndHmac.sublist(0, 32);
final hmac = keyAndHmac.sublist(32, 48);
final derivedKeys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
final computedHmac = await truncatedHmac(ciphertext, derivedKeys.authenticationKey);
if (!listsEqual(hmac, computedHmac)) {
throw InvalidMessageHMACException();
}
return utf8.decode(
await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv),
);
}
/// Add a session [ratchet] with the [deviceId] to the internal tracking state.
/// NOTE: Must be called from within the ratchet critical section.
void _addSession(String jid, int deviceId, OmemoDoubleRatchet ratchet) {
// Add the bundle Id
if (!_deviceList.containsKey(jid)) {
_deviceList[jid] = [deviceId];
// Commit the device map
_eventStreamController.add(DeviceListModifiedEvent(_deviceList));
} else {
// Prevent having the same device multiple times in the list
if (!_deviceList[jid]!.contains(deviceId)) {
_deviceList[jid]!.add(deviceId);
// Commit the device map
_eventStreamController.add(DeviceListModifiedEvent(_deviceList));
}
}
// Add the ratchet session
final key = RatchetMapKey(jid, deviceId);
_ratchetMap[key] = ratchet;
// Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, true));
}
/// Build a new session with the user at [jid] with the device [deviceId] using data
/// from the key exchange [kex]. In case [kex] contains an unknown Signed Prekey
/// identifier an UnknownSignedPrekeyException will be thrown.
Future<OmemoDoubleRatchet> _addSessionFromKeyExchange(String jid, int deviceId, OmemoKeyExchange kex) async {
// Pick the correct SPK
final device = await getDevice();
OmemoKeyPair spk;
if (kex.spkId == _device.spkId) {
spk = _device.spk;
} else if (kex.spkId == _device.oldSpkId) {
spk = _device.oldSpk!;
} else {
throw UnknownSignedPrekeyException();
}
final kexResult = await x3dhFromInitialMessage(
X3DHMessage(
OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519),
OmemoPublicKey.fromBytes(kex.ek!, KeyPairType.x25519),
kex.pkId!,
),
spk,
device.opks.values.elementAt(kex.pkId!),
device.ik,
);
final ratchet = await OmemoDoubleRatchet.acceptNewSession(
spk,
OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519),
kexResult.sk,
kexResult.ad,
getTimestamp(),
);
return ratchet;
}
/// Create a ratchet session initiated by Alice to the user with Jid [jid] and the device
/// [deviceId] from the bundle [bundle].
@visibleForTesting
Future<OmemoKeyExchange> addSessionFromBundle(String jid, int deviceId, OmemoBundle bundle) async {
final device = await getDevice();
final kexResult = await x3dhFromBundle(
bundle,
device.ik,
);
final ratchet = await OmemoDoubleRatchet.initiateNewSession(
bundle.spk,
bundle.ik,
kexResult.sk,
kexResult.ad,
getTimestamp(),
);
await _trustManager.onNewSession(jid, deviceId);
_addSession(jid, deviceId, ratchet);
return OmemoKeyExchange()
..pkId = kexResult.opkId
..spkId = bundle.spkId
..ik = await device.ik.pk.getBytes()
..ek = await kexResult.ek.pk.getBytes();
}
/// In case a decryption error occurs, the Double Ratchet spec says to just restore
/// the ratchet to its old state. As such, this function restores the ratchet at
/// [mapKey] with [oldRatchet].
/// NOTE: Must be called from within the ratchet critical section
void _restoreRatchet(RatchetMapKey mapKey, OmemoDoubleRatchet oldRatchet) {
_log.finest('Restoring ratchet ${mapKey.jid}:${mapKey.deviceId} to ${oldRatchet.nr}');
_ratchetMap[mapKey] = oldRatchet;
// Commit the ratchet
_eventStreamController.add(
RatchetModifiedEvent(
mapKey.jid,
mapKey.deviceId,
oldRatchet,
false,
),
);
}
/// Attempt to decrypt [ciphertext]. [keys] refers to the <key /> elements inside the
/// <keys /> element with a "jid" attribute matching our own. [senderJid] refers to the
/// bare Jid of the sender. [senderDeviceId] refers to the "sid" attribute of the
/// <encrypted /> element.
/// [timestamp] refers to the time the message was sent. This might be either what the
/// server tells you via "XEP-0203: Delayed Delivery" or the point in time at which
/// you received the stanza, if no Delayed Delivery element was found.
///
/// If the received message is an empty OMEMO message, i.e. there is no <payload />
/// element, then [ciphertext] must be set to null. In this case, this function
/// will return null as there is no message to be decrypted. This, however, is used
/// to set up sessions or advance the ratchets.
Future<_InternalDecryptionResult> _decryptMessage(List<int>? ciphertext, String senderJid, int senderDeviceId, List<EncryptedKey> keys, int timestamp) async {
// Try to find a session we can decrypt with.
var device = await getDevice();
final rawKey = keys.firstWhereOrNull((key) => key.rid == device.id);
if (rawKey == null) {
throw NotEncryptedForDeviceException();
}
final ratchetKey = RatchetMapKey(senderJid, senderDeviceId);
final decodedRawKey = base64.decode(rawKey.value);
List<int>? keyAndHmac;
OmemoAuthenticatedMessage authMessage;
OmemoDoubleRatchet? oldRatchet;
OmemoMessage? message;
var ratchetCreated = false;
if (rawKey.kex) {
// If the ratchet already existed, we store it. If it didn't, oldRatchet will stay
// null.
final oldRatchet = getRatchet(ratchetKey)?.clone();
final kex = OmemoKeyExchange.fromBuffer(decodedRawKey);
authMessage = kex.message!;
message = OmemoMessage.fromBuffer(authMessage.message!);
// Guard against old key exchanges
if (oldRatchet != null) {
_log.finest('KEX for existent ratchet ${ratchetKey.toJsonKey()}. ${oldRatchet.kexTimestamp} > $timestamp: ${oldRatchet.kexTimestamp > timestamp}');
if (oldRatchet.kexTimestamp > timestamp) {
throw InvalidKeyExchangeException();
}
// Try to decrypt it
try {
final decrypted = await oldRatchet.ratchetDecrypt(message, authMessage.writeToBuffer());
// Commit the ratchet
_eventStreamController.add(
RatchetModifiedEvent(
senderJid,
senderDeviceId,
oldRatchet,
false,
),
);
final plaintext = await _decryptAndVerifyHmac(
ciphertext,
decrypted,
);
_addSession(senderJid, senderDeviceId, oldRatchet);
return _InternalDecryptionResult(
true,
plaintext,
);
} catch (_) {
_log.finest('Failed to use old ratchet with KEX for existing ratchet');
}
}
final r = await _addSessionFromKeyExchange(senderJid, senderDeviceId, kex);
await _trustManager.onNewSession(senderJid, senderDeviceId);
_addSession(senderJid, senderDeviceId, r);
ratchetCreated = true;
// Replace the OPK
// TODO(PapaTutuWawa): Replace the OPK when we know that the KEX worked
await _deviceLock.synchronized(() async {
device = await device.replaceOnetimePrekey(kex.pkId!);
// Commit the device
_eventStreamController.add(DeviceModifiedEvent(device));
});
} else {
authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey);
message = OmemoMessage.fromBuffer(authMessage.message!);
}
final devices = _deviceList[senderJid];
if (devices?.contains(senderDeviceId) != true) {
throw NoDecryptionKeyException();
}
// We can guarantee that the ratchet exists at this point in time
final ratchet = getRatchet(ratchetKey)!;
oldRatchet ??= ratchet.clone();
try {
if (rawKey.kex) {
keyAndHmac = await ratchet.ratchetDecrypt(message, authMessage.writeToBuffer());
} else {
keyAndHmac = await ratchet.ratchetDecrypt(message, decodedRawKey);
}
} catch (_) {
_restoreRatchet(ratchetKey, oldRatchet);
rethrow;
}
// Commit the ratchet
_eventStreamController.add(
RatchetModifiedEvent(
senderJid,
senderDeviceId,
ratchet,
false,
),
);
try {
return _InternalDecryptionResult(
ratchetCreated,
await _decryptAndVerifyHmac(ciphertext, keyAndHmac),
);
} catch (_) {
_restoreRatchet(ratchetKey, oldRatchet);
rethrow;
}
}
/// Returns, if it exists, the ratchet associated with [key].
/// NOTE: Must be called from within the ratchet critical section.
@visibleForTesting
OmemoDoubleRatchet? getRatchet(RatchetMapKey key) => _ratchetMap[key];
/// Figure out what bundles we have to still build a session with.
Future<List<OmemoBundle>> _fetchNewBundles(String jid) async {
// Check if we already requested the device list for [jid]
List<int> bundlesToFetch;
if (!_deviceListRequested.containsKey(jid) || !_deviceList.containsKey(jid)) {
// We don't have an up-to-date version of the device list
final newDeviceList = await fetchDeviceListImpl(jid);
if (newDeviceList == null) return [];
_deviceList[jid] = newDeviceList;
bundlesToFetch = newDeviceList
.where((id) {
return !_ratchetMap.containsKey(RatchetMapKey(jid, id)) ||
_deviceList[jid]?.contains(id) == false;
}).toList();
// Trigger an event with the new device list
_eventStreamController.add(DeviceListModifiedEvent(_deviceList));
} else {
// We already have an up-to-date version of the device list
bundlesToFetch = _deviceList[jid]!
.where((id) => !_ratchetMap.containsKey(RatchetMapKey(jid, id)))
.toList();
}
if (bundlesToFetch.isNotEmpty) {
_log.finest('Fetching bundles $bundlesToFetch for $jid');
}
final device = await getDevice();
final newBundles = List<OmemoBundle>.empty(growable: true);
for (final id in bundlesToFetch) {
if (jid == device.jid && id == device.id) continue;
final bundle = await fetchDeviceBundleImpl(jid, id);
if (bundle != null) newBundles.add(bundle);
}
return newBundles;
}
/// Encrypt the key [plaintext] for all known bundles of the Jids in [jids]. Returns a
/// map that maps the device Id to the ciphertext of [plaintext].
///
/// If [plaintext] is null, then the result will be an empty OMEMO message, i.e. one that
/// does not contain a <payload /> element. This means that the ciphertext attribute of
/// the result will be null as well.
/// NOTE: Must be called within the ratchet critical section
Future<EncryptionResult> _encryptToJids(List<String> jids, String? plaintext) async {
final encryptedKeys = List<EncryptedKey>.empty(growable: true);
var ciphertext = const <int>[];
var keyPayload = const <int>[];
if (plaintext != null) {
// Generate the key and encrypt the plaintext
final key = generateRandomBytes(32);
final keys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
ciphertext = await aes256CbcEncrypt(
utf8.encode(plaintext),
keys.encryptionKey,
keys.iv,
);
final hmac = await truncatedHmac(ciphertext, keys.authenticationKey);
keyPayload = concat([key, hmac]);
} else {
keyPayload = List<int>.filled(32, 0x0);
}
final kex = <RatchetMapKey, OmemoKeyExchange>{};
for (final jid in jids) {
for (final newSession in await _fetchNewBundles(jid)) {
kex[RatchetMapKey(jid, newSession.id)] = await addSessionFromBundle(
newSession.jid,
newSession.id,
newSession,
);
}
}
// We assume that the user already checked if the session exists
final deviceEncryptionErrors = <RatchetMapKey, OmemoException>{};
final jidEncryptionErrors = <String, OmemoException>{};
for (final jid in jids) {
final devices = _deviceList[jid];
if (devices == null) {
_log.severe('Device list does not exist for $jid.');
jidEncryptionErrors[jid] = NoKeyMaterialAvailableException();
continue;
}
if (!_subscriptionMap.containsKey(jid)) {
unawaited(subscribeToDeviceListNodeImpl(jid));
_subscriptionMap[jid] = true;
}
for (final deviceId in devices) {
// Empty OMEMO messages are allowed to bypass trust
if (plaintext != null) {
// Only encrypt to devices that are trusted
if (!(await _trustManager.isTrusted(jid, deviceId))) continue;
// Only encrypt to devices that are enabled
if (!(await _trustManager.isEnabled(jid, deviceId))) continue;
}
final ratchetKey = RatchetMapKey(jid, deviceId);
var ratchet = _ratchetMap[ratchetKey];
if (ratchet == null) {
_log.severe('Ratchet ${ratchetKey.toJsonKey()} does not exist.');
deviceEncryptionErrors[ratchetKey] = NoKeyMaterialAvailableException();
continue;
}
final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext;
if (kex.containsKey(ratchetKey)) {
// The ratchet did not exist
final k = kex[ratchetKey]!
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
final buffer = base64.encode(k.writeToBuffer());
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
buffer,
true,
),
);
ratchet = ratchet.cloneWithKex(buffer);
_ratchetMap[ratchetKey] = ratchet;
} else if (!ratchet.acknowledged) {
// The ratchet exists but is not acked
if (ratchet.kex != null) {
final oldKex = OmemoKeyExchange.fromBuffer(base64.decode(ratchet.kex!))
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(oldKex.writeToBuffer()),
true,
),
);
} else {
// The ratchet is not acked but we don't have the old key exchange
_log.warning('Ratchet for $jid:$deviceId is not acked but the kex attribute is null');
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(ciphertext),
false,
),
);
}
} else {
// The ratchet exists and is acked
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(ciphertext),
false,
),
);
}
// Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, false));
}
}
return EncryptionResult(
plaintext != null ?
ciphertext : null,
encryptedKeys,
deviceEncryptionErrors,
jidEncryptionErrors,
);
}
/// Call when receiving an OMEMO:2 encrypted stanza. Will handle everything and
/// decrypt it.
Future<DecryptionResult> onIncomingStanza(OmemoIncomingStanza stanza) async {
await _enterRatchetCriticalSection(stanza.bareSenderJid);
if (!_subscriptionMap.containsKey(stanza.bareSenderJid)) {
unawaited(subscribeToDeviceListNodeImpl(stanza.bareSenderJid));
_subscriptionMap[stanza.bareSenderJid] = true;
}
final ratchetKey = RatchetMapKey(stanza.bareSenderJid, stanza.senderDeviceId);
final _InternalDecryptionResult result;
try {
result = await _decryptMessage(
stanza.payload != null ?
base64.decode(stanza.payload!) :
null,
stanza.bareSenderJid,
stanza.senderDeviceId,
stanza.keys,
stanza.timestamp,
);
} on OmemoException catch (ex) {
await _leaveRatchetCriticalSection(stanza.bareSenderJid);
return DecryptionResult(
null,
ex,
);
}
// Check if the ratchet is acked
final ratchet = getRatchet(ratchetKey);
assert(ratchet != null, 'We decrypted the message, so the ratchet must exist');
if (ratchet!.acknowledged) {
// Ratchet is acknowledged
if (ratchet.nr > 53 || result.ratchetCreated) {
await sendEmptyOmemoMessageImpl(
await _encryptToJids(
[stanza.bareSenderJid],
null,
),
stanza.bareSenderJid,
);
}
// Ratchet is acked
await _leaveRatchetCriticalSection(stanza.bareSenderJid);
return DecryptionResult(
result.payload,
null,
);
} else {
// Ratchet is not acked.
// Mark as acked and send an empty OMEMO message.
await ratchetAcknowledged(
stanza.bareSenderJid,
stanza.senderDeviceId,
enterCriticalSection: false,
);
await sendEmptyOmemoMessageImpl(
await _encryptToJids(
[stanza.bareSenderJid],
null,
),
stanza.bareSenderJid,
);
await _leaveRatchetCriticalSection(stanza.bareSenderJid);
return DecryptionResult(
result.payload,
null,
);
}
}
/// Call when sending out an encrypted stanza. Will handle everything and
/// encrypt it.
Future<EncryptionResult> onOutgoingStanza(OmemoOutgoingStanza stanza) async {
_log.finest('Waiting to enter critical section');
await _enterRatchetCriticalSection(stanza.recipientJids.first);
_log.finest('Entered critical section');
final result = _encryptToJids(
stanza.recipientJids,
stanza.payload,
);
await _leaveRatchetCriticalSection(stanza.recipientJids.first);
return result;
}
// Sends a hearbeat message as specified by XEP-0384 to [jid].
Future<void> sendOmemoHeartbeat(String jid) async {
// TODO(Unknown): Include some error handling
final result = await _encryptToJids(
[jid],
null,
);
await sendEmptyOmemoMessageImpl(result, jid);
}
/// Mark the ratchet for device [deviceId] from [jid] as acked.
Future<void> ratchetAcknowledged(String jid, int deviceId, { bool enterCriticalSection = true }) async {
if (enterCriticalSection) await _enterRatchetCriticalSection(jid);
final key = RatchetMapKey(jid, deviceId);
if (_ratchetMap.containsKey(key)) {
final ratchet = _ratchetMap[key]!
..acknowledged = true;
// Commit it
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, false));
} else {
_log.severe('Attempted to acknowledge ratchet ${key.toJsonKey()}, even though it does not exist');
}
if (enterCriticalSection) await _leaveRatchetCriticalSection(jid);
}
/// Generates an entirely new device. May be useful when the user wants to reset their cryptographic
/// identity. Triggers an event to commit it to storage.
Future<void> regenerateDevice() async {
await _deviceLock.synchronized(() async {
_device = await OmemoDevice.generateNewDevice(_device.jid);
// Commit it
_eventStreamController.add(DeviceModifiedEvent(_device));
});
}
/// Returns the device used for encryption and decryption.
Future<OmemoDevice> getDevice() => _deviceLock.synchronized(() => _device);
/// Returns the id of the device used for encryption and decryption.
Future<int> getDeviceId() async => (await getDevice()).id;
/// Directly aquire the current device as a OMEMO device bundle.
Future<OmemoBundle> getDeviceBundle() async => (await getDevice()).toBundle();
/// Directly aquire the current device's fingerprint.
Future<String> getDeviceFingerprint() async => (await getDevice()).getFingerprint();
/// Returns the fingerprints for all devices of [jid] that we have a session with.
/// If there are not sessions with [jid], then returns null.
Future<List<DeviceFingerprint>?> getFingerprintsForJid(String jid) async {
if (!_deviceList.containsKey(jid)) return null;
await _enterRatchetCriticalSection(jid);
final fingerprintKeys = _deviceList[jid]!
.map((id) => RatchetMapKey(jid, id))
.where((key) => _ratchetMap.containsKey(key));
final fingerprints = List<DeviceFingerprint>.empty(growable: true);
for (final key in fingerprintKeys) {
final curveKey = await _ratchetMap[key]!.ik.toCurve25519();
fingerprints.add(
DeviceFingerprint(
key.deviceId,
HEX.encode(await curveKey.getBytes()),
),
);
}
await _leaveRatchetCriticalSection(jid);
return fingerprints;
}
/// Ensures that the device list is fetched again on the next message sending.
void onNewConnection() {
_deviceListRequested.clear();
_subscriptionMap.clear();
}
/// Sets the device list for [jid] to [devices]. Triggers a DeviceListModifiedEvent.
void onDeviceListUpdate(String jid, List<int> devices) {
_deviceList[jid] = devices;
_deviceListRequested[jid] = true;
// Trigger an event
_eventStreamController.add(DeviceListModifiedEvent(_deviceList));
}
void initialize(Map<RatchetMapKey, OmemoDoubleRatchet> ratchetMap, Map<String, List<int>> deviceList) {
_deviceList = deviceList;
_ratchetMap = ratchetMap;
}
/// Removes all ratchets for JID [jid]. This also removes all trust decisions for
/// [jid] from the trust manager. This function triggers a RatchetRemovedEvent for
/// every removed ratchet and a DeviceListModifiedEvent afterwards. Behaviour for
/// the trust manager is dependent on its implementation.
Future<void> removeAllRatchets(String jid) async {
await _enterRatchetCriticalSection(jid);
for (final deviceId in _deviceList[jid]!) {
// Remove the ratchet and commit it
_ratchetMap.remove(RatchetMapKey(jid, deviceId));
_eventStreamController.add(RatchetRemovedEvent(jid, deviceId));
}
// Remove the devices from the device list cache and commit it
_deviceList.remove(jid);
_deviceListRequested.remove(jid);
_eventStreamController.add(DeviceListModifiedEvent(_deviceList));
// Remove trust decisions
await _trustManager.removeTrustDecisionsForJid(jid);
await _leaveRatchetCriticalSection(jid);
}
/// Replaces the internal device with [newDevice]. Does not trigger an event.
Future<void> replaceDevice(OmemoDevice newDevice) async {
await _deviceLock.synchronized(() {
_device = newDevice;
});
}
}

View File

@@ -2,11 +2,25 @@ import 'package:meta/meta.dart';
@immutable @immutable
class RatchetMapKey { class RatchetMapKey {
const RatchetMapKey(this.jid, this.deviceId); const RatchetMapKey(this.jid, this.deviceId);
factory RatchetMapKey.fromJsonKey(String key) {
final parts = key.split(':');
final deviceId = int.parse(parts.first);
return RatchetMapKey(
parts.sublist(1).join(':'),
deviceId,
);
}
final String jid; final String jid;
final int deviceId; final int deviceId;
String toJsonKey() {
return '$deviceId:$jid';
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is RatchetMapKey && jid == other.jid && deviceId == other.deviceId; return other is RatchetMapKey && jid == other.jid && deviceId == other.deviceId;

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:hex/hex.dart'; import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:omemo_dart/src/crypto.dart'; import 'package:omemo_dart/src/crypto.dart';
import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart'; import 'package:omemo_dart/src/double_ratchet/double_ratchet.dart';
@@ -10,6 +11,7 @@ import 'package:omemo_dart/src/errors.dart';
import 'package:omemo_dart/src/helpers.dart'; import 'package:omemo_dart/src/helpers.dart';
import 'package:omemo_dart/src/keys.dart'; import 'package:omemo_dart/src/keys.dart';
import 'package:omemo_dart/src/omemo/bundle.dart'; import 'package:omemo_dart/src/omemo/bundle.dart';
import 'package:omemo_dart/src/omemo/constants.dart';
import 'package:omemo_dart/src/omemo/device.dart'; import 'package:omemo_dart/src/omemo/device.dart';
import 'package:omemo_dart/src/omemo/encrypted_key.dart'; import 'package:omemo_dart/src/omemo/encrypted_key.dart';
import 'package:omemo_dart/src/omemo/encryption_result.dart'; import 'package:omemo_dart/src/omemo/encryption_result.dart';
@@ -23,28 +25,35 @@ import 'package:omemo_dart/src/trust/base.dart';
import 'package:omemo_dart/src/x3dh/x3dh.dart'; import 'package:omemo_dart/src/x3dh/x3dh.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
/// The info used for when encrypting the AES key for the actual payload. @Deprecated('Use OmemoManager instead')
const omemoPayloadInfoString = 'OMEMO Payload';
class OmemoSessionManager { class OmemoSessionManager {
@Deprecated('Use OmemoManager instead')
OmemoSessionManager(this._device, this._deviceMap, this._ratchetMap, this._trustManager) OmemoSessionManager(this._device, this._deviceMap, this._ratchetMap, this._trustManager)
: _lock = Lock(), : _lock = Lock(),
_deviceLock = Lock(), _deviceLock = Lock(),
_eventStreamController = StreamController<OmemoEvent>.broadcast(); _eventStreamController = StreamController<OmemoEvent>.broadcast(),
_log = Logger('OmemoSessionManager');
/// Deserialise the OmemoSessionManager from JSON data [data].
factory OmemoSessionManager.fromJson(Map<String, dynamic> data, TrustManager trustManager) {
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
for (final rawRatchet in data['sessions']! as List<Map<String, dynamic>>) {
final key = RatchetMapKey(rawRatchet['jid']! as String, rawRatchet['deviceId']! as int);
final ratchet = OmemoDoubleRatchet.fromJson(rawRatchet['ratchet']! as Map<String, dynamic>);
ratchetMap[key] = ratchet;
}
/// Deserialise the OmemoSessionManager from JSON data [data] that does not contain
/// the ratchet sessions.
@Deprecated('Use OmemoManager instead')
factory OmemoSessionManager.fromJsonWithoutSessions(
Map<String, dynamic> data,
Map<RatchetMapKey, OmemoDoubleRatchet> ratchetMap,
TrustManager trustManager,
) {
// NOTE: Dart has some issues with just casting a List<dynamic> to List<Map<...>>, as
// such we need to convert the items by hand.
return OmemoSessionManager( return OmemoSessionManager(
Device.fromJson(data['device']! as Map<String, dynamic>), OmemoDevice.fromJson(data['device']! as Map<String, dynamic>),
data['devices']! as Map<String, List<int>>, (data['devices']! as Map<String, dynamic>).map<String, List<int>>(
(key, value) {
return MapEntry(
key,
(value as List<dynamic>).map<int>((i) => i as int).toList(),
);
}
),
ratchetMap, ratchetMap,
trustManager, trustManager,
); );
@@ -53,11 +62,14 @@ class OmemoSessionManager {
/// Generate a new cryptographic identity. /// Generate a new cryptographic identity.
static Future<OmemoSessionManager> generateNewIdentity(String jid, TrustManager trustManager, { int opkAmount = 100 }) async { static Future<OmemoSessionManager> generateNewIdentity(String jid, TrustManager trustManager, { int opkAmount = 100 }) async {
assert(opkAmount > 0, 'opkAmount must be bigger than 0.'); assert(opkAmount > 0, 'opkAmount must be bigger than 0.');
final device = await Device.generateNewDevice(jid, opkAmount: opkAmount); final device = await OmemoDevice.generateNewDevice(jid, opkAmount: opkAmount);
return OmemoSessionManager(device, {}, {}, trustManager); return OmemoSessionManager(device, {}, {}, trustManager);
} }
/// Logging
Logger _log;
/// Lock for _ratchetMap and _bundleMap /// Lock for _ratchetMap and _bundleMap
final Lock _lock; final Lock _lock;
@@ -72,7 +84,7 @@ class OmemoSessionManager {
/// Our own keys... /// Our own keys...
// ignore: prefer_final_fields // ignore: prefer_final_fields
Device _device; OmemoDevice _device;
/// and its lock /// and its lock
final Lock _deviceLock; final Lock _deviceLock;
@@ -83,13 +95,21 @@ class OmemoSessionManager {
/// A stream that receives events regarding the session /// A stream that receives events regarding the session
Stream<OmemoEvent> get eventStream => _eventStreamController.stream; Stream<OmemoEvent> get eventStream => _eventStreamController.stream;
Future<Device> getDevice() async { /// Returns our own device.
Device? dev; Future<OmemoDevice> getDevice() async {
await _deviceLock.synchronized(() async { return _deviceLock.synchronized(() => _device);
dev = _device; }
});
return dev!; /// Returns the id attribute of our own device. This is just a short-hand for
/// ```await (session.getDevice()).id```.
Future<int> getDeviceId() async {
return _deviceLock.synchronized(() => _device.id);
}
/// Returns the device as an OmemoBundle. This is just a short-hand for
/// ```await (await session.getDevice()).toBundle()```.
Future<OmemoBundle> getDeviceBundle() async {
return _deviceLock.synchronized(() async => _device.toBundle());
} }
/// Add a session [ratchet] with the [deviceId] to the internal tracking state. /// Add a session [ratchet] with the [deviceId] to the internal tracking state.
@@ -98,24 +118,25 @@ class OmemoSessionManager {
// Add the bundle Id // Add the bundle Id
if (!_deviceMap.containsKey(jid)) { if (!_deviceMap.containsKey(jid)) {
_deviceMap[jid] = [deviceId]; _deviceMap[jid] = [deviceId];
} else {
_deviceMap[jid]!.add(deviceId);
}
// Commit the device map // Commit the device map
_eventStreamController.add(DeviceMapModifiedEvent(_deviceMap)); _eventStreamController.add(DeviceListModifiedEvent(_deviceMap));
} else {
// Prevent having the same device multiple times in the list
if (!_deviceMap[jid]!.contains(deviceId)) {
_deviceMap[jid]!.add(deviceId);
// Commit the device map
_eventStreamController.add(DeviceListModifiedEvent(_deviceMap));
}
}
// Add the ratchet session // Add the ratchet session
final key = RatchetMapKey(jid, deviceId); final key = RatchetMapKey(jid, deviceId);
if (!_ratchetMap.containsKey(key)) {
_ratchetMap[key] = ratchet; _ratchetMap[key] = ratchet;
} else {
// TODO(PapaTutuWawa): What do we do now?
throw Exception();
}
// Commit the ratchet // Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet)); _eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, true));
}); });
} }
@@ -133,6 +154,7 @@ class OmemoSessionManager {
bundle.ik, bundle.ik,
kexResult.sk, kexResult.sk,
kexResult.ad, kexResult.ad,
getTimestamp(),
); );
await _trustManager.onNewSession(jid, deviceId); await _trustManager.onNewSession(jid, deviceId);
@@ -148,19 +170,21 @@ class OmemoSessionManager {
/// Build a new session with the user at [jid] with the device [deviceId] using data /// Build a new session with the user at [jid] with the device [deviceId] using data
/// from the key exchange [kex]. In case [kex] contains an unknown Signed Prekey /// from the key exchange [kex]. In case [kex] contains an unknown Signed Prekey
/// identifier an UnknownSignedPrekeyException will be thrown. /// identifier an UnknownSignedPrekeyException will be thrown.
Future<void> _addSessionFromKeyExchange(String jid, int deviceId, OmemoKeyExchange kex) async { Future<OmemoDoubleRatchet> _addSessionFromKeyExchange(String jid, int deviceId, OmemoKeyExchange kex) async {
// Pick the correct SPK // Pick the correct SPK
final device = await getDevice(); final device = await getDevice();
OmemoKeyPair? spk; final spk = await _lock.synchronized(() async {
if (kex.spkId == device.spkId) { if (kex.spkId == _device.spkId) {
spk = device.spk; return _device.spk;
} else if (kex.spkId == device.oldSpkId) { } else if (kex.spkId == _device.oldSpkId) {
spk = device.oldSpk; return _device.oldSpk;
} else {
throw UnknownSignedPrekeyException();
} }
assert(spk != null, 'The used SPK must be found'); return null;
});
if (spk == null) {
throw UnknownSignedPrekeyException();
}
final kexResult = await x3dhFromInitialMessage( final kexResult = await x3dhFromInitialMessage(
X3DHMessage( X3DHMessage(
@@ -168,7 +192,7 @@ class OmemoSessionManager {
OmemoPublicKey.fromBytes(kex.ek!, KeyPairType.x25519), OmemoPublicKey.fromBytes(kex.ek!, KeyPairType.x25519),
kex.pkId!, kex.pkId!,
), ),
spk!, spk,
device.opks.values.elementAt(kex.pkId!), device.opks.values.elementAt(kex.pkId!),
device.ik, device.ik,
); );
@@ -177,10 +201,10 @@ class OmemoSessionManager {
OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519), OmemoPublicKey.fromBytes(kex.ik!, KeyPairType.ed25519),
kexResult.sk, kexResult.sk,
kexResult.ad, kexResult.ad,
getTimestamp(),
); );
await _trustManager.onNewSession(jid, deviceId); return ratchet;
await _addSession(jid, deviceId, ratchet);
} }
/// Like [encryptToJids] but only for one Jid [jid]. /// Like [encryptToJids] but only for one Jid [jid].
@@ -217,7 +241,11 @@ class OmemoSessionManager {
final kex = <int, OmemoKeyExchange>{}; final kex = <int, OmemoKeyExchange>{};
if (newSessions != null) { if (newSessions != null) {
for (final newSession in newSessions) { for (final newSession in newSessions) {
kex[newSession.id] = await addSessionFromBundle(newSession.jid, newSession.id, newSession); kex[newSession.id] = await addSessionFromBundle(
newSession.jid,
newSession.id,
newSession,
);
} }
} }
@@ -229,27 +257,48 @@ class OmemoSessionManager {
if (plaintext != null) { if (plaintext != null) {
// Only encrypt to devices that are trusted // Only encrypt to devices that are trusted
if (!(await _trustManager.isTrusted(jid, deviceId))) continue; if (!(await _trustManager.isTrusted(jid, deviceId))) continue;
// Onyl encrypt to devices that are enabled
if (!(await _trustManager.isEnabled(jid, deviceId))) continue;
} }
final ratchetKey = RatchetMapKey(jid, deviceId); final ratchetKey = RatchetMapKey(jid, deviceId);
final ratchet = _ratchetMap[ratchetKey]!; var ratchet = _ratchetMap[ratchetKey]!;
final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext; final ciphertext = (await ratchet.ratchetEncrypt(keyPayload)).ciphertext;
// Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet));
if (kex.isNotEmpty && kex.containsKey(deviceId)) { if (kex.isNotEmpty && kex.containsKey(deviceId)) {
// The ratchet did not exist
final k = kex[deviceId]! final k = kex[deviceId]!
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext); ..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
final buffer = base64.encode(k.writeToBuffer());
encryptedKeys.add( encryptedKeys.add(
EncryptedKey( EncryptedKey(
jid, jid,
deviceId, deviceId,
base64.encode(k.writeToBuffer()), buffer,
true,
),
);
ratchet = ratchet.cloneWithKex(buffer);
_ratchetMap[ratchetKey] = ratchet;
} else if (!ratchet.acknowledged) {
// The ratchet exists but is not acked
if (ratchet.kex != null) {
final oldKex = OmemoKeyExchange.fromBuffer(base64.decode(ratchet.kex!))
..message = OmemoAuthenticatedMessage.fromBuffer(ciphertext);
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(oldKex.writeToBuffer()),
true, true,
), ),
); );
} else { } else {
// The ratchet is not acked but we don't have the old key exchange
_log.warning('Ratchet for $jid:$deviceId is not acked but the kex attribute is null');
encryptedKeys.add( encryptedKeys.add(
EncryptedKey( EncryptedKey(
jid, jid,
@@ -259,6 +308,20 @@ class OmemoSessionManager {
), ),
); );
} }
} else {
// The ratchet exists and is acked
encryptedKeys.add(
EncryptedKey(
jid,
deviceId,
base64.encode(ciphertext),
false,
),
);
}
// Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, false));
} }
} }
}); });
@@ -266,6 +329,47 @@ class OmemoSessionManager {
return EncryptionResult( return EncryptionResult(
plaintext != null ? ciphertext : null, plaintext != null ? ciphertext : null,
encryptedKeys, encryptedKeys,
const <RatchetMapKey, OmemoException>{},
const <String, OmemoException>{},
);
}
/// In case a decryption error occurs, the Double Ratchet spec says to just restore
/// the ratchet to its old state. As such, this function restores the ratchet at
/// [mapKey] with [oldRatchet].
Future<void> _restoreRatchet(RatchetMapKey mapKey, OmemoDoubleRatchet oldRatchet) async {
await _lock.synchronized(() {
_log.finest('Restoring ratchet ${mapKey.jid}:${mapKey.deviceId} to ${oldRatchet.nr}');
_ratchetMap[mapKey] = oldRatchet;
// Commit the ratchet
_eventStreamController.add(
RatchetModifiedEvent(
mapKey.jid,
mapKey.deviceId,
oldRatchet,
false,
),
);
});
}
Future<String?> _decryptAndVerifyHmac(List<int>? ciphertext, List<int> keyAndHmac) async {
// Empty OMEMO messages should just have the key decrypted and/or session set up.
if (ciphertext == null) {
return null;
}
final key = keyAndHmac.sublist(0, 32);
final hmac = keyAndHmac.sublist(32, 48);
final derivedKeys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
final computedHmac = await truncatedHmac(ciphertext, derivedKeys.authenticationKey);
if (!listsEqual(hmac, computedHmac)) {
throw InvalidMessageHMACException();
}
return utf8.decode(
await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv),
); );
} }
@@ -273,12 +377,15 @@ class OmemoSessionManager {
/// <keys /> element with a "jid" attribute matching our own. [senderJid] refers to the /// <keys /> element with a "jid" attribute matching our own. [senderJid] refers to the
/// bare Jid of the sender. [senderDeviceId] refers to the "sid" attribute of the /// bare Jid of the sender. [senderDeviceId] refers to the "sid" attribute of the
/// <encrypted /> element. /// <encrypted /> element.
/// [timestamp] refers to the time the message was sent. This might be either what the
/// server tells you via "XEP-0203: Delayed Delivery" or the point in time at which
/// you received the stanza, if no Delayed Delivery element was found.
/// ///
/// If the received message is an empty OMEMO message, i.e. there is no <payload /> /// If the received message is an empty OMEMO message, i.e. there is no <payload />
/// element, then [ciphertext] must be set to null. In this case, this function /// element, then [ciphertext] must be set to null. In this case, this function
/// will return null as there is no message to be decrypted. This, however, is used /// will return null as there is no message to be decrypted. This, however, is used
/// to set up sessions or advance the ratchets. /// to set up sessions or advance the ratchets.
Future<String?> decryptMessage(List<int>? ciphertext, String senderJid, int senderDeviceId, List<EncryptedKey> keys) async { Future<String?> decryptMessage(List<int>? ciphertext, String senderJid, int senderDeviceId, List<EncryptedKey> keys, int timestamp) async {
// Try to find a session we can decrypt with. // Try to find a session we can decrypt with.
var device = await getDevice(); var device = await getDevice();
final rawKey = keys.firstWhereOrNull((key) => key.rid == device.id); final rawKey = keys.firstWhereOrNull((key) => key.rid == device.id);
@@ -286,20 +393,58 @@ class OmemoSessionManager {
throw NotEncryptedForDeviceException(); throw NotEncryptedForDeviceException();
} }
final ratchetKey = RatchetMapKey(senderJid, senderDeviceId);
final decodedRawKey = base64.decode(rawKey.value); final decodedRawKey = base64.decode(rawKey.value);
List<int>? keyAndHmac;
OmemoAuthenticatedMessage authMessage; OmemoAuthenticatedMessage authMessage;
OmemoDoubleRatchet? oldRatchet;
OmemoMessage? message;
if (rawKey.kex) { if (rawKey.kex) {
// TODO(PapaTutuWawa): Only do this when we should // If the ratchet already existed, we store it. If it didn't, oldRatchet will stay
// null.
final oldRatchet = (await _getRatchet(ratchetKey))?.clone();
final kex = OmemoKeyExchange.fromBuffer(decodedRawKey); final kex = OmemoKeyExchange.fromBuffer(decodedRawKey);
await _addSessionFromKeyExchange( authMessage = kex.message!;
message = OmemoMessage.fromBuffer(authMessage.message!);
// Guard against old key exchanges
if (oldRatchet != null) {
_log.finest('KEX for existent ratchet. ${oldRatchet.pn}');
if (oldRatchet.kexTimestamp > timestamp) {
throw InvalidKeyExchangeException();
}
// Try to decrypt it
try {
final decrypted = await oldRatchet.ratchetDecrypt(message, authMessage.writeToBuffer());
// Commit the ratchet
_eventStreamController.add(
RatchetModifiedEvent(
senderJid, senderJid,
senderDeviceId, senderDeviceId,
kex, oldRatchet,
false,
),
); );
authMessage = kex.message!; final plaintext = await _decryptAndVerifyHmac(
ciphertext,
decrypted,
);
await _addSession(senderJid, senderDeviceId, oldRatchet);
return plaintext;
} catch (_) {
_log.finest('Failed to use old ratchet with KEX for existing ratchet');
}
}
final r = await _addSessionFromKeyExchange(senderJid, senderDeviceId, kex);
await _trustManager.onNewSession(senderJid, senderDeviceId);
await _addSession(senderJid, senderDeviceId, r);
// Replace the OPK // Replace the OPK
// TODO(PapaTutuWawa): Replace the OPK when we know that the KEX worked
await _deviceLock.synchronized(() async { await _deviceLock.synchronized(() async {
device = await device.replaceOnetimePrekey(kex.pkId!); device = await device.replaceOnetimePrekey(kex.pkId!);
@@ -308,6 +453,7 @@ class OmemoSessionManager {
}); });
} else { } else {
authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey); authMessage = OmemoAuthenticatedMessage.fromBuffer(decodedRawKey);
message = OmemoMessage.fromBuffer(authMessage.message!);
} }
final devices = _deviceMap[senderJid]; final devices = _deviceMap[senderJid];
@@ -318,37 +464,37 @@ class OmemoSessionManager {
throw NoDecryptionKeyException(); throw NoDecryptionKeyException();
} }
final message = OmemoMessage.fromBuffer(authMessage.message!); // We can guarantee that the ratchet exists at this point in time
final ratchetKey = RatchetMapKey(senderJid, senderDeviceId); final ratchet = (await _getRatchet(ratchetKey))!;
List<int>? keyAndHmac; oldRatchet ??= ratchet.clone();
await _lock.synchronized(() async {
final ratchet = _ratchetMap[ratchetKey]!; try {
if (rawKey.kex) { if (rawKey.kex) {
keyAndHmac = await ratchet.ratchetDecrypt(message, authMessage.writeToBuffer()); keyAndHmac = await ratchet.ratchetDecrypt(message, authMessage.writeToBuffer());
} else { } else {
keyAndHmac = await ratchet.ratchetDecrypt(message, decodedRawKey); keyAndHmac = await ratchet.ratchetDecrypt(message, decodedRawKey);
} }
} catch (_) {
await _restoreRatchet(ratchetKey, oldRatchet);
rethrow;
}
// Commit the ratchet // Commit the ratchet
_eventStreamController.add(RatchetModifiedEvent(senderJid, senderDeviceId, ratchet)); _eventStreamController.add(
}); RatchetModifiedEvent(
senderJid,
senderDeviceId,
ratchet,
false,
),
);
// Empty OMEMO messages should just have the key decrypted and/or session set up. try {
if (ciphertext == null) { return _decryptAndVerifyHmac(ciphertext, keyAndHmac);
return null; } catch (_) {
await _restoreRatchet(ratchetKey, oldRatchet);
rethrow;
} }
final key = keyAndHmac!.sublist(0, 32);
final hmac = keyAndHmac!.sublist(32, 48);
final derivedKeys = await deriveEncryptionKeys(key, omemoPayloadInfoString);
final computedHmac = await truncatedHmac(ciphertext, derivedKeys.authenticationKey);
if (!listsEqual(hmac, computedHmac)) {
throw InvalidMessageHMACException();
}
final plaintext = await aes256CbcDecrypt(ciphertext, derivedKeys.encryptionKey, derivedKeys.iv);
return utf8.decode(plaintext);
} }
/// Returns the list of hex-encoded fingerprints we have for sessions with [jid]. /// Returns the list of hex-encoded fingerprints we have for sessions with [jid].
@@ -357,7 +503,7 @@ class OmemoSessionManager {
await _lock.synchronized(() async { await _lock.synchronized(() async {
// Get devices for jid // Get devices for jid
final devices = _deviceMap[jid]!; final devices = _deviceMap[jid] ?? [];
for (final deviceId in devices) { for (final deviceId in devices) {
final ratchet = _ratchetMap[RatchetMapKey(jid, deviceId)]!; final ratchet = _ratchetMap[RatchetMapKey(jid, deviceId)]!;
@@ -374,6 +520,16 @@ class OmemoSessionManager {
return fingerprints; return fingerprints;
} }
/// Returns the hex-encoded fingerprint of the current device.
Future<DeviceFingerprint> getHexFingerprintForDevice() async {
final device = await getDevice();
return DeviceFingerprint(
device.id,
HEX.encode(await device.ik.pk.getBytes()),
);
}
/// Replaces the Signed Prekey and its signature in our own device bundle. Triggers /// Replaces the Signed Prekey and its signature in our own device bundle. Triggers
/// a DeviceModifiedEvent when done. /// a DeviceModifiedEvent when done.
/// See https://xmpp.org/extensions/xep-0384.html#protocol-key_exchange under the point /// See https://xmpp.org/extensions/xep-0384.html#protocol-key_exchange under the point
@@ -387,17 +543,120 @@ class OmemoSessionManager {
}); });
} }
@visibleForTesting /// Returns the device map, i.e. the mapping of bare Jid to its device identifiers
OmemoDoubleRatchet getRatchet(String jid, int deviceId) => _ratchetMap[RatchetMapKey(jid, deviceId)]!; /// we have built sessions with.
Future<Map<String, List<int>>> getDeviceMap() async {
return _lock.synchronized(() => _deviceMap);
}
/// Removes the ratchet identified by [jid] and [deviceId] from the session manager.
/// Also triggers events for commiting the new device map to storage and removing
/// the old ratchet.
Future<void> removeRatchet(String jid, int deviceId) async {
await _lock.synchronized(() async {
// Remove the ratchet
_ratchetMap.remove(RatchetMapKey(jid, deviceId));
// Commit it
_eventStreamController.add(RatchetRemovedEvent(jid, deviceId));
// Remove the device from jid
_deviceMap[jid]!.remove(deviceId);
if (_deviceMap[jid]!.isEmpty) {
_deviceMap.remove(jid);
}
// Commit it
_eventStreamController.add(DeviceListModifiedEvent(_deviceMap));
});
}
/// Removes all ratchets for Jid [jid]. Triggers a DeviceMapModified event at the end and an
/// RatchetRemovedEvent for each ratchet.
Future<void> removeAllRatchets(String jid) async {
await _lock.synchronized(() async {
for (final deviceId in _deviceMap[jid]!) {
// Remove the ratchet
_ratchetMap.remove(RatchetMapKey(jid, deviceId));
// Commit it
_eventStreamController.add(RatchetRemovedEvent(jid, deviceId));
}
// Remove the device from jid
_deviceMap.remove(jid);
// Commit it
_eventStreamController.add(DeviceListModifiedEvent(_deviceMap));
});
}
/// Returns the list of device identifiers belonging to [jid] that are yet unacked, i.e.
/// we have not yet received an empty OMEMO message from.
Future<List<int>?> getUnacknowledgedRatchets(String jid) async {
return _lock.synchronized(() async {
final ret = List<int>.empty(growable: true);
final devices = _deviceMap[jid];
if (devices == null) return null;
for (final device in devices) {
final ratchet = _ratchetMap[RatchetMapKey(jid, device)]!;
if (!ratchet.acknowledged) ret.add(device);
}
return ret;
});
}
/// Returns true if the ratchet for [jid] with device identifier [deviceId] is
/// acknowledged. Returns false if not.
Future<bool> isRatchetAcknowledged(String jid, int deviceId) async {
return _lock.synchronized(() => _ratchetMap[RatchetMapKey(jid, deviceId)]!.acknowledged);
}
/// Mark the ratchet for device [deviceId] from [jid] as acked.
Future<void> ratchetAcknowledged(String jid, int deviceId) async {
await _lock.synchronized(() async {
final ratchet = _ratchetMap[RatchetMapKey(jid, deviceId)]!
..acknowledged = true;
// Commit it
_eventStreamController.add(RatchetModifiedEvent(jid, deviceId, ratchet, false));
});
}
/// Generates an entirely new device. May be useful when the user wants to reset their cryptographic
/// identity. Triggers an event to commit it to storage.
Future<void> regenerateDevice({ int opkAmount = 100 }) async {
await _deviceLock.synchronized(() async {
_device = await OmemoDevice.generateNewDevice(_device.jid, opkAmount: opkAmount);
// Commit it
_eventStreamController.add(DeviceModifiedEvent(_device));
});
}
/// Make our device have a new identifier. Only useful before publishing it as a bundle
/// to make sure that our device has a id that is account unique.
Future<void> regenerateDeviceId() async {
await _deviceLock.synchronized(() async {
_device = _device.withNewId();
// Commit it
_eventStreamController.add(DeviceModifiedEvent(_device));
});
}
Future<OmemoDoubleRatchet?> _getRatchet(RatchetMapKey key) async {
return _lock.synchronized(() async {
return _ratchetMap[key];
});
}
@visibleForTesting @visibleForTesting
Map<String, List<int>> getDeviceMap() => _deviceMap; OmemoDoubleRatchet getRatchet(String jid, int deviceId) => _ratchetMap[RatchetMapKey(jid, deviceId)]!;
@visibleForTesting @visibleForTesting
Map<RatchetMapKey, OmemoDoubleRatchet> getRatchetMap() => _ratchetMap; Map<RatchetMapKey, OmemoDoubleRatchet> getRatchetMap() => _ratchetMap;
/// Serialise the entire session manager into a JSON object. /// Serialise the entire session manager into a JSON object.
Future<Map<String, dynamic>> toJson() async { Future<Map<String, dynamic>> toJsonWithoutSessions() async {
/* /*
{ {
'devices': { 'devices': {
@@ -406,32 +665,12 @@ class OmemoSessionManager {
... ...
}, },
'device': { ... }, 'device': { ... },
'sessions': [
{
'jid': 'alice@...',
'deviceId': 1,
'ratchet': { ... },
},
...
],
'trust': { ... }
} }
*/ */
final sessions = List<Map<String, dynamic>>.empty(growable: true);
for (final entry in _ratchetMap.entries) {
sessions.add({
'jid': entry.key.jid,
'deviceId': entry.key.deviceId,
'ratchet': await entry.value.toJson(),
});
}
return { return {
'devices': _deviceMap, 'devices': _deviceMap,
'device': await (await getDevice()).toJson(), 'device': await (await getDevice()).toJson(),
'sessions': sessions,
// TODO(PapaTutuWawa): Implement
'trust': <String, dynamic>{},
}; };
} }
} }

41
lib/src/omemo/stanza.dart Normal file
View File

@@ -0,0 +1,41 @@
import 'package:omemo_dart/src/omemo/encrypted_key.dart';
/// Describes a stanza that was received by the underlying XMPP library.
class OmemoIncomingStanza {
const OmemoIncomingStanza(
this.bareSenderJid,
this.senderDeviceId,
this.timestamp,
this.keys,
this.payload,
);
/// The bare JID of the sender of the stanza.
final String bareSenderJid;
/// The device ID of the sender.
final int senderDeviceId;
/// The timestamp when the stanza was received.
final int timestamp;
/// The included encrypted keys
final List<EncryptedKey> keys;
/// The string payload included in the <encrypted /> element.
final String? payload;
}
/// Describes a stanza that is to be sent out
class OmemoOutgoingStanza {
const OmemoOutgoingStanza(
this.recipientJids,
this.payload,
);
/// The JIDs the stanza will be sent to.
final List<String> recipientJids;
/// The serialised XML data that should be encrypted.
final String payload;
}

View File

@@ -3,7 +3,6 @@ import 'package:omemo_dart/src/protobuf/omemo_authenticated_message.dart';
import 'package:omemo_dart/src/protobuf/protobuf.dart'; import 'package:omemo_dart/src/protobuf/protobuf.dart';
class OmemoKeyExchange { class OmemoKeyExchange {
OmemoKeyExchange(); OmemoKeyExchange();
factory OmemoKeyExchange.fromBuffer(List<int> data) { factory OmemoKeyExchange.fromBuffer(List<int> data) {

View File

@@ -46,21 +46,18 @@ List<int> encodeVarint(int i) {
assert(i >= 0, "Two's complement is not implemented"); assert(i >= 0, "Two's complement is not implemented");
final ret = List<int>.empty(growable: true); final ret = List<int>.empty(growable: true);
var j = 0; // Thanks to https://github.com/hathibelagal-dev/LEB128 for the trick with toRadixString!
while (true) { final numSevenBlocks = (i.toRadixString(2).length / 7).ceil();
for (var j = 0; j < numSevenBlocks; j++) {
// The 7 LSB of the byte we're creating // The 7 LSB of the byte we're creating
final x = (i & (lsb7Mask << j * 7)) >> j * 7; final x = (i & (lsb7Mask << j * 7)) >> j * 7;
// The next bits
final next = i & (lsb7Mask << (j + 1) * 7);
if (next == 0) { if (j == numSevenBlocks - 1) {
// If we were to shift further, we only get zero, so we're at the end // If we were to shift further, we only get zero, so we're at the end
ret.add(x); ret.add(x);
break;
} else { } else {
// We still have at least one bit more to go, so set the MSB to 1 // We still have at least one bit more to go, so set the MSB to 1
ret.add(x + msb); ret.add(x + msb);
j++;
} }
} }

View File

@@ -11,4 +11,16 @@ class AlwaysTrustingTrustManager extends TrustManager {
@override @override
Future<void> onNewSession(String jid, int deviceId) async {} Future<void> onNewSession(String jid, int deviceId) async {}
@override
Future<bool> isEnabled(String jid, int deviceId) async => true;
@override
Future<void> setEnabled(String jid, int deviceId, bool enabled) async {}
@override
Future<void> removeTrustDecisionsForJid(String jid) async {}
@override
Future<Map<String, dynamic>> toJson() async => <String, dynamic>{};
} }

View File

@@ -8,4 +8,18 @@ abstract class TrustManager {
/// Called by the OmemoSessionManager when a new session has been built. Should set /// Called by the OmemoSessionManager when a new session has been built. Should set
/// a default trust state to [jid]'s device with identifier [deviceId]. /// a default trust state to [jid]'s device with identifier [deviceId].
Future<void> onNewSession(String jid, int deviceId); Future<void> onNewSession(String jid, int deviceId);
/// Return true if the device with id [deviceId] of Jid [jid] should be used for encryption.
/// If not, return false.
Future<bool> isEnabled(String jid, int deviceId);
/// Mark the device with id [deviceId] of Jid [jid] as enabled if [enabled] is true or as disabled
/// if [enabled] is false.
Future<void> setEnabled(String jid, int deviceId, bool enabled);
/// Serialize the trust manager to JSON.
Future<Map<String, dynamic>> toJson();
/// Removes all trust decisions for [jid].
Future<void> removeTrustDecisionsForJid(String jid);
} }

View File

@@ -8,24 +8,52 @@ import 'package:synchronized/synchronized.dart';
/// - blindTrust: The fingerprint is not verified using OOB means /// - blindTrust: The fingerprint is not verified using OOB means
/// - verified: The fingerprint has been verified using OOB means /// - verified: The fingerprint has been verified using OOB means
enum BTBVTrustState { enum BTBVTrustState {
notTrusted, notTrusted, // = 1
blindTrust, blindTrust, // = 2
verified, verified, // = 3
}
int _trustToInt(BTBVTrustState state) {
switch (state) {
case BTBVTrustState.notTrusted: return 1;
case BTBVTrustState.blindTrust: return 2;
case BTBVTrustState.verified: return 3;
}
}
BTBVTrustState _trustFromInt(int i) {
switch (i) {
case 1: return BTBVTrustState.notTrusted;
case 2: return BTBVTrustState.blindTrust;
case 3: return BTBVTrustState.verified;
default: return BTBVTrustState.notTrusted;
}
} }
/// A TrustManager that implements the idea of Blind Trust Before Verification. /// A TrustManager that implements the idea of Blind Trust Before Verification.
/// See https://gultsch.de/trust.html for more details. /// See https://gultsch.de/trust.html for more details.
abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager { abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager {
BlindTrustBeforeVerificationTrustManager() BlindTrustBeforeVerificationTrustManager({
: trustCache = {}, Map<RatchetMapKey, BTBVTrustState>? trustCache,
devices = {}, Map<RatchetMapKey, bool>? enablementCache,
Map<String, List<int>>? devices,
}) : trustCache = trustCache ?? {},
enablementCache = enablementCache ?? {},
devices = devices ?? {},
_lock = Lock(); _lock = Lock();
/// The cache for Mapping a RatchetMapKey to its trust state /// The cache for mapping a RatchetMapKey to its trust state
@visibleForTesting
@protected @protected
final Map<RatchetMapKey, BTBVTrustState> trustCache; final Map<RatchetMapKey, BTBVTrustState> trustCache;
/// The cache for mapping a RatchetMapKey to whether it is enabled or not
@visibleForTesting
@protected
final Map<RatchetMapKey, bool> enablementCache;
/// Mapping of Jids to their device identifiers /// Mapping of Jids to their device identifiers
@visibleForTesting
@protected @protected
final Map<String, List<int>> devices; final Map<String, List<int>> devices;
@@ -74,10 +102,13 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager {
@override @override
Future<void> onNewSession(String jid, int deviceId) async { Future<void> onNewSession(String jid, int deviceId) async {
await _lock.synchronized(() async { await _lock.synchronized(() async {
final key = RatchetMapKey(jid, deviceId);
if (_hasAtLeastOneVerifiedDevice(jid)) { if (_hasAtLeastOneVerifiedDevice(jid)) {
trustCache[RatchetMapKey(jid, deviceId)] = BTBVTrustState.notTrusted; trustCache[key] = BTBVTrustState.notTrusted;
enablementCache[key] = false;
} else { } else {
trustCache[RatchetMapKey(jid, deviceId)] = BTBVTrustState.blindTrust; trustCache[key] = BTBVTrustState.blindTrust;
enablementCache[key] = true;
} }
if (devices.containsKey(jid)) { if (devices.containsKey(jid)) {
@@ -91,17 +122,20 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager {
}); });
} }
/// Returns a mapping from the device identifiers of [jid] to their trust state. /// Returns a mapping from the device identifiers of [jid] to their trust state. If
/// there are no devices known for [jid], then an empty map is returned.
Future<Map<int, BTBVTrustState>> getDevicesTrust(String jid) async { Future<Map<int, BTBVTrustState>> getDevicesTrust(String jid) async {
return _lock.synchronized(() async {
final map = <int, BTBVTrustState>{}; final map = <int, BTBVTrustState>{};
await _lock.synchronized(() async { if (!devices.containsKey(jid)) return map;
for (final deviceId in devices[jid]!) { for (final deviceId in devices[jid]!) {
map[deviceId] = trustCache[RatchetMapKey(jid, deviceId)]!; map[deviceId] = trustCache[RatchetMapKey(jid, deviceId)]!;
} }
});
return map; return map;
});
} }
/// Sets the trust of [jid]'s device with identifier [deviceId] to [state]. /// Sets the trust of [jid]'s device with identifier [deviceId] to [state].
@@ -114,16 +148,83 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager {
}); });
} }
@override
Future<bool> isEnabled(String jid, int deviceId) async {
return _lock.synchronized(() async {
final value = enablementCache[RatchetMapKey(jid, deviceId)];
if (value == null) return false;
return value;
});
}
@override
Future<void> setEnabled(String jid, int deviceId, bool enabled) async {
await _lock.synchronized(() async {
enablementCache[RatchetMapKey(jid, deviceId)] = enabled;
});
// Commit the state
await commitState();
}
@override
Future<Map<String, dynamic>> toJson() async {
return {
'devices': devices,
'trust': trustCache.map((key, value) => MapEntry(
key.toJsonKey(), _trustToInt(value),
),),
'enable': enablementCache.map((key, value) => MapEntry(key.toJsonKey(), value)),
};
}
/// From a serialized version of a BTBV trust manager, extract the device list.
/// NOTE: This is needed as Dart cannot just cast a List<dynamic> to List<int> and so on.
static Map<String, List<int>> deviceListFromJson(Map<String, dynamic> json) {
return (json['devices']! as Map<String, dynamic>).map<String, List<int>>(
(key, value) => MapEntry(
key,
(value as List<dynamic>).map<int>((i) => i as int).toList(),
),
);
}
/// From a serialized version of a BTBV trust manager, extract the trust cache.
/// NOTE: This is needed as Dart cannot just cast a List<dynamic> to List<int> and so on.
static Map<RatchetMapKey, BTBVTrustState> trustCacheFromJson(Map<String, dynamic> json) {
return (json['trust']! as Map<String, dynamic>).map<RatchetMapKey, BTBVTrustState>(
(key, value) => MapEntry(
RatchetMapKey.fromJsonKey(key),
_trustFromInt(value as int),
),
);
}
/// From a serialized version of a BTBV trust manager, extract the enable cache.
/// NOTE: This is needed as Dart cannot just cast a List<dynamic> to List<int> and so on.
static Map<RatchetMapKey, bool> enableCacheFromJson(Map<String, dynamic> json) {
return (json['enable']! as Map<String, dynamic>).map<RatchetMapKey, bool>(
(key, value) => MapEntry(
RatchetMapKey.fromJsonKey(key),
value as bool,
),
);
}
@override
Future<void> removeTrustDecisionsForJid(String jid) async {
await _lock.synchronized(() async {
devices.remove(jid);
await commitState();
});
}
/// Called when the state of the trust manager has been changed. Allows the user to /// Called when the state of the trust manager has been changed. Allows the user to
/// commit the trust state to persistent storage. /// commit the trust state to persistent storage.
@visibleForOverriding @visibleForOverriding
Future<void> commitState(); Future<void> commitState();
/// Called when the user wants to restore the state of the trust manager. The format
/// and actual storage mechanism is left to the user.
@visibleForOverriding
Future<void> loadState();
@visibleForTesting @visibleForTesting
BTBVTrustState getDeviceTrust(String jid, int deviceId) => trustCache[RatchetMapKey(jid, deviceId)]!; BTBVTrustState getDeviceTrust(String jid, int deviceId) => trustCache[RatchetMapKey(jid, deviceId)]!;
} }
@@ -133,7 +234,4 @@ abstract class BlindTrustBeforeVerificationTrustManager extends TrustManager {
class MemoryBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager { class MemoryBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager {
@override @override
Future<void> commitState() async {} Future<void> commitState() async {}
@override
Future<void> loadState() async {}
} }

View File

@@ -11,4 +11,16 @@ class NeverTrustingTrustManager extends TrustManager {
@override @override
Future<void> onNewSession(String jid, int deviceId) async {} Future<void> onNewSession(String jid, int deviceId) async {}
@override
Future<bool> isEnabled(String jid, int deviceId) async => true;
@override
Future<void> setEnabled(String jid, int deviceId, bool enabled) async {}
@override
Future<void> removeTrustDecisionsForJid(String jid) async {}
@override
Future<Map<String, dynamic>> toJson() async => <String, dynamic>{};
} }

View File

@@ -1,17 +1,18 @@
name: omemo_dart name: omemo_dart
description: An XMPP library independent OMEMO library description: An XMPP library independent OMEMO library
version: 0.1.1 version: 0.4.2
homepage: https://github.com/PapaTutuWawa/omemo_dart homepage: https://github.com/PapaTutuWawa/omemo_dart
publish_to: https://git.polynom.me/api/packages/PapaTutuWawa/pub publish_to: https://git.polynom.me/api/packages/PapaTutuWawa/pub
environment: environment:
sdk: '>=2.15.0 <3.0.0' sdk: '>=2.17.0 <3.0.0'
dependencies: dependencies:
collection: ^1.16.0 collection: ^1.16.0
cryptography: ^2.0.5 cryptography: ^2.0.5
hex: ^0.2.0 hex: ^0.2.0
meta: ^1.8.0 logging: ^1.0.2
meta: ^1.7.0
pinenacl: ^0.5.1 pinenacl: ^0.5.1
synchronized: ^3.0.0+2 synchronized: ^3.0.0+2

View File

@@ -84,12 +84,14 @@ void main() {
ikBob.pk, ikBob.pk,
resultAlice.sk, resultAlice.sk,
resultAlice.ad, resultAlice.ad,
0,
); );
final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession( final bobsRatchet = await OmemoDoubleRatchet.acceptNewSession(
spkBob, spkBob,
ikAlice.pk, ikAlice.pk,
resultBob.sk, resultBob.sk,
resultBob.ad, resultBob.ad,
0,
); );
expect(alicesRatchet.sessionAd, bobsRatchet.sessionAd); expect(alicesRatchet.sessionAd, bobsRatchet.sessionAd);

View File

@@ -1,13 +1,50 @@
import 'package:logging/logging.dart';
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:omemo_dart/src/trust/always.dart'; import 'package:omemo_dart/src/trust/always.dart';
import 'package:omemo_dart/src/trust/never.dart'; import 'package:omemo_dart/src/trust/never.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
Logger.root
..level = Level.ALL
..onRecord.listen((record) {
// ignore: avoid_print
print('${record.level.name}: ${record.message}');
});
test('Test replacing a onetime prekey', () async {
const aliceJid = 'alice@server.example';
final device = await OmemoDevice.generateNewDevice(aliceJid, opkAmount: 1);
final newDevice = await device.replaceOnetimePrekey(0);
expect(device.jid, newDevice.jid);
expect(device.id, newDevice.id);
var opksMatch = true;
if (newDevice.opks.length != device.opks.length) {
opksMatch = false;
} else {
for (final entry in device.opks.entries) {
final m = await newDevice.opks[entry.key]?.equals(entry.value) ?? false;
if (!m) opksMatch = false;
}
}
expect(opksMatch, true);
expect(await device.ik.equals(newDevice.ik), true);
expect(await device.spk.equals(newDevice.spk), true);
final oldSpkMatch = device.oldSpk != null ?
await device.oldSpk!.equals(newDevice.oldSpk!) :
newDevice.oldSpk == null;
expect(oldSpkMatch, true);
expect(listsEqual(device.spkSignature, newDevice.spkSignature), true);
});
test('Test using OMEMO sessions with only one device per user', () async { test('Test using OMEMO sessions with only one device per user', () async {
const aliceJid = 'alice@server.example'; const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example'; const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions // Alice and Bob generate their sessions
var deviceModified = false; var deviceModified = false;
var ratchetModified = 0; var ratchetModified = 0;
@@ -28,7 +65,7 @@ void main() {
deviceModified = true; deviceModified = true;
} else if (event is RatchetModifiedEvent) { } else if (event is RatchetModifiedEvent) {
ratchetModified++; ratchetModified++;
} else if (event is DeviceMapModifiedEvent) { } else if (event is DeviceListModifiedEvent) {
deviceMapModified++; deviceMapModified++;
} }
}); });
@@ -39,7 +76,7 @@ void main() {
bobJid, bobJid,
messagePlaintext, messagePlaintext,
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
], ],
); );
expect(aliceMessage.encryptedKeys.length, 1); expect(aliceMessage.encryptedKeys.length, 1);
@@ -51,8 +88,9 @@ void main() {
final bobMessage = await bobSession.decryptMessage( final bobMessage = await bobSession.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession.getDevice()).id, await aliceSession.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
0,
); );
expect(messagePlaintext, bobMessage); expect(messagePlaintext, bobMessage);
// The ratchet should be modified two times: Once for when the ratchet is created and // The ratchet should be modified two times: Once for when the ratchet is created and
@@ -68,6 +106,10 @@ void main() {
false, false,
); );
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Bob responds to Alice // Bob responds to Alice
const bobResponseText = 'Oh, hello Alice!'; const bobResponseText = 'Oh, hello Alice!';
final bobResponseMessage = await bobSession.encryptToJid( final bobResponseMessage = await bobSession.encryptToJid(
@@ -82,8 +124,9 @@ void main() {
final aliceReceivedMessage = await aliceSession.decryptMessage( final aliceReceivedMessage = await aliceSession.decryptMessage(
bobResponseMessage.ciphertext, bobResponseMessage.ciphertext,
bobJid, bobJid,
(await bobSession.getDevice()).id, await bobSession.getDeviceId(),
bobResponseMessage.encryptedKeys, bobResponseMessage.encryptedKeys,
0,
); );
expect(bobResponseText, aliceReceivedMessage); expect(bobResponseText, aliceReceivedMessage);
}); });
@@ -116,8 +159,8 @@ void main() {
bobJid, bobJid,
messagePlaintext, messagePlaintext,
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
await (await bobSession2.getDevice()).toBundle(), await bobSession2.getDeviceBundle(),
], ],
); );
expect(aliceMessage.encryptedKeys.length, 2); expect(aliceMessage.encryptedKeys.length, 2);
@@ -131,11 +174,16 @@ void main() {
final bobMessage = await bobSession.decryptMessage( final bobMessage = await bobSession.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession.getDevice()).id, await aliceSession.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
0,
); );
expect(messagePlaintext, bobMessage); expect(messagePlaintext, bobMessage);
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Bob responds to Alice // Bob responds to Alice
const bobResponseText = 'Oh, hello Alice!'; const bobResponseText = 'Oh, hello Alice!';
final bobResponseMessage = await bobSession.encryptToJid( final bobResponseMessage = await bobSession.encryptToJid(
@@ -150,8 +198,9 @@ void main() {
final aliceReceivedMessage = await aliceSession.decryptMessage( final aliceReceivedMessage = await aliceSession.decryptMessage(
bobResponseMessage.ciphertext, bobResponseMessage.ciphertext,
bobJid, bobJid,
(await bobSession.getDevice()).id, await bobSession.getDeviceId(),
bobResponseMessage.encryptedKeys, bobResponseMessage.encryptedKeys,
0,
); );
expect(bobResponseText, aliceReceivedMessage); expect(bobResponseText, aliceReceivedMessage);
@@ -193,8 +242,8 @@ void main() {
[bobJid, aliceJid], [bobJid, aliceJid],
messagePlaintext, messagePlaintext,
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
await (await aliceSession2.getDevice()).toBundle(), await aliceSession2.getDeviceBundle(),
], ],
); );
expect(aliceMessage.encryptedKeys.length, 2); expect(aliceMessage.encryptedKeys.length, 2);
@@ -206,8 +255,9 @@ void main() {
final bobMessage = await bobSession.decryptMessage( final bobMessage = await bobSession.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession1.getDevice()).id, await aliceSession1.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
0,
); );
expect(messagePlaintext, bobMessage); expect(messagePlaintext, bobMessage);
@@ -215,13 +265,14 @@ void main() {
final aliceMessage2 = await aliceSession2.decryptMessage( final aliceMessage2 = await aliceSession2.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession1.getDevice()).id, await aliceSession1.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
0,
); );
expect(messagePlaintext, aliceMessage2); expect(messagePlaintext, aliceMessage2);
}); });
test('Test using sending empty OMEMO messages', () async { test('Test sending empty OMEMO messages', () async {
const aliceJid = 'alice@server.example'; const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example'; const bobJid = 'bob@other.server.example';
@@ -242,7 +293,7 @@ void main() {
bobJid, bobJid,
null, null,
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
], ],
); );
expect(aliceMessage.encryptedKeys.length, 1); expect(aliceMessage.encryptedKeys.length, 1);
@@ -255,13 +306,14 @@ void main() {
final bobMessage = await bobSession.decryptMessage( final bobMessage = await bobSession.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession.getDevice()).id, await aliceSession.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
0,
); );
expect(bobMessage, null); expect(bobMessage, null);
// This call must not cause an exception // This call must not cause an exception
bobSession.getRatchet(aliceJid, (await aliceSession.getDevice()).id); bobSession.getRatchet(aliceJid, await aliceSession.getDeviceId());
}); });
test('Test rotating the Signed Prekey', () async { test('Test rotating the Signed Prekey', () async {
@@ -275,7 +327,7 @@ void main() {
// Setup an event listener // Setup an event listener
final oldDevice = await aliceSession.getDevice(); final oldDevice = await aliceSession.getDevice();
Device? newDevice; OmemoDevice? newDevice;
aliceSession.eventStream.listen((event) { aliceSession.eventStream.listen((event) {
if (event is DeviceModifiedEvent) { if (event is DeviceModifiedEvent) {
newDevice = event.device; newDevice = event.device;
@@ -317,7 +369,7 @@ void main() {
bobJid, bobJid,
messagePlaintext, messagePlaintext,
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
], ],
); );
expect(aliceMessage.encryptedKeys.length, 1); expect(aliceMessage.encryptedKeys.length, 1);
@@ -332,8 +384,9 @@ void main() {
final bobMessage = await bobSession.decryptMessage( final bobMessage = await bobSession.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession.getDevice()).id, await aliceSession.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
0,
); );
expect(messagePlaintext, bobMessage); expect(messagePlaintext, bobMessage);
}); });
@@ -359,7 +412,7 @@ void main() {
bobJid, bobJid,
null, null,
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
], ],
); );
@@ -367,4 +420,527 @@ void main() {
// untrusted device. // untrusted device.
expect(aliceMessage.encryptedKeys.length, 1); expect(aliceMessage.encryptedKeys.length, 1);
}); });
test('Test by sending multiple messages back and forth', () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
// Alice encrypts a message for Bob
final aliceMessage = await aliceSession.encryptToJid(
bobJid,
'Hello Bob!',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
// Alice sends the message to Bob
// ...
await bobSession.decryptMessage(
aliceMessage.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
aliceMessage.encryptedKeys,
0,
);
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
for (var i = 0; i < 100; i++) {
final messageText = 'Test Message #$i';
// Bob responds to Alice
final bobResponseMessage = await bobSession.encryptToJid(
aliceJid,
messageText,
);
// Bob sends the message to Alice
// ...
// Alice decrypts it
final aliceReceivedMessage = await aliceSession.decryptMessage(
bobResponseMessage.ciphertext,
bobJid,
await bobSession.getDeviceId(),
bobResponseMessage.encryptedKeys,
0,
);
expect(messageText, aliceReceivedMessage);
}
});
group('Test removing a ratchet', () {
test('Test removing a ratchet when the user has multiple', () async {
const aliceJid = 'alice@server.local';
const bobJid = 'bob@some.server.local';
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession1 = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession2 = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
// Alice sends a message to those two Bobs
await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession1.getDeviceBundle(),
await bobSession2.getDeviceBundle(),
],
);
// One of those two sessions is broken, so Alice removes the session2 ratchet
final id1 = await bobSession1.getDeviceId();
final id2 = await bobSession2.getDeviceId();
await aliceSession.removeRatchet(bobJid, id1);
final map = aliceSession.getRatchetMap();
expect(map.containsKey(RatchetMapKey(bobJid, id1)), false);
expect(map.containsKey(RatchetMapKey(bobJid, id2)), true);
final deviceMap = await aliceSession.getDeviceMap();
expect(deviceMap.containsKey(bobJid), true);
expect(deviceMap[bobJid], [id2]);
});
test('Test removing a ratchet when the user has only one', () async {
const aliceJid = 'alice@server.local';
const bobJid = 'bob@some.server.local';
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
// Alice sends a message to those two Bobs
await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
// One of those two sessions is broken, so Alice removes the session2 ratchet
final id = await bobSession.getDeviceId();
await aliceSession.removeRatchet(bobJid, id);
final map = aliceSession.getRatchetMap();
expect(map.containsKey(RatchetMapKey(bobJid, id)), false);
final deviceMap = await aliceSession.getDeviceMap();
expect(deviceMap.containsKey(bobJid), false);
});
});
test('Test acknowledging a ratchet', () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
// Alice sends Bob a message
await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
expect(
await aliceSession.getUnacknowledgedRatchets(bobJid),
[
await bobSession.getDeviceId(),
],
);
// Bob sends alice an empty message
// ...
// Alice decrypts it
// ...
// Alice marks the ratchet as acknowledged
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
expect(
(await aliceSession.getUnacknowledgedRatchets(bobJid))!.isEmpty,
true,
);
});
test('Test overwriting sessions', () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 2,
);
// Alice sends Bob a message
final msg1 = await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
await bobSession.decryptMessage(
msg1.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg1.encryptedKeys,
0,
);
final aliceRatchet1 = aliceSession.getRatchet(
bobJid,
await bobSession.getDeviceId(),
);
final bobRatchet1 = bobSession.getRatchet(
aliceJid,
await aliceSession.getDeviceId(),
);
// Alice is impatient and immediately sends another message before the original one
// can be acknowledged by Bob
final msg2 = await aliceSession.encryptToJid(
bobJid,
"Why don't you answer?",
newSessions: [
await bobSession.getDeviceBundle(),
],
);
await bobSession.decryptMessage(
msg2.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg2.encryptedKeys,
getTimestamp(),
);
final aliceRatchet2 = aliceSession.getRatchet(
bobJid,
await bobSession.getDeviceId(),
);
final bobRatchet2 = bobSession.getRatchet(
aliceJid,
await aliceSession.getDeviceId(),
);
// Both should only have one ratchet
expect(aliceSession.getRatchetMap().length, 1);
expect(bobSession.getRatchetMap().length, 1);
// The ratchets should both be different
expect(await aliceRatchet1.equals(aliceRatchet2), false);
expect(await bobRatchet1.equals(bobRatchet2), false);
});
test('Test resending key exchanges', () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 2,
);
// Alice sends Bob a message
final msg1 = await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
// The first message should be a kex message
expect(msg1.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg1.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg1.encryptedKeys,
0,
);
// Alice is impatient and immediately sends another message before the original one
// can be acknowledged by Bob
final msg2 = await aliceSession.encryptToJid(
bobJid,
"Why don't you answer?",
);
expect(msg2.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg2.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg2.encryptedKeys,
getTimestamp(),
);
});
test('Test receiving old messages including a KEX', () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 2,
);
final bobsReceivedMessages = List<EncryptionResult>.empty(growable: true);
final bobsReceivedMessagesTimestamps = List<int>.empty(growable: true);
// Alice sends Bob a message
final msg1 = await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
bobsReceivedMessages.add(msg1);
final t1 = getTimestamp();
bobsReceivedMessagesTimestamps.add(t1);
await bobSession.decryptMessage(
msg1.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg1.encryptedKeys,
t1,
);
// Ratchets are acked
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Bob responds
final msg2 = await bobSession.encryptToJid(
aliceJid,
'Hello!',
);
await aliceSession.decryptMessage(
msg2.ciphertext,
bobJid,
await bobSession.getDeviceId(),
msg2.encryptedKeys,
getTimestamp(),
);
// Send some messages between the two
for (var i = 0; i < 100; i++) {
final msg = await aliceSession.encryptToJid(
bobJid,
'Hello $i',
);
bobsReceivedMessages.add(msg);
final t = getTimestamp();
bobsReceivedMessagesTimestamps.add(t);
final result = await bobSession.decryptMessage(
msg.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg.encryptedKeys,
t,
);
expect(result, 'Hello $i');
}
// Due to some issue with the transport protocol, the messages to Bob are received
// again.
final ratchetPreError = bobSession
.getRatchet(aliceJid, await aliceSession.getDeviceId())
.clone();
var invalidKex = 0;
var errorCounter = 0;
for (var i = 0; i < bobsReceivedMessages.length; i++) {
final msg = bobsReceivedMessages[i];
try {
await bobSession.decryptMessage(
msg.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg.encryptedKeys,
bobsReceivedMessagesTimestamps[i],
);
expect(true, false);
} on InvalidMessageHMACException catch (_) {
errorCounter++;
} on InvalidKeyExchangeException catch (_) {
invalidKex++;
}
}
final ratchetPostError = bobSession
.getRatchet(aliceJid, await aliceSession.getDeviceId())
.clone();
// The 100 messages including the initial KEX message
expect(invalidKex, 1);
expect(errorCounter, 100);
expect(await ratchetPreError.equals(ratchetPostError), true);
final msg3 = await aliceSession.encryptToJid(
bobJid,
'Are you okay?',
);
final result = await bobSession.decryptMessage(
msg3.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg3.encryptedKeys,
104,
);
expect(result, 'Are you okay?');
});
test("Test ignoring a new KEX when we haven't acket it yet", () async {
const aliceJid = 'alice@server.example';
const bobJid = 'bob@other.server.example';
// Alice and Bob generate their sessions
final aliceSession = await OmemoSessionManager.generateNewIdentity(
aliceJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
final bobSession = await OmemoSessionManager.generateNewIdentity(
bobJid,
AlwaysTrustingTrustManager(),
opkAmount: 1,
);
// Alice sends Bob a message
final msg1 = await aliceSession.encryptToJid(
bobJid,
'Hallo Welt',
newSessions: [
await bobSession.getDeviceBundle(),
],
);
expect(msg1.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg1.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg1.encryptedKeys,
getTimestamp(),
);
// Alice sends another message before the ack can reach us
final msg2 = await aliceSession.encryptToJid(
bobJid,
'ANSWER ME!',
);
expect(msg2.encryptedKeys.first.kex, true);
await bobSession.decryptMessage(
msg2.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg2.encryptedKeys,
getTimestamp(),
);
// Now the acks reach us
await aliceSession.ratchetAcknowledged(bobJid, await bobSession.getDeviceId());
await bobSession.ratchetAcknowledged(aliceJid, await aliceSession.getDeviceId());
// Alice sends another message
final msg3 = await aliceSession.encryptToJid(
bobJid,
"You read the message, didn't you?",
);
expect(msg3.encryptedKeys.first.kex, false);
await bobSession.decryptMessage(
msg3.ciphertext,
aliceJid,
await aliceSession.getDeviceId(),
msg3.encryptedKeys,
getTimestamp(),
);
for (var i = 0; i < 100; i++) {
final messageText = 'Test Message #$i';
// Bob responds to Alice
final bobResponseMessage = await bobSession.encryptToJid(
aliceJid,
messageText,
);
// Bob sends the message to Alice
// ...
// Alice decrypts it
final aliceReceivedMessage = await aliceSession.decryptMessage(
bobResponseMessage.ciphertext,
bobJid,
await bobSession.getDeviceId(),
bobResponseMessage.encryptedKeys,
0,
);
expect(messageText, aliceReceivedMessage);
}
});
} }

1028
test/omemomanager_test.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,10 @@ void main() {
<int>[172, 2], <int>[172, 2],
); );
}); });
test('Test some special cases', () {
expect(decodeVarint(encodeVarint(1042464893), 0).n, 1042464893);
});
}); });
group('OMEMOMessage', () { group('OMEMOMessage', () {
@@ -170,5 +174,13 @@ void main() {
expect(decoded.message!.mac, <int>[5, 6, 8, 0]); expect(decoded.message!.mac, <int>[5, 6, 8, 0]);
expect(decoded.message!.message, <int>[4, 5, 7, 3, 2]); expect(decoded.message!.message, <int>[4, 5, 7, 3, 2]);
}); });
test('Test decoding an issue', () {
/*
final data = 'CAAQfRogc2GwslU219dUkrMHNM4KdZRmuFnBTae+bQaJ+55IsAMiII7aZKj2sUpb6xR/3Ari7WZUmKFV0G6czUc4NMvjKDBaKnwKEM2ZpI8X3TgcxhxwENANnlsSaAgAEAAaICy8T9WPgLb7RdYd8/4JkrLF0RahEkC3ZaEfk5jw3dsLIkBMILzLyByweLgF4lCn0oNea+kbdrFr6rY7r/7WyI8hXEQz38QpnN+jyGGwC7Ga0dq70WuyqE7VpiFArQwqZh2G';
final kex = OmemoKeyExchange.fromBuffer(base64Decode(data));
expect(kex.spkId!, 1042464893);
*/
});
}); });
} }

View File

@@ -1,7 +1,12 @@
import 'dart:convert';
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:omemo_dart/src/trust/always.dart'; import 'package:omemo_dart/src/trust/always.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
Map<String, dynamic> jsonify(Map<String, dynamic> map) {
return jsonDecode(jsonEncode(map)) as Map<String, dynamic>;
}
void main() { void main() {
test('Test serialising and deserialising the Device', () async { test('Test serialising and deserialising the Device', () async {
// Generate a random session // Generate a random session
@@ -11,9 +16,9 @@ void main() {
opkAmount: 1, opkAmount: 1,
); );
final oldDevice = await oldSession.getDevice(); final oldDevice = await oldSession.getDevice();
final serialised = await oldDevice.toJson(); final serialised = jsonify(await oldDevice.toJson());
final newDevice = Device.fromJson(serialised); final newDevice = OmemoDevice.fromJson(serialised);
expect(await oldDevice.equals(newDevice), true); expect(await oldDevice.equals(newDevice), true);
}); });
@@ -25,9 +30,9 @@ void main() {
opkAmount: 1, opkAmount: 1,
); );
final oldDevice = await (await oldSession.getDevice()).replaceSignedPrekey(); final oldDevice = await (await oldSession.getDevice()).replaceSignedPrekey();
final serialised = await oldDevice.toJson(); final serialised = jsonify(await oldDevice.toJson());
final newDevice = Device.fromJson(serialised); final newDevice = OmemoDevice.fromJson(serialised);
expect(await oldDevice.equals(newDevice), true); expect(await oldDevice.equals(newDevice), true);
}); });
@@ -49,17 +54,18 @@ void main() {
bobJid, bobJid,
'Hello Bob!', 'Hello Bob!',
newSessions: [ newSessions: [
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
], ],
); );
await bobSession.decryptMessage( await bobSession.decryptMessage(
aliceMessage.ciphertext, aliceMessage.ciphertext,
aliceJid, aliceJid,
(await aliceSession.getDevice()).id, await aliceSession.getDeviceId(),
aliceMessage.encryptedKeys, aliceMessage.encryptedKeys,
getTimestamp(),
); );
final aliceOld = aliceSession.getRatchet(bobJid, (await bobSession.getDevice()).id); final aliceOld = aliceSession.getRatchet(bobJid, await bobSession.getDeviceId());
final aliceSerialised = await aliceOld.toJson(); final aliceSerialised = jsonify(await aliceOld.toJson());
final aliceNew = OmemoDoubleRatchet.fromJson(aliceSerialised); final aliceNew = OmemoDoubleRatchet.fromJson(aliceSerialised);
expect(await aliceOld.equals(aliceNew), true); expect(await aliceOld.equals(aliceNew), true);
@@ -79,29 +85,64 @@ void main() {
); );
await oldSession.addSessionFromBundle( await oldSession.addSessionFromBundle(
'bob@localhost', 'bob@localhost',
(await bobSession.getDevice()).id, await bobSession.getDeviceId(),
await (await bobSession.getDevice()).toBundle(), await bobSession.getDeviceBundle(),
); );
// Serialise and deserialise // Serialise and deserialise
final serialised = await oldSession.toJson(); final serialised = jsonify(await oldSession.toJsonWithoutSessions());
final newSession = OmemoSessionManager.fromJson( final newSession = OmemoSessionManager.fromJsonWithoutSessions(
serialised, serialised,
// NOTE: At this point, we don't care about this attribute
{},
AlwaysTrustingTrustManager(), AlwaysTrustingTrustManager(),
); );
final oldDevice = await oldSession.getDevice(); final oldDevice = await oldSession.getDevice();
final newDevice = await newSession.getDevice(); final newDevice = await newSession.getDevice();
expect(await oldDevice.equals(newDevice), true); expect(await oldDevice.equals(newDevice), true);
expect(oldSession.getDeviceMap(), newSession.getDeviceMap()); expect(await oldSession.getDeviceMap(), await newSession.getDeviceMap());
});
expect(oldSession.getRatchetMap().length, newSession.getRatchetMap().length); test('Test serializing and deserializing RatchetMapKey', () {
for (final session in oldSession.getRatchetMap().entries) { const test1 = RatchetMapKey('user@example.org', 1234);
expect(newSession.getRatchetMap().containsKey(session.key), true); final result1 = RatchetMapKey.fromJsonKey(test1.toJsonKey());
expect(result1.jid, test1.jid);
expect(result1.deviceId, test1.deviceId);
final oldRatchet = oldSession.getRatchetMap()[session.key]!; const test2 = RatchetMapKey('user@example.org/hallo:welt', 3333);
final newRatchet = newSession.getRatchetMap()[session.key]!; final result2 = RatchetMapKey.fromJsonKey(test2.toJsonKey());
expect(await oldRatchet.equals(newRatchet), true); expect(result2.jid, test2.jid);
} expect(result2.deviceId, test2.deviceId);
});
test('Test serializing and deserializing the components of the BTBV manager', () async {
// Caroline's BTBV manager
final btbv = MemoryBTBVTrustManager();
// Example data
const aliceJid = 'alice@some.server';
const bobJid = 'bob@other.server';
await btbv.onNewSession(aliceJid, 1);
await btbv.setDeviceTrust(aliceJid, 1, BTBVTrustState.verified);
await btbv.onNewSession(aliceJid, 2);
await btbv.onNewSession(bobJid, 3);
await btbv.onNewSession(bobJid, 4);
final serialized = jsonify(await btbv.toJson());
final deviceList = BlindTrustBeforeVerificationTrustManager.deviceListFromJson(
serialized,
);
expect(btbv.devices, deviceList);
final trustCache = BlindTrustBeforeVerificationTrustManager.trustCacheFromJson(
serialized,
);
expect(btbv.trustCache, trustCache);
final enableCache = BlindTrustBeforeVerificationTrustManager.enableCacheFromJson(
serialized,
);
expect(btbv.enablementCache, enableCache);
}); });
} }

View File

@@ -12,6 +12,7 @@ void main() {
// Caroline starts a chat a device from Alice // Caroline starts a chat a device from Alice
await btbv.onNewSession(aliceJid, 1); await btbv.onNewSession(aliceJid, 1);
expect(await btbv.isTrusted(aliceJid, 1), true); expect(await btbv.isTrusted(aliceJid, 1), true);
expect(await btbv.isEnabled(aliceJid, 1), true);
// Caroline meets with Alice and verifies her fingerprint // Caroline meets with Alice and verifies her fingerprint
await btbv.setDeviceTrust(aliceJid, 1, BTBVTrustState.verified); await btbv.setDeviceTrust(aliceJid, 1, BTBVTrustState.verified);
@@ -21,6 +22,7 @@ void main() {
await btbv.onNewSession(aliceJid, 2); await btbv.onNewSession(aliceJid, 2);
expect(await btbv.isTrusted(aliceJid, 2), false); expect(await btbv.isTrusted(aliceJid, 2), false);
expect(btbv.getDeviceTrust(aliceJid, 2), BTBVTrustState.notTrusted); expect(btbv.getDeviceTrust(aliceJid, 2), BTBVTrustState.notTrusted);
expect(await btbv.isEnabled(aliceJid, 2), false);
// Caronline starts a chat with Bob but since they live far apart, Caroline cannot // Caronline starts a chat with Bob but since they live far apart, Caroline cannot
// verify his fingerprint. // verify his fingerprint.
@@ -32,5 +34,7 @@ void main() {
expect(await btbv.isTrusted(bobJid, 4), true); expect(await btbv.isTrusted(bobJid, 4), true);
expect(btbv.getDeviceTrust(bobJid, 3), BTBVTrustState.blindTrust); expect(btbv.getDeviceTrust(bobJid, 3), BTBVTrustState.blindTrust);
expect(btbv.getDeviceTrust(bobJid, 4), BTBVTrustState.blindTrust); expect(btbv.getDeviceTrust(bobJid, 4), BTBVTrustState.blindTrust);
expect(await btbv.isEnabled(bobJid, 3), true);
expect(await btbv.isEnabled(bobJid, 4), true);
}); });
} }