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.
This commit is contained in:
parent
da591a552d
commit
1d87c0ce95
@ -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
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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<StreamManagementManager>(smManager)?.streamResumed ??
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets the connection state to [state] and triggers an event of type
|
||||
|
@ -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;
|
||||
}
|
||||
|
5
packages/moxxmpp/lib/src/util/list.dart
Normal file
5
packages/moxxmpp/lib/src/util/list.dart
Normal file
@ -0,0 +1,5 @@
|
||||
extension ListItemCountExtension<T> on List<T> {
|
||||
int count(bool Function(T) matches) {
|
||||
return where(matches).length;
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 />');
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -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('''
|
||||
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user