moxxmpp/packages/moxxmpp/lib/src/xeps/xep_0115.dart

365 lines
11 KiB
Dart

import 'dart:async';
import 'dart:convert';
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/data.dart';
import 'package:moxxmpp/src/managers/handlers.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/stanza.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_0300.dart';
import 'package:synchronized/synchronized.dart';
/// 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,
) async {
final buffer = StringBuffer();
final identitiesSorted = info.identities.map(_identityString).toList();
// ignore: cascade_invocations
identitiesSorted.sort(ioctetSortComparator);
buffer.write('${identitiesSorted.join("<")}<');
final featuresSorted = List<String>.from(info.features)
..sort(ioctetSortComparator);
buffer.write('${featuresSorted.join("<")}<');
if (info.extendedInfo.isNotEmpty) {
final sortedExt = info.extendedInfo
..sort(
(a, b) => ioctetSortComparator(
a.getFieldByVar('FORM_TYPE')!.values.first,
b.getFieldByVar('FORM_TYPE')!.values.first,
),
);
for (final ext in sortedExt) {
buffer.write('${ext.getFieldByVar("FORM_TYPE")!.values.first}<');
final sortedFields = ext.fields
..sort(
(a, b) => ioctetSortComparator(
a.varAttr!,
b.varAttr!,
),
);
for (final field in sortedFields) {
if (field.varAttr == 'FORM_TYPE') continue;
buffer.write('${field.varAttr!}<');
final sortedValues = field.values..sort(ioctetSortComparator);
for (final value in sortedValues) {
buffer.write('$value<');
}
}
}
}
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
/// disco#info requests on the specified node with the information provided by
/// the DiscoManager.
/// NOTE: This manager requires that the DiscoManager is also registered.
class EntityCapabilitiesManager extends XmppManagerBase {
EntityCapabilitiesManager(this._capabilityHashBase)
: super(entityCapabilitiesManager);
/// The string that is both the node under which we advertise the disco info
/// and the base for the actual node on which we respond to disco#info requests.
final String _capabilityHashBase;
/// 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;
@override
List<String> getDiscoFeatures() => [
capsXmlns,
];
@override
List<StanzaHandler> getIncomingStanzaHandlers() => [
StanzaHandler(
stanzaTag: 'presence',
tagName: 'c',
tagXmlns: capsXmlns,
callback: onPresence,
priority: PresenceManager.presenceHandlerPriority + 1,
),
];
/// Computes, if required, the capability hash of the data provided by
/// the DiscoManager.
Future<String> getCapabilityHash() async {
_capabilityHash ??= await calculateCapabilityHash(
HashFunction.sha1,
getAttributes()
.getManagerById<DiscoManager>(discoManager)!
.getDiscoInfo(null),
);
return _capabilityHash!;
}
Future<String> _getNode() async {
final hash = await getCapabilityHash();
return '$_capabilityHashBase#$hash';
}
Future<DiscoInfo> _onInfoQuery() async {
return getAttributes()
.getManagerById<DiscoManager>(discoManager)!
.getDiscoInfo(await _getNode());
}
Future<List<XMLNode>> _prePresenceSent() async {
return [
XMLNode.xmlns(
tag: 'c',
xmlns: capsXmlns,
attributes: {
'hash': 'sha-1',
'node': _capabilityHashBase,
'ver': await getCapabilityHash(),
},
),
];
}
/// 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<StanzaHandlerData> onPresence(
Stanza stanza,
StanzaHandlerData state,
) async {
if (stanza.from == null) {
return state;
}
final from = JID.fromString(stanza.from!);
final c = stanza.firstTag('c', xmlns: capsXmlns)!;
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 state;
}
// Check if we know of the hash
final isCached =
await _cacheLock.synchronized(() => _capHashCache.containsKey(ver));
if (isCached) {
return state;
}
final dm = getAttributes().getManagerById<DiscoManager>(discoManager)!;
final discoRequest = await dm.discoInfoQuery(
from,
node: capabilityNode,
);
if (discoRequest.isType<DiscoError>()) {
return state;
}
final discoInfo = discoRequest.get<DiscoInfo>();
final hashFunction = HashFunction.maybeFromName(hashFunctionName);
if (hashFunction == null) {
await dm.addCachedDiscoInfo(
MapEntry<DiscoCacheKey, DiscoInfo>(
DiscoCacheKey(
from,
null,
),
discoInfo,
),
);
return state;
}
// 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 state;
}
}
// > 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 state;
}
}
// > 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 state;
}
}
// 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 state;
}
// 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[from.toString()] = ver;
_capHashCache[ver] = newDiscoInfo;
});
} else {
logger.warning(
'Capability hash mismatch from $from: Received "$ver", expected "$computedCapabilityHash".',
);
}
return state;
}
@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 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(
await _getNode(),
_onInfoQuery,
);
getAttributes()
.getManagerById<PresenceManager>(presenceManager)
?.registerPreSendCallback(
_prePresenceSent,
);
}
}