diff --git a/packages/moxxmpp/lib/moxxmpp.dart b/packages/moxxmpp/lib/moxxmpp.dart index 6c321a7..77322d6 100644 --- a/packages/moxxmpp/lib/moxxmpp.dart +++ b/packages/moxxmpp/lib/moxxmpp.dart @@ -39,6 +39,7 @@ export 'package:moxxmpp/src/stanza.dart'; export 'package:moxxmpp/src/stringxml.dart'; export 'package:moxxmpp/src/types/result.dart'; export 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart'; +export 'package:moxxmpp/src/xeps/staging/fast.dart'; export 'package:moxxmpp/src/xeps/staging/file_upload_notification.dart'; export 'package:moxxmpp/src/xeps/xep_0004.dart'; export 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; diff --git a/packages/moxxmpp/lib/src/connection.dart b/packages/moxxmpp/lib/src/connection.dart index c092c57..cc4654e 100644 --- a/packages/moxxmpp/lib/src/connection.dart +++ b/packages/moxxmpp/lib/src/connection.dart @@ -314,13 +314,8 @@ class XmppConnection { /// A [PresenceManager] is required, so have a wrapper for getting it. /// Returns the registered [PresenceManager]. - PresenceManager getPresenceManager() { - assert( - _xmppManagers.containsKey(presenceManager), - 'A PresenceManager is mandatory', - ); - - return getManagerById(presenceManager)!; + PresenceManager? getPresenceManager() { + return getManagerById(presenceManager); } /// A [DiscoManager] is required so, have a wrapper for getting it. @@ -1030,6 +1025,9 @@ class XmppConnection { for (final manager in _xmppManagers.values) { await manager.onXmppEvent(event); } + for (final negotiator in _featureNegotiators.values) { + await negotiator.onXmppEvent(event); + } _eventStreamController.add(event); } @@ -1068,7 +1066,7 @@ class XmppConnection { await _reconnectionPolicy.setShouldReconnect(false); if (triggeredByUser) { - getPresenceManager().sendUnavailablePresence(); + getPresenceManager()?.sendUnavailablePresence(); } _socket.prepareDisconnect(); @@ -1136,6 +1134,8 @@ class XmppConnection { if (lastResource != null) { setResource(lastResource, triggerEvent: false); + } else { + setResource('', triggerEvent: false); } _enableReconnectOnSuccess = enableReconnectOnSuccess; diff --git a/packages/moxxmpp/lib/src/namespaces.dart b/packages/moxxmpp/lib/src/namespaces.dart index 38cf744..86b69b3 100644 --- a/packages/moxxmpp/lib/src/namespaces.dart +++ b/packages/moxxmpp/lib/src/namespaces.dart @@ -160,3 +160,6 @@ const fallbackXmlns = 'urn:xmpp:feature-fallback:0'; // ??? const urlDataXmlns = 'http://jabber.org/protocol/url-data'; + +// XEP-XXXX +const fastXmlns = 'urn:xmpp:fast:0'; diff --git a/packages/moxxmpp/lib/src/negotiators/namespaces.dart b/packages/moxxmpp/lib/src/negotiators/namespaces.dart index 755835d..a62dd44 100644 --- a/packages/moxxmpp/lib/src/negotiators/namespaces.dart +++ b/packages/moxxmpp/lib/src/negotiators/namespaces.dart @@ -9,3 +9,4 @@ const streamManagementNegotiator = 'im.moxxmpp.xeps.sm'; const startTlsNegotiator = 'im.moxxmpp.core.starttls'; const sasl2Negotiator = 'org.moxxmpp.sasl.sasl2'; const bind2Negotiator = 'org.moxxmpp.bind2'; +const saslFASTNegotiator = 'org.moxxmpp.sasl.fast'; diff --git a/packages/moxxmpp/lib/src/negotiators/negotiator.dart b/packages/moxxmpp/lib/src/negotiators/negotiator.dart index 534dd28..98c0d4f 100644 --- a/packages/moxxmpp/lib/src/negotiators/negotiator.dart +++ b/packages/moxxmpp/lib/src/negotiators/negotiator.dart @@ -124,6 +124,9 @@ abstract class XmppFeatureNegotiatorBase { null; } + /// Called when an event is triggered in the [XmppConnection]. + Future onXmppEvent(XmppEvent event) async {} + /// Called with the currently received nonza [nonza] when the negotiator is active. /// If the negotiator is just elected to be the next one, then [nonza] is equal to /// the nonza. diff --git a/packages/moxxmpp/lib/src/negotiators/sasl/plain.dart b/packages/moxxmpp/lib/src/negotiators/sasl/plain.dart index d3e02c2..7f3acb9 100644 --- a/packages/moxxmpp/lib/src/negotiators/sasl/plain.dart +++ b/packages/moxxmpp/lib/src/negotiators/sasl/plain.dart @@ -98,6 +98,9 @@ class SaslPlainNegotiator extends Sasl2AuthenticationNegotiator { return const Result(true); } + @override + Future onSasl2Failure(XMLNode response) async {} + @override Future> onSasl2FeaturesReceived(XMLNode sasl2Features) async { return []; diff --git a/packages/moxxmpp/lib/src/negotiators/sasl/scram.dart b/packages/moxxmpp/lib/src/negotiators/sasl/scram.dart index 360ebf7..61ad667 100644 --- a/packages/moxxmpp/lib/src/negotiators/sasl/scram.dart +++ b/packages/moxxmpp/lib/src/negotiators/sasl/scram.dart @@ -356,6 +356,9 @@ class SaslScramNegotiator extends Sasl2AuthenticationNegotiator { return []; } + @override + Future onSasl2Failure(XMLNode response) async {} + @override Future> onSasl2Success(XMLNode response) async { // When we're done with SASL2, check the additional data to verify the server diff --git a/packages/moxxmpp/lib/src/negotiators/sasl2.dart b/packages/moxxmpp/lib/src/negotiators/sasl2.dart index f12e61a..28d01b3 100644 --- a/packages/moxxmpp/lib/src/negotiators/sasl2.dart +++ b/packages/moxxmpp/lib/src/negotiators/sasl2.dart @@ -2,6 +2,7 @@ import 'package:moxxmpp/src/jid.dart'; import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/negotiators/namespaces.dart'; import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/negotiators/sasl/errors.dart'; import 'package:moxxmpp/src/negotiators/sasl/negotiator.dart'; import 'package:moxxmpp/src/stringxml.dart'; import 'package:moxxmpp/src/types/result.dart'; @@ -28,6 +29,10 @@ abstract class Sasl2FeatureNegotiator extends XmppFeatureNegotiatorBase { /// item with xmlns equal to [negotiatingXmlns]. Future> onSasl2Success(XMLNode response); + /// Called by the SASL2 negotiator when the SASL2 negotiations have failed. [response] + /// is the entire response nonza. + Future onSasl2Failure(XMLNode response) async {} + /// Called by the SASL2 negotiator to find out whether the negotiator is willing /// to inline a feature. [features] is the list of elements inside the /// element. @@ -39,10 +44,26 @@ abstract class Sasl2AuthenticationNegotiator extends SaslNegotiator implements Sasl2FeatureNegotiator { Sasl2AuthenticationNegotiator(super.priority, super.id, super.mechanismName); + /// Flag indicating whether this negotiator was chosen during SASL2 as the SASL + /// negotiator to use. + bool _pickedForSasl2 = false; + bool get pickedForSasl2 => _pickedForSasl2; + /// Perform a SASL step with [input] as the already parsed input data. Returns /// the base64-encoded response data. Future getRawStep(String input); + void pickForSasl2() { + _pickedForSasl2 = true; + } + + @override + void reset() { + _pickedForSasl2 = false; + + super.reset(); + } + @override bool canInlineFeature(List features) { return true; @@ -174,6 +195,7 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase { for (final negotiator in _saslNegotiators) { if (negotiator.matchesFeature([mechanisms])) { _currentSaslNegotiator = negotiator; + _currentSaslNegotiator!.pickForSasl2(); break; } } @@ -256,6 +278,20 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase { text: await _currentSaslNegotiator!.getRawStep(challenge), ); attributes.sendNonza(response); + } else if (nonza.tag == 'failure') { + final negotiators = _featureNegotiators + .where( + (negotiator) => _activeSasl2Negotiators.contains(negotiator.id), + ) + .toList() + ..add(_currentSaslNegotiator!); + for (final negotiator in negotiators) { + await negotiator.onSasl2Failure(nonza); + } + + return Result( + SaslError.fromFailure(nonza), + ); } } diff --git a/packages/moxxmpp/lib/src/xeps/staging/fast.dart b/packages/moxxmpp/lib/src/xeps/staging/fast.dart new file mode 100644 index 0000000..76088b1 --- /dev/null +++ b/packages/moxxmpp/lib/src/xeps/staging/fast.dart @@ -0,0 +1,158 @@ +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/negotiators/sasl2.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/types/result.dart'; + +/// This event is triggered whenever a new FAST token is received. +class NewFASTTokenReceivedEvent extends XmppEvent { + NewFASTTokenReceivedEvent(this.token); + + /// The token. + final FASTToken token; +} + +/// This event is triggered whenever a new FAST token is invalidated because it's +/// invalid. +class InvalidateFASTTokenEvent extends XmppEvent { + InvalidateFASTTokenEvent(); +} + +/// The description of a token for FAST authentication. +class FASTToken { + const FASTToken( + this.token, + this.expiry, + ); + + factory FASTToken.fromXml(XMLNode token) { + assert(token.tag == 'token', + 'Token can only be deserialised from a element',); + assert(token.xmlns == fastXmlns, + 'Token can only be deserialised from a element',); + + return FASTToken( + token.attributes['token']! as String, + token.attributes['expiry']! as String, + ); + } + + /// The actual token. + final String token; + + /// The token's expiry. + final String expiry; +} + +// TODO(Unknown): Implement multiple hash functions, similar to how we do SCRAM +class FASTSaslNegotiator extends Sasl2AuthenticationNegotiator { + FASTSaslNegotiator() : super(20, saslFASTNegotiator, 'HT-SHA-256-NONE'); + + final Logger _log = Logger('FASTSaslNegotiator'); + + /// The token, if non-null, to use for authentication. + FASTToken? fastToken; + + @override + bool matchesFeature(List features) { + if (fastToken == null) { + return false; + } + + if (super.matchesFeature(features)) { + if (!attributes.getSocket().isSecure()) { + _log.warning( + 'Refusing to match SASL feature due to unsecured connection', + ); + return false; + } + + return true; + } + + return false; + } + + @override + bool canInlineFeature(List features) { + return features.firstWhereOrNull( + (child) => child.tag == 'fast' && child.xmlns == fastXmlns,) != + null; + } + + @override + Future> negotiate( + XMLNode nonza, + ) async { + // TODO(Unknown): Is FAST supposed to work without SASL2? + return const Result(NegotiatorState.done); + } + + @override + Future> onSasl2Success(XMLNode response) async { + final token = response.firstTag('token', xmlns: fastXmlns); + if (token != null) { + fastToken = FASTToken.fromXml(token); + await attributes.sendEvent( + NewFASTTokenReceivedEvent(fastToken!), + ); + } + + state = NegotiatorState.done; + return const Result(true); + } + + @override + Future onSasl2Failure(XMLNode response) async { + fastToken = null; + await attributes.sendEvent( + InvalidateFASTTokenEvent(), + ); + } + + @override + Future> onSasl2FeaturesReceived(XMLNode sasl2Features) async { + if (fastToken != null && pickedForSasl2) { + // Specify that we are using a token + return [ + // As we don't do TLS 0-RTT, we don't have to specify `count`. + XMLNode.xmlns( + tag: 'fast', + xmlns: fastXmlns, + ), + ]; + } + + // Only request a new token when we don't already have one and we are not picked + // for SASL + if (!pickedForSasl2) { + return [ + XMLNode.xmlns( + tag: 'request-token', + xmlns: fastXmlns, + attributes: { + 'mechanism': 'HT-SHA-256-NONE', + }, + ), + ]; + } else { + return []; + } + } + + @override + Future getRawStep(String input) async { + return fastToken!.token; + } + + @override + Future postRegisterCallback() async { + attributes + .getNegotiatorById(sasl2Negotiator) + ?.registerSaslNegotiator(this); + } +} diff --git a/packages/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart b/packages/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart index 9c149c3..48497f3 100644 --- a/packages/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart +++ b/packages/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:moxxmpp/src/events.dart'; import 'package:moxxmpp/src/managers/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart'; @@ -57,6 +58,13 @@ class StreamManagementNegotiator extends Sasl2FeatureNegotiator /// True if we requested stream enablement inline bool _inlineStreamEnablementRequested = false; + /// Cached resource for stream resumption + String _resource = ''; + @visibleForTesting + void setResource(String resource) { + _resource = resource; + } + @override bool canInlineFeature(List features) { final sm = attributes.getManagerById(smManager)!; @@ -78,6 +86,13 @@ class StreamManagementNegotiator extends Sasl2FeatureNegotiator } } + @override + Future onXmppEvent(XmppEvent event) async { + if (event is ResourceBoundEvent) { + _resource = event.resource; + } + } + @override bool matchesFeature(List features) { final sm = attributes.getManagerById(smManager)!; @@ -116,6 +131,13 @@ class StreamManagementNegotiator extends Sasl2FeatureNegotiator _resumeFailed = false; _isResumed = true; + + if (attributes.getConnection().resource.isEmpty && _resource.isNotEmpty) { + attributes.setResource(_resource); + } else if (attributes.getConnection().resource.isNotEmpty && + _resource.isEmpty) { + _resource = attributes.getConnection().resource; + } } Future _onStreamEnablementSuccessful(XMLNode enabled) async { diff --git a/packages/moxxmpp/test/xeps/xep_0198_test.dart b/packages/moxxmpp/test/xeps/xep_0198_test.dart index 03e35c7..d454f64 100644 --- a/packages/moxxmpp/test/xeps/xep_0198_test.dart +++ b/packages/moxxmpp/test/xeps/xep_0198_test.dart @@ -855,7 +855,7 @@ void main() { await conn.registerFeatureNegotiators([ SaslPlainNegotiator(), ResourceBindingNegotiator(), - StreamManagementNegotiator(), + StreamManagementNegotiator()..setResource('test-resource'), Sasl2Negotiator( userAgent: const UserAgent( id: 'd4565fa7-4d72-4749-b3d3-740edbf87770', @@ -954,7 +954,7 @@ void main() { await conn.registerFeatureNegotiators([ SaslPlainNegotiator(), ResourceBindingNegotiator(), - StreamManagementNegotiator(), + StreamManagementNegotiator()..setResource('test-resource'), Bind2Negotiator(), Sasl2Negotiator( userAgent: const UserAgent( diff --git a/packages/moxxmpp/test/xeps/xep_xxxx_fast_test.dart b/packages/moxxmpp/test/xeps/xep_xxxx_fast_test.dart new file mode 100644 index 0000000..b59a22a --- /dev/null +++ b/packages/moxxmpp/test/xeps/xep_xxxx_fast_test.dart @@ -0,0 +1,163 @@ +import 'package:moxxmpp/moxxmpp.dart'; +import 'package:test/test.dart'; +import '../helpers/logging.dart'; +import '../helpers/xmpp.dart'; + +void main() { + initLogger(); + + test('Test FAST authentication without a token', () async { + final fakeSocket = StubTCPSocket([ + StringExpectation( + "", + ''' + + + + PLAIN + HT-SHA-256-NONE + + + PLAIN + HT-SHA-256-NONE + HT-SHA-256-ENDP + + + + HT-SHA-256-NONE + HT-SHA-256-ENDP + + + + + + + ''', + ), + StanzaExpectation( + "moxxmppPapaTutuWawa's awesome deviceAHBvbHlub21kaXZpc2lvbgBhYWFh", + ''' + + polynomdivision@test.server + + + ''', + ), + StanzaExpectation( + '', + 'polynomdivision@test.server/MU29eEZn', + ignoreId: true, + ), + StringExpectation( + '', + '', + ), + StringExpectation( + "", + ''' + + + + PLAIN + HT-SHA-256-NONE + + + PLAIN + HT-SHA-256-NONE + HT-SHA-256-ENDP + + + + HT-SHA-256-NONE + HT-SHA-256-ENDP + + + + + + + ''', + ), + StanzaExpectation( + "moxxmppPapaTutuWawa's awesome deviceWXZzciBwYmFmdmZnZiBqdmd1IGp2eXFhcmZm", + ''' + + polynomdivision@test.server + + ''', + ), + StanzaExpectation( + '', + 'polynomdivision@test.server/MU29eEZn', + ignoreId: true, + ), + ]); + final conn = XmppConnection( + TestingReconnectionPolicy(), + AlwaysConnectedConnectivityManager(), + fakeSocket, + )..setConnectionSettings( + ConnectionSettings( + jid: JID.fromString('polynomdivision@test.server'), + password: 'aaaa', + useDirectTLS: true, + ), + ); + await conn.registerManagers([ + RosterManager(TestingRosterStateManager('', [])), + DiscoManager([]), + ]); + await conn.registerFeatureNegotiators([ + SaslPlainNegotiator(), + ResourceBindingNegotiator(), + FASTSaslNegotiator(), + Sasl2Negotiator( + userAgent: const UserAgent( + id: 'd4565fa7-4d72-4749-b3d3-740edbf87770', + software: 'moxxmpp', + device: "PapaTutuWawa's awesome device", + ), + ), + ]); + + final result1 = await conn.connect( + waitUntilLogin: true, + shouldReconnect: false, + enableReconnectOnSuccess: false, + ); + expect(result1.isType(), false); + expect(conn.resource, 'MU29eEZn'); + expect(fakeSocket.getState(), 3); + + final token = conn + .getNegotiatorById(saslFASTNegotiator)! + .fastToken; + expect(token != null, true); + expect(token!.token, 'WXZzciBwYmFmdmZnZiBqdmd1IGp2eXFhcmZm'); + + // Disconnect + await conn.disconnect(); + + // Connect again, but use FAST this time + final result2 = await conn.connect( + waitUntilLogin: true, + shouldReconnect: false, + enableReconnectOnSuccess: false, + ); + expect(result2.isType(), false); + expect(conn.resource, 'MU29eEZn'); + expect(fakeSocket.getState(), 7); + }); +}