feat: Migrate to the new omemo_dart API

This commit is contained in:
PapaTutuWawa 2023-01-01 16:19:25 +01:00
parent 09696c1c4d
commit 637e1e25a6
2 changed files with 156 additions and 467 deletions

View File

@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:meta/meta.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/src/events.dart';
import 'package:moxxmpp/src/jid.dart';
import 'package:moxxmpp/src/managers/base.dart';
@ -25,7 +23,6 @@ import 'package:moxxmpp/src/xeps/xep_0384/errors.dart';
import 'package:moxxmpp/src/xeps/xep_0384/helpers.dart';
import 'package:moxxmpp/src/xeps/xep_0384/types.dart';
import 'package:omemo_dart/omemo_dart.dart';
import 'package:synchronized/synchronized.dart';
const _doNotEncryptList = [
// XEP-0033
@ -43,14 +40,7 @@ const _doNotEncryptList = [
DoNotEncrypt('stanza-id', stableIdXmlns),
];
abstract class OmemoManager extends XmppManagerBase {
OmemoManager() : _handlerLock = Lock(), _handlerFutures = {}, super();
final Lock _handlerLock;
final Map<JID, Queue<Completer<void>>> _handlerFutures;
final Map<JID, List<int>> _deviceMap = {};
abstract class BaseOmemoManager extends XmppManagerBase {
// Mapping whether we already tried to subscribe to the JID's devices node
final Map<JID, bool> _subscriptionMap = {};
@ -127,60 +117,31 @@ abstract class OmemoManager extends XmppManagerBase {
} else {
// Someone published to their device list node
logger.finest('Got devices $ids');
_deviceMap[jid] = ids;
}
// Tell the OmemoManager
(await getOmemoManager())
.onDeviceListUpdate(jid.toString(), ids);
// Generate an event
getAttributes().sendEvent(OmemoDeviceListUpdatedEvent(jid, ids));
}
}
@visibleForOverriding
Future<OmemoSessionManager> getSessionManager();
Future<OmemoManager> getOmemoManager();
/// Wrapper around using getSessionManager and then calling encryptToJids on it.
Future<EncryptionResult> _encryptToJids(List<String> jids, String? plaintext, { List<OmemoBundle>? newSessions }) async {
final session = await getSessionManager();
return session.encryptToJids(jids, plaintext, newSessions: newSessions);
}
/// Wrapper around using getSessionManager and then calling encryptToJids on it.
Future<String?> _decryptMessage(List<int>? ciphertext, String senderJid, int senderDeviceId, List<EncryptedKey> keys, int sendTimestamp) async {
final session = await getSessionManager();
return session.decryptMessage(
ciphertext,
senderJid,
senderDeviceId,
keys,
sendTimestamp,
);
}
/// Wrapper around using getSessionManager and then calling getDeviceId on it.
Future<int> _getDeviceId() async {
final session = await getSessionManager();
return session.getDeviceId();
}
Future<int> _getDeviceId() async => (await getOmemoManager()).getDeviceId();
/// Wrapper around using getSessionManager and then calling getDeviceId on it.
Future<OmemoBundle> _getDeviceBundle() async {
final session = await getSessionManager();
return session.getDeviceBundle();
final om = await getOmemoManager();
final device = await om.getDevice();
return device.toBundle();
}
/// Wrapper around using getSessionManager and then calling isRatchetAcknowledged on it.
Future<bool> _isRatchetAcknowledged(String jid, int deviceId) async {
final session = await getSessionManager();
return session.isRatchetAcknowledged(jid, deviceId);
}
/// Wrapper around checking if [jid] appears in the session manager's device map.
Future<bool> _hasSessionWith(String jid) async {
final session = await getSessionManager();
final deviceMap = await session.getDeviceMap();
return deviceMap.containsKey(jid);
}
/// Determines what child elements of a stanza should be encrypted. If shouldEncrypt
/// returns true for [element], then [element] will be encrypted. If shouldEncrypt
/// returns false, then [element] won't be encrypted.
@ -205,56 +166,51 @@ abstract class OmemoManager extends XmppManagerBase {
/// an attached payload, if [children] is not null, or an empty OMEMO message if
/// [children] is null. This function takes care of creating the affix elements as
/// specified by both XEP-0420 and XEP-0384.
/// [jids] is the list of JIDs the payload should be encrypted for.
Future<XMLNode> _encryptChildren(List<XMLNode>? children, List<String> jids, String toJid, List<OmemoBundle> newSessions) async {
XMLNode? payload;
if (children != null) {
payload = XMLNode.xmlns(
tag: 'envelope',
xmlns: sceXmlns,
children: [
XMLNode(
tag: 'content',
children: children,
),
/// [toJid] is the list of JIDs the payload should be encrypted for.
String _buildEnvelope(List<XMLNode> children, String toJid) {
final payload = XMLNode.xmlns(
tag: 'envelope',
xmlns: sceXmlns,
children: [
XMLNode(
tag: 'content',
children: children,
),
XMLNode(
tag: 'rpad',
text: generateRpad(),
),
XMLNode(
tag: 'to',
attributes: <String, String>{
'jid': toJid,
},
),
XMLNode(
tag: 'from',
attributes: <String, String>{
'jid': getAttributes().getFullJID().toString(),
},
),
/*
XMLNode(
tag: 'time',
// TODO(Unknown): Implement
attributes: <String, String>{
'stamp': '',
},
),
*/
],
);
}
final encryptedEnvelope = await _encryptToJids(
jids,
payload?.toXml(),
newSessions: newSessions,
XMLNode(
tag: 'rpad',
text: generateRpad(),
),
XMLNode(
tag: 'to',
attributes: <String, String>{
'jid': toJid,
},
),
XMLNode(
tag: 'from',
attributes: <String, String>{
'jid': getAttributes().getFullJID().toString(),
},
),
/*
XMLNode(
tag: 'time',
// TODO(Unknown): Implement
attributes: <String, String>{
'stamp': '',
},
),
*/
],
);
return payload.toString();
}
XMLNode _buildEncryptedElement(EncryptionResult result, String recipientJid, int deviceId) {
final keyElements = <String, List<XMLNode>>{};
for (final key in encryptedEnvelope.encryptedKeys) {
for (final key in result.encryptedKeys) {
final keyElement = XMLNode(
tag: 'key',
attributes: <String, String>{
@ -282,11 +238,11 @@ abstract class OmemoManager extends XmppManagerBase {
}).toList();
var payloadElement = <XMLNode>[];
if (payload != null) {
if (result.ciphertext != null) {
payloadElement = [
XMLNode(
tag: 'payload',
text: base64.encode(encryptedEnvelope.ciphertext!),
text: base64.encode(result.ciphertext!),
),
];
}
@ -299,7 +255,7 @@ abstract class OmemoManager extends XmppManagerBase {
XMLNode(
tag: 'header',
attributes: <String, String>{
'sid': (await _getDeviceId()).toString(),
'sid': deviceId.toString(),
},
children: keysElements,
),
@ -307,136 +263,18 @@ abstract class OmemoManager extends XmppManagerBase {
);
}
/// A logging wrapper around acking the ratchet with [jid] with identifier [deviceId].
Future<void> _ackRatchet(String jid, int deviceId) async {
logger.finest('Acking ratchet $jid:$deviceId');
final session = await getSessionManager();
await session.ratchetAcknowledged(jid, deviceId);
}
/// Figure out if new sessions need to be built. [toJid] is the JID of the entity we
/// want to send a message to. [children] refers to the unencrypted children of the
/// message. They are required to be passed because shouldIgnoreUnackedRatchets is
/// called here.
///
/// Either returns a list of bundles we "need" to build a session with or an OmemoError.
Future<Result<OmemoError, List<OmemoBundle>>> _findNewSessions(JID toJid, List<XMLNode> children) async {
final ownJid = getAttributes().getFullJID().toBare();
final session = await getSessionManager();
final ownId = await session.getDeviceId();
// Ignore our own device if it is the only published device on our devices node
if (toJid.toBare() == ownJid) {
final deviceList = await getDeviceList(ownJid);
if (deviceList.isType<List<int>>()) {
final devices = deviceList.get<List<int>>();
if (devices.length == 1 && devices.first == ownId) {
return const Result(<OmemoBundle>[]);
}
}
}
final newSessions = List<OmemoBundle>.empty(growable: true);
final sessionAvailable = await _hasSessionWith(toJid.toString());
if (!sessionAvailable) {
logger.finest('No session for $toJid. Retrieving bundles to build a new session.');
final result = await retrieveDeviceBundles(toJid);
if (result.isType<List<OmemoBundle>>()) {
final bundles = result.get<List<OmemoBundle>>();
if (ownJid == toJid) {
logger.finest('Requesting bundles for own JID. Ignoring current device');
newSessions.addAll(bundles.where((bundle) => bundle.id != ownId));
} else {
newSessions.addAll(bundles);
}
} else {
logger.warning('Failed to retrieve device bundles for $toJid');
return Result(OmemoNotSupportedForContactException());
}
if (!_subscriptionMap.containsKey(toJid)) {
await subscribeToDeviceList(toJid);
}
} else {
final toBare = toJid.toBare();
final ratchetSessions = (await session.getDeviceMap())[toBare.toString()]!;
final deviceMapRaw = await getDeviceList(toBare);
if (!_subscriptionMap.containsKey(toBare)) {
unawaited(subscribeToDeviceList(toBare));
}
if (deviceMapRaw.isType<OmemoError>()) {
logger.warning('Failed to get device list');
return Result(UnknownOmemoError());
}
final deviceList = deviceMapRaw.get<List<int>>();
for (final id in deviceList) {
// We already have a session with that device
if (ratchetSessions.contains(id)) continue;
// Ignore requests for our own device.
if (toJid == ownJid && id == ownId) {
logger.finest('Attempted to request bundle for our own device $id, which is the current device. Skipping request...');
continue;
}
logger.finest('Retrieving bundle for $toJid:$id');
final bundle = await retrieveDeviceBundle(toJid, id);
if (bundle.isType<OmemoBundle>()) {
newSessions.add(bundle.get<OmemoBundle>());
} else {
logger.warning('Failed to retrieve bundle for $toJid:$id');
}
}
}
return Result(newSessions);
}
/// Sends an empty Omemo message to [toJid].
///
/// If [findNewSessions] is true, then
/// new devices will be looked for first before sending the message. This means that
/// the new sessions will be included in the empty Omemo message. If false, then no
/// new sessions will be looked for before encrypting.
///
/// [calledFromCriticalSection] MUST NOT be used from outside the manager. If true, then
/// sendEmptyMessage will not attempt to enter the critical section guarding the
/// encryption and decryption. If false, then the critical section will be entered before
/// encryption and left after sending the message.
Future<void> sendEmptyMessage(JID toJid, {
bool findNewSessions = false,
@protected
bool calledFromCriticalSection = false,
}) async {
if (!calledFromCriticalSection) {
final completer = await _handlerEntry(toJid);
if (completer != null) {
await completer.future;
}
}
var newSessions = <OmemoBundle>[];
if (findNewSessions) {
final result = await _findNewSessions(toJid, <XMLNode>[]);
if (!result.isType<OmemoError>()) newSessions = result.get<List<OmemoBundle>>();
}
final empty = await _encryptChildren(
null,
[toJid.toString()],
toJid.toString(),
newSessions,
);
/// For usage with omemo_dart's OmemoManager.
Future<void> sendEmptyMessageImpl(EncryptionResult result, String toJid) async {
await getAttributes().sendStanza(
Stanza.message(
to: toJid.toString(),
to: toJid,
type: 'chat',
children: [
empty,
_buildEncryptedElement(
result,
toJid,
await _getDeviceId(),
),
// Add a storage hint in case this is a message
// Taken from the example at
@ -447,10 +285,28 @@ abstract class OmemoManager extends XmppManagerBase {
awaitable: false,
encrypted: true,
);
}
if (!calledFromCriticalSection) {
await _handlerExit(toJid);
}
/// Send a heartbeat message to [jid].
Future<void> sendOmemoHeartbeat(String jid) async {
final om = await getOmemoManager();
await om.sendOmemoHeartbeat(jid);
}
/// For usage with omemo_dart's OmemoManager
Future<List<int>?> fetchDeviceList(String jid) async {
final result = await getDeviceList(JID.fromString(jid));
if (result.isType<OmemoError>()) return null;
return result.get<List<int>>();
}
/// For usage with omemo_dart's OmemoManager
Future<OmemoBundle?> fetchDeviceBundle(String jid, int id) async {
final result = await retrieveDeviceBundle(JID.fromString(jid), id);
if (result.isType<OmemoError>()) return null;
return result.get<OmemoBundle>();
}
Future<StanzaHandlerData> _onOutgoingStanza(Stanza stanza, StanzaHandlerData state) async {
@ -465,40 +321,20 @@ abstract class OmemoManager extends XmppManagerBase {
}
final toJid = JID.fromString(stanza.to!).toBare();
if (_subscriptionMap[toJid] != true) {
unawaited(
subscribeToDeviceList(toJid),
);
_subscriptionMap[toJid] = true;
}
if (!(await shouldEncryptStanza(toJid, stanza))) {
logger.finest('shouldEncryptStanza returned false for message to $toJid. Not encrypting.');
return state;
} else {
logger.finest('shouldEncryptStanza returned true for message to $toJid.');
}
final completer = await _handlerEntry(toJid);
if (completer != null) {
await completer.future;
}
final newSessions = List<OmemoBundle>.empty(growable: true);
// Try to find new sessions for [toJid].
final resultToJid = await _findNewSessions(toJid, stanza.children);
if (resultToJid.isType<List<OmemoBundle>>()) {
newSessions.addAll(resultToJid.get<List<OmemoBundle>>());
} else {
if (resultToJid.isType<OmemoNotSupportedForContactException>()) {
await _handlerExit(toJid);
return state.copyWith(
cancel: true,
cancelReason: resultToJid.get<OmemoNotSupportedForContactException>(),
);
}
}
// Try to find new sessions for our own Jid.
final ownJid = getAttributes().getFullJID().toBare();
final resultOwnJid = await _findNewSessions(ownJid, stanza.children);
if (resultOwnJid.isType<List<OmemoBundle>>()) {
newSessions.addAll(resultOwnJid.get<List<OmemoBundle>>());
}
final toEncrypt = List<XMLNode>.empty(growable: true);
final children = List<XMLNode>.empty(growable: true);
for (final child in stanza.children) {
@ -508,76 +344,38 @@ abstract class OmemoManager extends XmppManagerBase {
toEncrypt.add(child);
}
}
final om = await getOmemoManager();
final result = await om.onOutgoingStanza(
OmemoOutgoingStanza(
[toJid.toString()],
_buildEnvelope(toEncrypt, toJid.toString()),
),
);
final jidsToEncryptFor = <String>[JID.fromString(stanza.to!).toBare().toString()];
// Prevent encrypting to self if there is only one device (ours).
if (await _hasSessionWith(ownJid.toString())) {
jidsToEncryptFor.add(ownJid.toString());
final encrypted = _buildEncryptedElement(
result,
toJid.toString(),
await _getDeviceId(),
);
children.add(encrypted);
// Only add message specific metadata when actually sending a message
if (stanza.tag == 'message') {
children
// Add EME data
..add(buildEmeElement(ExplicitEncryptionType.omemo2))
// Add a storage hint in case this is a message
// Taken from the example at
// https://xmpp.org/extensions/xep-0384.html#message-structure-description.
..add(MessageProcessingHint.store.toXml());
}
try {
logger.finest('Encrypting stanza');
final encrypted = await _encryptChildren(
toEncrypt,
jidsToEncryptFor,
stanza.to!,
newSessions,
);
logger.finest('Encryption done');
children.add(encrypted);
// Only add EME when sending a message
if (stanza.tag == 'message') {
children.add(buildEmeElement(ExplicitEncryptionType.omemo2));
}
// Add a storage hint in case this is a message
// Taken from the example at
// https://xmpp.org/extensions/xep-0384.html#message-structure-description.
if (stanza.tag == 'message') {
children.add(MessageProcessingHint.store.toXml());
}
await _handlerExit(toJid);
return state.copyWith(
stanza: state.stanza.copyWith(
children: children,
),
encrypted: true,
);
} catch (ex) {
logger.severe('Encryption failed! $ex');
await _handlerExit(toJid);
return state.copyWith(
cancel: true,
cancelReason: EncryptionFailedException(),
other: {
...state.other,
'encryption_error': ex,
},
);
}
}
/// This function returns true if the encryption scheme should ignore unacked ratchets
/// and don't try to build a new ratchet even though there are unacked ones.
/// The current logic is that chat states with no body ignore the "ack" state of the
/// ratchets.
///
/// This function may be overriden. By default, the ack status of the ratchet is ignored
/// if we're sending a message containing chatstates or chat markers and the message does
/// not contain a <body /> element.
@visibleForOverriding
bool shouldIgnoreUnackedRatchets(List<XMLNode> children) {
return listContains(
children,
(XMLNode child) {
return child.attributes['xmlns'] == chatStateXmlns || child.attributes['xmlns'] == chatMarkersXmlns;
},
) && !listContains(
children,
(XMLNode child) => child.tag == 'body',
return state.copyWith(
stanza: state.stanza.copyWith(
children: children,
),
encrypted: true,
);
}
@ -587,48 +385,12 @@ abstract class OmemoManager extends XmppManagerBase {
@visibleForOverriding
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza);
/// Wrapper function that attempts to enter the encryption/decryption critical section.
/// In case the critical section could be entered, null is returned. If not, then a
/// Completer is returned whose future will resolve once the critical section can be
/// entered.
Future<Completer<void>?> _handlerEntry(JID fromJid) async {
return _handlerLock.synchronized(() {
if (_handlerFutures.containsKey(fromJid)) {
final c = Completer<void>();
_handlerFutures[fromJid]!.addLast(c);
return c;
}
_handlerFutures[fromJid] = Queue();
return null;
});
}
/// Wrapper function that exits the critical section.
Future<void> _handlerExit(JID fromJid) async {
await _handlerLock.synchronized(() {
if (_handlerFutures.containsKey(fromJid)) {
if (_handlerFutures[fromJid]!.isEmpty) {
_handlerFutures.remove(fromJid);
return;
}
_handlerFutures[fromJid]!.removeFirst().complete();
}
});
}
Future<StanzaHandlerData> _onIncomingStanza(Stanza stanza, StanzaHandlerData state) async {
final encrypted = stanza.firstTag('encrypted', xmlns: omemoXmlns);
if (encrypted == null) return state;
if (stanza.from == null) return state;
final fromJid = JID.fromString(stanza.from!).toBare();
final completer = await _handlerEntry(fromJid);
if (completer != null) {
await completer.future;
}
final header = encrypted.firstTag('header')!;
final payloadElement = encrypted.firstTag('payload');
final keys = List<EncryptedKey>.empty(growable: true);
@ -649,118 +411,49 @@ abstract class OmemoManager extends XmppManagerBase {
final ourJid = getAttributes().getFullJID();
final sid = int.parse(header.attributes['sid']! as String);
// Ensure that if we receive a message from a device that we don't know about, we
// ensure that _deviceMap is up-to-date.
final devices = _deviceMap[fromJid] ?? <int>[];
if (!devices.contains(sid)) {
await getDeviceList(fromJid);
}
String? decrypted;
try {
decrypted = await _decryptMessage(
payloadElement != null ? base64.decode(payloadElement.innerText()) : null,
final om = await getOmemoManager();
final result = await om.onIncomingStanza(
OmemoIncomingStanza(
fromJid.toString(),
sid,
keys,
state.delayedDelivery?.timestamp.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch,
);
} catch (ex) {
logger.warning('Error occurred during message decryption: $ex');
keys,
payloadElement?.innerText(),
),
);
await _handlerExit(fromJid);
return state.copyWith(
other: {
...state.other,
'encryption_error': ex,
},
);
final children = stanza.children.where(
(child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns,
).toList();
final other = Map<String, dynamic>.from(state.other);
if (result.error != null) {
other['encryption_error'] = result.error;
}
final isAcked = await _isRatchetAcknowledged(fromJid.toString(), sid);
if (!isAcked) {
// Unacked ratchet decrypted this message
if (decrypted != null) {
// The message is not empty, i.e. contains content
logger.finest('Received non-empty OMEMO encrypted message for unacked ratchet. Acking with empty OMEMO message.');
await _ackRatchet(fromJid.toString(), sid);
await sendEmptyMessage(fromJid, calledFromCriticalSection: true);
if (result.payload != null) {
final envelope = XMLNode.fromString(result.payload!);
children.addAll(
envelope.firstTag('content')!.children,
);
final envelope = XMLNode.fromString(decrypted);
final children = stanza.children.where(
(child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns,
).toList()
..addAll(envelope.firstTag('content')!.children);
final other = Map<String, dynamic>.from(state.other);
if (!checkAffixElements(envelope, stanza.from!, ourJid)) {
other['encryption_error'] = InvalidAffixElementsException();
}
await _handlerExit(fromJid);
return state.copyWith(
encrypted: true,
stanza: Stanza(
to: stanza.to,
from: stanza.from,
id: stanza.id,
type: stanza.type,
children: children,
tag: stanza.tag,
attributes: Map<String, String>.from(stanza.attributes),
),
other: other,
);
} else {
logger.info('Received empty OMEMO message for unacked ratchet. Marking $fromJid:$sid as acked');
await _ackRatchet(fromJid.toString(), sid);
final ownId = await (await getSessionManager()).getDeviceId();
final kex = keys.any((key) => key.kex && key.rid == ownId);
if (kex) {
logger.info('Empty OMEMO message contained a kex. Answering.');
await sendEmptyMessage(fromJid, calledFromCriticalSection: true);
}
await _handlerExit(fromJid);
return state;
}
} else {
// The ratchet that decrypted the message was acked
if (decrypted != null) {
final envelope = XMLNode.fromString(decrypted);
final children = stanza.children.where(
(child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns,
).toList()
..addAll(envelope.firstTag('content')!.children);
final other = Map<String, dynamic>.from(state.other);
if (!checkAffixElements(envelope, stanza.from!, ourJid)) {
other['encryption_error'] = InvalidAffixElementsException();
}
await _handlerExit(fromJid);
return state.copyWith(
encrypted: true,
stanza: Stanza(
to: stanza.to,
from: stanza.from,
id: stanza.id,
type: stanza.type,
children: children,
tag: stanza.tag,
attributes: Map<String, String>.from(stanza.attributes),
),
other: other,
);
} else {
logger.info('Received empty OMEMO message on acked ratchet. Doing nothing');
await _handlerExit(fromJid);
return state;
if (!checkAffixElements(envelope, stanza.from!, ourJid)) {
other['encryption_error'] = InvalidAffixElementsException();
}
}
return state.copyWith(
encrypted: true,
stanza: Stanza(
to: stanza.to,
from: stanza.from,
id: stanza.id,
type: stanza.type,
children: children,
tag: stanza.tag,
attributes: Map<String, String>.from(stanza.attributes),
),
other: other,
);
}
/// Convenience function that attempts to retrieve the raw XML payload from the
@ -776,15 +469,12 @@ abstract class OmemoManager extends XmppManagerBase {
/// Retrieves the OMEMO device list from [jid].
Future<Result<OmemoError, List<int>>> getDeviceList(JID jid) async {
if (_deviceMap.containsKey(jid)) return Result(_deviceMap[jid]);
final itemsRaw = await _retrieveDeviceListPayload(jid);
if (itemsRaw.isType<OmemoError>()) return Result(UnknownOmemoError());
final ids = itemsRaw.get<XMLNode>().children
.map((child) => int.parse(child.attributes['id']! as String))
.toList();
_deviceMap[jid] = ids;
return Result(ids);
}

View File

@ -19,8 +19,7 @@ dependencies:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: ^0.1.5
omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: ^0.3.2
path: /home/alexander/Development/Personal/omemo_dart
random_string: ^2.3.1
saslprep: ^1.0.2
synchronized: ^3.0.0+2