Compare commits

...

2 Commits

Author SHA1 Message Date
c6552968d5 feat(core): Remove resumed from connection state change events 2023-05-23 15:56:38 +02:00
1d87c0ce95 feat(xep): Separate XEP-0030 and XEP-0115 support
Also, we now validate whatever we get in terms of disco#info
and capability hashes before caching.
2023-05-23 15:52:48 +02:00
13 changed files with 774 additions and 93 deletions

View File

@ -2,6 +2,10 @@
- **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.
- **BREAKING**: Added the `resumed` parameter to `StreamNegotiationsDoneEvent`. Use this to check if the current stream is new or resumed instead of using the `ConnectionStateChangedEvent`.
## 0.3.1

View File

@ -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';

View File

@ -15,7 +15,6 @@ import 'package:moxxmpp/src/managers/base.dart';
import 'package:moxxmpp/src/managers/data.dart';
import 'package:moxxmpp/src/managers/handlers.dart';
import 'package:moxxmpp/src/managers/namespaces.dart';
import 'package:moxxmpp/src/negotiators/namespaces.dart';
import 'package:moxxmpp/src/negotiators/negotiator.dart';
import 'package:moxxmpp/src/parser.dart';
import 'package:moxxmpp/src/presence.dart';
@ -28,7 +27,6 @@ import 'package:moxxmpp/src/stanza.dart';
import 'package:moxxmpp/src/stringxml.dart';
import 'package:moxxmpp/src/types/result.dart';
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
import 'package:moxxmpp/src/xeps/xep_0198/negotiator.dart';
import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
import 'package:moxxmpp/src/xeps/xep_0352.dart';
import 'package:synchronized/synchronized.dart';
@ -578,7 +576,12 @@ class XmppConnection {
// Tell consumers of the event stream that we're done with stream feature
// negotiations
await _sendEvent(StreamNegotiationsDoneEvent());
await _sendEvent(
StreamNegotiationsDoneEvent(
getManagerById<StreamManagementManager>(smManager)?.streamResumed ??
false,
),
);
}
/// Sets the connection state to [state] and triggers an event of type
@ -606,15 +609,10 @@ class XmppConnection {
_destroyConnectingTimer();
}
final sm =
_negotiationsHandler.getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
);
await _sendEvent(
ConnectionStateChangedEvent(
state,
oldState,
sm?.isResumed ?? false,
),
);
}

View File

@ -22,10 +22,9 @@ abstract class XmppEvent {}
/// Triggered when the connection state of the XmppConnection has
/// changed.
class ConnectionStateChangedEvent extends XmppEvent {
ConnectionStateChangedEvent(this.state, this.before, this.resumed);
ConnectionStateChangedEvent(this.state, this.before);
final XmppConnectionState before;
final XmppConnectionState state;
final bool resumed;
/// Indicates whether the connection state switched from a not connected state to a
/// connected state.
@ -257,4 +256,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;
}

View File

@ -0,0 +1,5 @@
extension ListItemCountExtension<T> on List<T> {
int count(bool Function(T) matches) {
return where(matches).length;
}
}

View File

@ -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.

View File

@ -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<Identity> _identities;
/// Map full JID to Capability hashes
final Map<String, CapabilityHashInfo> _capHashCache = {};
/// Map capability hash to the disco info
final Map<String, DiscoInfo> _capHashInfoCache = {};
/// Map full JID to Disco Info
final Map<DiscoCacheKey, DiscoInfo> _discoInfoCache = {};
@ -101,13 +94,7 @@ class DiscoManager extends XmppManagerBase {
@override
Future<void> 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<void> addCachedDiscoInfo(
MapEntry<DiscoCacheKey, DiscoInfo> 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<String> features) {
@ -155,39 +151,6 @@ class DiscoManager extends XmppManagerBase {
}
}
Future<void> _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<DiscoError>()) return;
await _cacheLock.synchronized(() async {
_capHashCache[from.toString()] = info;
_capHashInfoCache[info.ver] = result.get<DiscoInfo>();
});
}
/// 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<void> _exitDiscoInfoCriticalSection(
DiscoCacheKey key,
Result<DiscoError, DiscoInfo> result,
bool shouldCache,
) async {
await _cacheLock.synchronized(() async {
// Add to cache if it is a result
if (result.isType<DiscoInfo>()) {
if (result.isType<DiscoInfo>() && shouldCache) {
_discoInfoCache[key] = result.get<DiscoInfo>();
}
});
@ -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<Result<DiscoError, DiscoInfo>> 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>(entityCapabilitiesManager);
final ffuture = await _cacheLock
.synchronized<Future<Future<Result<DiscoError, DiscoInfo>>?>?>(
() 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<DiscoError, DiscoInfo>(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<DiscoError, DiscoInfo>(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;
}

View File

@ -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<String> 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<String> 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<String, String> _jidToCapHashCache = {};
/// Cache the mapping between capability hash string and the resulting disco#info.
final Map<String, DiscoInfo> _capHashCache = {};
/// A lock guarding access to the capability hash cache.
final Lock _cacheLock = Lock();
@override
Future<bool> isSupported() async => true;
@ -101,10 +111,10 @@ class EntityCapabilitiesManager extends XmppManagerBase {
/// the DiscoManager.
Future<String> getCapabilityHash() async {
_capabilityHash ??= await calculateCapabilityHash(
HashFunction.sha1,
getAttributes()
.getManagerById<DiscoManager>(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<DiscoInfo?> getCachedDiscoInfoFromJid(JID jid) async {
return _cacheLock.synchronized(() {
final capHash = _jidToCapHashCache[jid.toString()];
if (capHash == null) {
return null;
}
return _capHashCache[capHash];
});
}
@visibleForTesting
Future<void> 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>(discoManager)!;
final discoRequest = await dm.discoInfoQuery(
event.jid.toString(),
node: capabilityNode,
);
if (discoRequest.isType<DiscoError>()) {
return;
}
final discoInfo = discoRequest.get<DiscoInfo>();
final hashFunction = HashFunction.maybeFromName(hashFunctionName);
if (hashFunction == null) {
await dm.addCachedDiscoInfo(
MapEntry<DiscoCacheKey, DiscoInfo>(
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
// > <value/> 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<DataForm>.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<String>.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<void> 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<void> postRegisterCallback() async {
await super.postRegisterCallback();
getAttributes()
.getManagerById<DiscoManager>(discoManager)!
.registerInfoCallback(
.getManagerById<DiscoManager>(discoManager)
?.registerInfoCallback(
await _getNode(),
_onInfoQuery,
);
getAttributes()
.getManagerById<PresenceManager>(presenceManager)!
.registerPreSendCallback(
.getManagerById<PresenceManager>(presenceManager)
?.registerPreSendCallback(
_prePresenceSent,
);
}

View File

@ -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<bool> 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<String> 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;

View File

@ -22,6 +22,9 @@ class TestingManagerHolder {
final Map<String, XmppManagerBase> _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('<iq />');
}

View File

@ -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<DiscoError>(), false);
expect(tm.sentStanzas, 0);
});
});
}

View File

@ -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('''

View File

@ -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<Result<DiscoError, DiscoInfo>> 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,
);
});
});
}