diff --git a/packages/moxxmpp/CHANGELOG.md b/packages/moxxmpp/CHANGELOG.md index 6db9a04..d2cf385 100644 --- a/packages/moxxmpp/CHANGELOG.md +++ b/packages/moxxmpp/CHANGELOG.md @@ -2,6 +2,9 @@ - **BREAKING**: Remove `lastResource` from `XmppConnection`'s `connect` method. Instead, set the `StreamManagementNegotiator`'s `resource` attribute instead. Since the resource can only really be restored by stream management, this is no issue. - **BREAKING**: Changed order of parameters of `CryptographicHashManager.hashFromData` +- **BREAKING**: Removed support for XEP-0414, as the (supported) hash computations are already implemented by `CryptographicHashManager.hashFromData`. +- The `DiscoManager` now only handled entity capabilities if a `EntityCapabilityManager` is registered. +- The `EntityCapabilityManager` now verifies and validates its data before caching. ## 0.3.1 diff --git a/packages/moxxmpp/lib/moxxmpp.dart b/packages/moxxmpp/lib/moxxmpp.dart index 7a93596..e44a60f 100644 --- a/packages/moxxmpp/lib/moxxmpp.dart +++ b/packages/moxxmpp/lib/moxxmpp.dart @@ -85,7 +85,6 @@ export 'package:moxxmpp/src/xeps/xep_0388/errors.dart'; export 'package:moxxmpp/src/xeps/xep_0388/negotiators.dart'; export 'package:moxxmpp/src/xeps/xep_0388/user_agent.dart'; export 'package:moxxmpp/src/xeps/xep_0388/xep_0388.dart'; -export 'package:moxxmpp/src/xeps/xep_0414.dart'; export 'package:moxxmpp/src/xeps/xep_0424.dart'; export 'package:moxxmpp/src/xeps/xep_0444.dart'; export 'package:moxxmpp/src/xeps/xep_0446.dart'; diff --git a/packages/moxxmpp/lib/src/connection.dart b/packages/moxxmpp/lib/src/connection.dart index 03899ed..3d0435b 100644 --- a/packages/moxxmpp/lib/src/connection.dart +++ b/packages/moxxmpp/lib/src/connection.dart @@ -578,7 +578,12 @@ class XmppConnection { // Tell consumers of the event stream that we're done with stream feature // negotiations - await _sendEvent(StreamNegotiationsDoneEvent()); + await _sendEvent( + StreamNegotiationsDoneEvent( + getManagerById(smManager)?.streamResumed ?? + false, + ), + ); } /// Sets the connection state to [state] and triggers an event of type diff --git a/packages/moxxmpp/lib/src/events.dart b/packages/moxxmpp/lib/src/events.dart index 44bbc87..0ab0929 100644 --- a/packages/moxxmpp/lib/src/events.dart +++ b/packages/moxxmpp/lib/src/events.dart @@ -257,4 +257,10 @@ class NonRecoverableErrorEvent extends XmppEvent { } /// Triggered when the stream negotiations are done. -class StreamNegotiationsDoneEvent extends XmppEvent {} +class StreamNegotiationsDoneEvent extends XmppEvent { + StreamNegotiationsDoneEvent(this.resumed); + + /// Flag indicating whether we resumed a previous stream (true) or are in a completely + /// new stream (false). + final bool resumed; +} diff --git a/packages/moxxmpp/lib/src/util/list.dart b/packages/moxxmpp/lib/src/util/list.dart new file mode 100644 index 0000000..35c9e9f --- /dev/null +++ b/packages/moxxmpp/lib/src/util/list.dart @@ -0,0 +1,5 @@ +extension ListItemCountExtension on List { + int count(bool Function(T) matches) { + return where(matches).length; + } +} diff --git a/packages/moxxmpp/lib/src/xeps/xep_0030/cache.dart b/packages/moxxmpp/lib/src/xeps/xep_0030/cache.dart index 5e4001e..7a97896 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0030/cache.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0030/cache.dart @@ -6,6 +6,7 @@ class DiscoCacheKey { const DiscoCacheKey(this.jid, this.node); /// The JID we're requesting disco data from. + // TODO(Unknown): Replace with JID final String jid; /// Optionally the node we are requesting from. diff --git a/packages/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart b/packages/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart index d8e682d..84eca04 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:meta/meta.dart'; -import 'package:moxxmpp/src/connection.dart'; import 'package:moxxmpp/src/events.dart'; import 'package:moxxmpp/src/jid.dart'; import 'package:moxxmpp/src/managers/base.dart'; @@ -41,12 +40,6 @@ class DiscoManager extends XmppManagerBase { /// Disco identities that we advertise final List _identities; - /// Map full JID to Capability hashes - final Map _capHashCache = {}; - - /// Map capability hash to the disco info - final Map _capHashInfoCache = {}; - /// Map full JID to Disco Info final Map _discoInfoCache = {}; @@ -101,13 +94,7 @@ class DiscoManager extends XmppManagerBase { @override Future onXmppEvent(XmppEvent event) async { - if (event is PresenceReceivedEvent) { - await _onPresence(event.jid, event.presence); - } else if (event is ConnectionStateChangedEvent) { - // TODO(Unknown): This handling is stupid. We should have an event that is - // triggered when we cannot guarantee that everything is as - // it was before. - if (event.state != XmppConnectionState.connected) return; + if (event is StreamNegotiationsDoneEvent) { if (event.resumed) return; // Cancel all waiting requests @@ -135,6 +122,15 @@ class DiscoManager extends XmppManagerBase { _discoItemsCallbacks[node] = callback; } + /// Add a [DiscoCacheKey]-[DiscoInfo] pair [discoInfoEntry] to the internal cache. + Future addCachedDiscoInfo( + MapEntry discoInfoEntry, + ) async { + await _cacheLock.synchronized(() { + _discoInfoCache[discoInfoEntry.key] = discoInfoEntry.value; + }); + } + /// Adds a list of features to the possible disco info response. /// This function only adds features that are not already present in the disco features. void addFeatures(List features) { @@ -155,39 +151,6 @@ class DiscoManager extends XmppManagerBase { } } - Future _onPresence(JID from, Stanza presence) async { - final c = presence.firstTag('c', xmlns: capsXmlns); - if (c == null) return; - - final info = CapabilityHashInfo( - c.attributes['ver']! as String, - c.attributes['node']! as String, - c.attributes['hash']! as String, - ); - - // Check if we already know of that cache - var cached = false; - await _cacheLock.synchronized(() async { - if (!_capHashCache.containsKey(info.ver)) { - cached = true; - } - }); - if (cached) return; - - // Request the cap hash - logger.finest( - "Received capability hash we don't know about. Requesting it...", - ); - final result = - await discoInfoQuery(from.toString(), node: '${info.node}#${info.ver}'); - if (result.isType()) return; - - await _cacheLock.synchronized(() async { - _capHashCache[from.toString()] = info; - _capHashInfoCache[info.ver] = result.get(); - }); - } - /// Returns the [DiscoInfo] object that would be used as the response to a disco#info /// query against our bare JID with no node. The results node attribute is set /// to [node]. @@ -269,10 +232,11 @@ class DiscoManager extends XmppManagerBase { Future _exitDiscoInfoCriticalSection( DiscoCacheKey key, Result result, + bool shouldCache, ) async { await _cacheLock.synchronized(() async { // Add to cache if it is a result - if (result.isType()) { + if (result.isType() && shouldCache) { _discoInfoCache[key] = result.get(); } }); @@ -280,14 +244,24 @@ class DiscoManager extends XmppManagerBase { await _discoInfoTracker.resolve(key, result); } - /// Sends a disco info query to the (full) jid [entity], optionally with node=[node]. + /// Send a disco#info query to [entity]. If [node] is specified, then the disco#info + /// request will be directed against that one node of [entity]. + /// + /// [shouldEncrypt] indicates to possible end-to-end encryption implementations whether + /// the request should be encrypted (true) or not (false). + /// + /// [shouldCache] indicates whether the successful result of the disco#info query + /// should be cached (true) or not(false). Future> discoInfoQuery( String entity, { String? node, bool shouldEncrypt = true, + bool shouldCache = true, }) async { - final cacheKey = DiscoCacheKey(entity, node); DiscoInfo? info; + final cacheKey = DiscoCacheKey(entity, node); + final ecm = getAttributes() + .getManagerById(entityCapabilitiesManager); final ffuture = await _cacheLock .synchronized>?>?>( () async { @@ -296,6 +270,14 @@ class DiscoManager extends XmppManagerBase { info = _discoInfoCache[cacheKey]; return null; } else { + // Check if we know entity capabilities + if (ecm != null && node == null) { + info = await ecm.getCachedDiscoInfoFromJid(JID.fromString(entity)); + if (info != null) { + return null; + } + } + return _discoInfoTracker.waitFor(cacheKey); } }); @@ -316,14 +298,14 @@ class DiscoManager extends XmppManagerBase { final query = stanza.firstTag('query'); if (query == null) { final result = Result(InvalidResponseDiscoError()); - await _exitDiscoInfoCriticalSection(cacheKey, result); + await _exitDiscoInfoCriticalSection(cacheKey, result, shouldCache); return result; } if (stanza.attributes['type'] == 'error') { //final error = stanza.firstTag('error'); final result = Result(ErrorResponseDiscoError()); - await _exitDiscoInfoCriticalSection(cacheKey, result); + await _exitDiscoInfoCriticalSection(cacheKey, result, shouldCache); return result; } @@ -333,7 +315,7 @@ class DiscoManager extends XmppManagerBase { JID.fromString(entity), ), ); - await _exitDiscoInfoCriticalSection(cacheKey, result); + await _exitDiscoInfoCriticalSection(cacheKey, result, shouldCache); return result; } diff --git a/packages/moxxmpp/lib/src/xeps/xep_0115.dart b/packages/moxxmpp/lib/src/xeps/xep_0115.dart index cbeaf8d..f528439 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0115.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0115.dart @@ -1,37 +1,35 @@ +import 'dart:async'; import 'dart:convert'; -import 'package:cryptography/cryptography.dart'; import 'package:meta/meta.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; import 'package:moxxmpp/src/managers/base.dart'; import 'package:moxxmpp/src/managers/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/presence.dart'; import 'package:moxxmpp/src/rfcs/rfc_4790.dart'; import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/util/list.dart'; +import 'package:moxxmpp/src/xeps/xep_0004.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/cache.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; -import 'package:moxxmpp/src/xeps/xep_0414.dart'; +import 'package:moxxmpp/src/xeps/xep_0300.dart'; +import 'package:synchronized/synchronized.dart'; -@immutable -class CapabilityHashInfo { - const CapabilityHashInfo(this.ver, this.node, this.hash); - final String ver; - final String node; - final String hash; -} +/// Given an identity [i], compute the string according to XEP-0115 § 5.1 step 2. +String _identityString(Identity i) => + '${i.category}/${i.type}/${i.lang ?? ""}/${i.name ?? ""}'; /// Calculates the Entitiy Capability hash according to XEP-0115 based on the /// disco information. Future calculateCapabilityHash( + HashFunction algorithm, DiscoInfo info, - HashAlgorithm algorithm, ) async { final buffer = StringBuffer(); - final identitiesSorted = info.identities - .map( - (Identity i) => - '${i.category}/${i.type}/${i.lang ?? ""}/${i.name ?? ""}', - ) - .toList(); + final identitiesSorted = info.identities.map(_identityString).toList(); // ignore: cascade_invocations identitiesSorted.sort(ioctetSortComparator); buffer.write('${identitiesSorted.join("<")}<'); @@ -72,8 +70,11 @@ Future calculateCapabilityHash( } } - return base64 - .encode((await algorithm.hash(utf8.encode(buffer.toString()))).bytes); + final rawHash = await CryptographicHashManager.hashFromData( + algorithm, + utf8.encode(buffer.toString()), + ); + return base64.encode(rawHash); } /// A manager implementing the advertising of XEP-0115. It responds to the @@ -91,6 +92,15 @@ class EntityCapabilitiesManager extends XmppManagerBase { /// The cached capability hash. String? _capabilityHash; + /// Cache the mapping between the full JID and the capability hash string. + final Map _jidToCapHashCache = {}; + + /// Cache the mapping between capability hash string and the resulting disco#info. + final Map _capHashCache = {}; + + /// A lock guarding access to the capability hash cache. + final Lock _cacheLock = Lock(); + @override Future isSupported() async => true; @@ -101,10 +111,10 @@ class EntityCapabilitiesManager extends XmppManagerBase { /// the DiscoManager. Future getCapabilityHash() async { _capabilityHash ??= await calculateCapabilityHash( + HashFunction.sha1, getAttributes() .getManagerById(discoManager)! .getDiscoInfo(null), - getHashByName('sha-1')!, ); return _capabilityHash!; @@ -135,20 +145,198 @@ class EntityCapabilitiesManager extends XmppManagerBase { ]; } + /// If we know of [jid]'s capability hash, look up the [DiscoInfo] associated with + /// that capability hash. If we don't know of [jid]'s capability hash, return null. + Future getCachedDiscoInfoFromJid(JID jid) async { + return _cacheLock.synchronized(() { + final capHash = _jidToCapHashCache[jid.toString()]; + if (capHash == null) { + return null; + } + + return _capHashCache[capHash]; + }); + } + + @visibleForTesting + Future onPresence(PresenceReceivedEvent event) async { + final c = event.presence.firstTag('c', xmlns: capsXmlns); + if (c == null) { + return; + } + + final hashFunctionName = c.attributes['hash'] as String?; + final capabilityNode = c.attributes['node'] as String?; + final ver = c.attributes['ver'] as String?; + if (hashFunctionName == null || capabilityNode == null || ver == null) { + return; + } + + // Check if we know of the hash + final isCached = + await _cacheLock.synchronized(() => _capHashCache.containsKey(ver)); + if (isCached) { + return; + } + + final dm = getAttributes().getManagerById(discoManager)!; + final discoRequest = await dm.discoInfoQuery( + event.jid.toString(), + node: capabilityNode, + ); + if (discoRequest.isType()) { + return; + } + final discoInfo = discoRequest.get(); + + final hashFunction = HashFunction.maybeFromName(hashFunctionName); + if (hashFunction == null) { + await dm.addCachedDiscoInfo( + MapEntry( + DiscoCacheKey( + event.jid.toString(), + null, + ), + discoInfo, + ), + ); + return; + } + + // Validate the disco#info result according to XEP-0115 § 5.4 + // > If the response includes more than one service discovery identity with the + // > same category/type/lang/name, consider the entire response to be ill-formed. + for (final identity in discoInfo.identities) { + final identityString = _identityString(identity); + if (discoInfo.identities + .count((i) => _identityString(i) == identityString) > + 1) { + logger.warning( + 'Malformed disco#info response: More than one equal identity', + ); + return; + } + } + + // > If the response includes more than one service discovery feature with the same + // > XML character data, consider the entire response to be ill-formed. + for (final feature in discoInfo.features) { + if (discoInfo.features.count((f) => f == feature) > 1) { + logger.warning( + 'Malformed disco#info response: More than one equal feature', + ); + return; + } + } + + // > If the response includes more than one extended service discovery information + // > form with the same FORM_TYPE or the FORM_TYPE field contains more than one + // > element with different XML character data, consider the entire response + // > to be ill-formed. + // > + // > If the response includes an extended service discovery information form where + // > the FORM_TYPE field is not of type "hidden" or the form does not include a + // > FORM_TYPE field, ignore the form but continue processing. + final validExtendedInfoItems = List.empty(growable: true); + for (final extendedInfo in discoInfo.extendedInfo) { + final formType = extendedInfo.getFieldByVar('FORM_TYPE'); + + // Form should have a FORM_TYPE field + if (formType == null) { + logger.fine('Skipping extended info as it contains no FORM_TYPE field'); + continue; + } + + // Check if we only have one unique FORM_TYPE value + if (formType.values.length > 1) { + if (Set.from(formType.values).length > 1) { + logger.warning( + 'Malformed disco#info response: Extended Info FORM_TYPE contains more than one value(s) of different value.', + ); + return; + } + } + + // Check if we have more than one extended info forms of the same type + final sameFormTypeFormsNumber = discoInfo.extendedInfo.count((form) { + final type = form.getFieldByVar('FORM_TYPE')?.values.first; + if (type == null) return false; + + return type == formType.values.first; + }); + if (sameFormTypeFormsNumber > 1) { + logger.warning( + 'Malformed disco#info response: More than one Extended Disco Info forms with the same FORM_TYPE value', + ); + return; + } + + // Check if the field type is hidden + if (formType.type != 'hidden') { + logger.fine( + 'Skipping extended info as the FORM_TYPE field is not of type "hidden"', + ); + continue; + } + + validExtendedInfoItems.add(extendedInfo); + } + + // Validate the capability hash + final newDiscoInfo = DiscoInfo( + discoInfo.features, + discoInfo.identities, + validExtendedInfoItems, + discoInfo.node, + discoInfo.jid, + ); + final computedCapabilityHash = await calculateCapabilityHash( + hashFunction, + newDiscoInfo, + ); + + if (computedCapabilityHash == ver) { + await _cacheLock.synchronized(() { + _jidToCapHashCache[event.jid.toString()] = ver; + _capHashCache[ver] = newDiscoInfo; + }); + } else { + logger.warning( + 'Capability hash mismatch from ${event.jid}: Received "$ver", expected "$computedCapabilityHash".', + ); + } + } + + @visibleForTesting + void injectIntoCache(JID jid, String ver, DiscoInfo info) { + _jidToCapHashCache[jid.toString()] = ver; + _capHashCache[ver] = info; + } + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is PresenceReceivedEvent) { + unawaited(onPresence(event)); + } else if (event is StreamNegotiationsDoneEvent) { + // Clear the JID to cap. hash mapping. + await _cacheLock.synchronized(_jidToCapHashCache.clear); + } + } + @override Future postRegisterCallback() async { await super.postRegisterCallback(); getAttributes() - .getManagerById(discoManager)! - .registerInfoCallback( + .getManagerById(discoManager) + ?.registerInfoCallback( await _getNode(), _onInfoQuery, ); getAttributes() - .getManagerById(presenceManager)! - .registerPreSendCallback( + .getManagerById(presenceManager) + ?.registerPreSendCallback( _prePresenceSent, ); } diff --git a/packages/moxxmpp/lib/src/xeps/xep_0300.dart b/packages/moxxmpp/lib/src/xeps/xep_0300.dart index f1d5f1c..16e8120 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0300.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0300.dart @@ -5,6 +5,7 @@ import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/stringxml.dart'; /// Hash names +const _hashSha1 = 'sha-1'; const _hashSha256 = 'sha-256'; const _hashSha512 = 'sha-512'; const _hashSha3256 = 'sha3-256'; @@ -23,6 +24,9 @@ XMLNode constructHashElement(HashFunction hash, String value) { } enum HashFunction { + /// SHA-1 + sha1, + /// SHA-256 sha256, @@ -46,6 +50,8 @@ enum HashFunction { /// - XEP-0300 factory HashFunction.fromName(String name) { switch (name) { + case _hashSha1: + return HashFunction.sha1; case _hashSha256: return HashFunction.sha256; case _hashSha512: @@ -63,9 +69,33 @@ enum HashFunction { throw Exception(); } + /// Like [HashFunction.fromName], but returns null if the hash function is unknown + static HashFunction? maybeFromName(String name) { + switch (name) { + case _hashSha1: + return HashFunction.sha1; + case _hashSha256: + return HashFunction.sha256; + case _hashSha512: + return HashFunction.sha512; + case _hashSha3256: + return HashFunction.sha3_256; + case _hashSha3512: + return HashFunction.sha3_512; + case _hashBlake2b256: + return HashFunction.blake2b256; + case _hashBlake2b512: + return HashFunction.blake2b512; + } + + return null; + } + /// Return the hash function's name according to IANA's hash name register or XEP-0300. String toName() { switch (this) { + case HashFunction.sha1: + return _hashSha1; case HashFunction.sha256: return _hashSha256; case HashFunction.sha512: @@ -88,6 +118,9 @@ class CryptographicHashManager extends XmppManagerBase { @override Future isSupported() async => true; + /// NOTE: We intentionally do not advertise support for SHA-1, as it is marked as + /// MUST NOT. Sha-1 support is only for providing a wrapper over its hash + /// function, for example for XEP-0115. @override List getDiscoFeatures() => [ '$hashFunctionNameBaseXmlns:$_hashSha256', @@ -107,6 +140,9 @@ class CryptographicHashManager extends XmppManagerBase { // TODO(PapaTutuWawa): Implement the others as well HashAlgorithm algo; switch (function) { + case HashFunction.sha1: + algo = Sha1(); + break; case HashFunction.sha256: algo = Sha256(); break; diff --git a/packages/moxxmpp/test/helpers/manager.dart b/packages/moxxmpp/test/helpers/manager.dart index 959d70a..6813271 100644 --- a/packages/moxxmpp/test/helpers/manager.dart +++ b/packages/moxxmpp/test/helpers/manager.dart @@ -22,6 +22,9 @@ class TestingManagerHolder { final Map _managers = {}; + // The amount of stanzas sent + int sentStanzas = 0; + static final JID jid = JID.fromString('testuser@example.org/abc123'); static final ConnectionSettings settings = ConnectionSettings( jid: jid, @@ -36,6 +39,7 @@ class TestingManagerHolder { bool encrypted = false, bool forceEncryption = false, }) async { + sentStanzas++; return XMLNode.fromString(''); } diff --git a/packages/moxxmpp/test/xeps/xep_0030_test.dart b/packages/moxxmpp/test/xeps/xep_0030_test.dart index 57f80e2..521dcb8 100644 --- a/packages/moxxmpp/test/xeps/xep_0030_test.dart +++ b/packages/moxxmpp/test/xeps/xep_0030_test.dart @@ -3,6 +3,7 @@ import 'package:moxxmpp/src/xeps/xep_0030/cache.dart'; import 'package:test/test.dart'; import '../helpers/logging.dart'; +import '../helpers/manager.dart'; import '../helpers/xmpp.dart'; void main() { @@ -114,4 +115,34 @@ void main() { expect(await result1, await result2); expect(disco.infoTracker.hasTasksRunning(), false); }); + + group('Interactions with Entity Capabilities', () { + test('Do not query when the capability hash is cached', () async { + final tm = TestingManagerHolder(); + final ecm = EntityCapabilitiesManager(''); + final dm = DiscoManager([]); + + await tm.register(dm); + await tm.register(ecm); + + // Inject a capability hash into the cache + final aliceJid = JID.fromString('alice@example.org/abc123'); + ecm.injectIntoCache( + aliceJid, + 'AAAAAAAAAAAAA', + DiscoInfo( + const [], + const [], + const [], + '', + aliceJid, + ), + ); + + // Query Alice's device + final result = await dm.discoInfoQuery(aliceJid.toString()); + expect(result.isType(), false); + expect(tm.sentStanzas, 0); + }); + }); } diff --git a/packages/moxxmpp/test/xeps/xep_0060_test.dart b/packages/moxxmpp/test/xeps/xep_0060_test.dart index 7649ce4..ee58272 100644 --- a/packages/moxxmpp/test/xeps/xep_0060_test.dart +++ b/packages/moxxmpp/test/xeps/xep_0060_test.dart @@ -15,6 +15,7 @@ class StubbedDiscoManager extends DiscoManager { String entity, { String? node, bool shouldEncrypt = true, + bool shouldCache = true, }) async { final result = DiscoInfo.fromQuery( XMLNode.fromString(''' diff --git a/packages/moxxmpp/test/xeps/xep_0115_test.dart b/packages/moxxmpp/test/xeps/xep_0115_test.dart index d9eff2c..f28aecc 100644 --- a/packages/moxxmpp/test/xeps/xep_0115_test.dart +++ b/packages/moxxmpp/test/xeps/xep_0115_test.dart @@ -1,8 +1,151 @@ -import 'package:cryptography/cryptography.dart'; import 'package:moxxmpp/moxxmpp.dart'; import 'package:test/test.dart'; +import '../helpers/logging.dart'; +import '../helpers/manager.dart'; + +class StubbedDiscoManager extends DiscoManager { + StubbedDiscoManager() : super([]); + + /// Inject an identity twice. + bool multipleEqualIdentities = false; + + /// Inject a disco feature twice. + bool multipleEqualFeatures = false; + + /// Inject the same (correct) extended info form twice. + bool multipleExtendedFormsWithSameType = false; + + /// No FORM_TYPE + bool invalidExtension1 = false; + + /// FORM_TYPE is not hidden + bool invalidExtension2 = false; + + /// FORM_TYPE has more than one different values + bool invalidExtension3 = false; + + @override + Future> discoInfoQuery( + String entity, { + String? node, + bool shouldEncrypt = true, + bool shouldCache = true, + }) async { + return Result( + DiscoInfo( + [ + 'http://jabber.org/protocol/caps', + 'http://jabber.org/protocol/disco#info', + 'http://jabber.org/protocol/disco#items', + 'http://jabber.org/protocol/muc', + if (multipleEqualFeatures) 'http://jabber.org/protocol/muc', + ], + [ + const Identity( + category: 'client', + type: 'pc', + name: 'Exodus 0.9.1', + ), + if (multipleEqualIdentities) + const Identity( + category: 'client', + type: 'pc', + name: 'Exodus 0.9.1', + ), + ], + [ + if (multipleExtendedFormsWithSameType) + const DataForm( + type: 'result', + instructions: [], + fields: [ + DataFormField( + options: [], + values: [ + 'http://jabber.org/network/serverinfo', + ], + isRequired: false, + varAttr: 'FORM_TYPE', + type: 'hidden', + ) + ], + reported: [], + items: [], + ), + if (multipleExtendedFormsWithSameType) + const DataForm( + type: 'result', + instructions: [], + fields: [ + DataFormField( + options: [], + values: [ + 'http://jabber.org/network/serverinfo', + ], + isRequired: false, + varAttr: 'FORM_TYPE', + type: 'hidden', + ), + ], + reported: [], + items: [], + ), + if (invalidExtension1) + const DataForm( + type: 'result', + instructions: [], + fields: [], + reported: [], + items: [], + ), + if (invalidExtension2) + const DataForm( + type: 'result', + instructions: [], + fields: [ + DataFormField( + options: [], + values: [ + 'http://jabber.org/network/serverinfo', + ], + isRequired: false, + varAttr: 'FORM_TYPE', + ), + ], + reported: [], + items: [], + ), + if (invalidExtension3) + const DataForm( + type: 'result', + instructions: [], + fields: [ + DataFormField( + options: [], + values: [ + 'http://jabber.org/network/serverinfo', + 'http://jabber.org/network/better-serverinfo', + ], + isRequired: false, + varAttr: 'FORM_TYPE', + type: 'hidden', + ), + ], + reported: [], + items: [], + ), + ], + null, + JID.fromString('some@user.local/test'), + ), + ); + } +} + void main() { + initLogger(); + test('Test XEP example', () async { final data = DiscoInfo( const [ @@ -23,7 +166,7 @@ void main() { JID.fromString('some@user.local/test'), ); - final hash = await calculateCapabilityHash(data, Sha1()); + final hash = await calculateCapabilityHash(HashFunction.sha1, data); expect(hash, 'QgayPKawpkPSDYmwT/WM94uAlu0='); }); @@ -56,7 +199,7 @@ void main() { JID.fromString('some@user.local/test'), ); - final hash = await calculateCapabilityHash(data, Sha1()); + final hash = await calculateCapabilityHash(HashFunction.sha1, data); expect(hash, 'q07IKJEyjvHSyhy//CH0CxmKi8w='); }); @@ -114,7 +257,7 @@ void main() { ] ); - final hash = await calculateCapabilityHash(data, Sha1()); + final hash = await calculateCapabilityHash(HashFunction.sha1, data); expect(hash, "T7fOZrtBnV8sDA2fFTS59vyOyUs="); */ }); @@ -165,7 +308,291 @@ void main() { JID.fromString('user@server.local/test'), ); - final hash = await calculateCapabilityHash(data, Sha1()); + final hash = await calculateCapabilityHash(HashFunction.sha1, data); expect(hash, 'zcIke+Rk13ah4d1pwDG7bEZsVwA='); }); + + group('Receiving a capability hash', () { + final aliceJid = JID.fromString('alice@example.org/abc123'); + + test('Caching a correct capability hash', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register(StubbedDiscoManager()); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + expect( + await manager.getCachedDiscoInfoFromJid(aliceJid) != null, + true, + ); + }); + + test('Not caching an incorrect capability hash string', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register(StubbedDiscoManager()); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94AAAAA=', + }, + ), + ], + ), + ), + ); + + expect( + await manager.getCachedDiscoInfoFromJid(aliceJid), + null, + ); + }); + + test('Not caching ill-formed identities', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register( + StubbedDiscoManager()..multipleEqualIdentities = true, + ); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + expect( + await manager.getCachedDiscoInfoFromJid(aliceJid), + null, + ); + }); + + test('Not caching ill-formed features', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register( + StubbedDiscoManager()..multipleEqualFeatures = true, + ); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + expect( + await manager.getCachedDiscoInfoFromJid(aliceJid), + null, + ); + }); + + test('Not caching multiple forms with equal FORM_TYPE', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register( + StubbedDiscoManager()..multipleExtendedFormsWithSameType = true, + ); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + expect( + await manager.getCachedDiscoInfoFromJid(aliceJid), + null, + ); + }); + + test('Caching without invalid form (no FORM_TYPE)', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register( + StubbedDiscoManager()..invalidExtension1 = true, + ); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid); + expect( + cachedItem != null, + true, + ); + expect(cachedItem!.extendedInfo.isEmpty, true); + }); + + test('Caching without invalid form (FORM_TYPE not hidden)', () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register( + StubbedDiscoManager()..invalidExtension2 = true, + ); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid); + expect( + cachedItem != null, + true, + ); + expect(cachedItem!.extendedInfo.isEmpty, true); + }); + + test("Not caching as FORM_TYPE's values are distinct", () async { + final tm = TestingManagerHolder(); + final manager = EntityCapabilitiesManager(''); + + await tm.register( + StubbedDiscoManager()..invalidExtension3 = true, + ); + await tm.register(manager); + + await manager.onPresence( + PresenceReceivedEvent( + aliceJid, + Stanza.presence( + from: aliceJid.toString(), + children: [ + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://example.org/client', + 'ver': 'QgayPKawpkPSDYmwT/WM94uAlu0=', + }, + ), + ], + ), + ), + ); + + expect( + await manager.getCachedDiscoInfoFromJid(aliceJid), + null, + ); + }); + }); }