feat(xep): Implement FAST

This commit is contained in:
PapaTutuWawa 2023-04-01 21:10:46 +02:00
parent 24cb05f91b
commit 0033d0eb6e
12 changed files with 403 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -124,6 +124,9 @@ abstract class XmppFeatureNegotiatorBase {
null;
}
/// Called when an event is triggered in the [XmppConnection].
Future<void> 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 <stream:features /> nonza.

View File

@ -98,6 +98,9 @@ class SaslPlainNegotiator extends Sasl2AuthenticationNegotiator {
return const Result(true);
}
@override
Future<void> onSasl2Failure(XMLNode response) async {}
@override
Future<List<XMLNode>> onSasl2FeaturesReceived(XMLNode sasl2Features) async {
return [];

View File

@ -356,6 +356,9 @@ class SaslScramNegotiator extends Sasl2AuthenticationNegotiator {
return [];
}
@override
Future<void> onSasl2Failure(XMLNode response) async {}
@override
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async {
// When we're done with SASL2, check the additional data to verify the server

View File

@ -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<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response);
/// Called by the SASL2 negotiator when the SASL2 negotiations have failed. [response]
/// is the entire response nonza.
Future<void> 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 <inline />
/// 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<String> getRawStep(String input);
void pickForSasl2() {
_pickedForSasl2 = true;
}
@override
void reset() {
_pickedForSasl2 = false;
super.reset();
}
@override
bool canInlineFeature(List<XMLNode> 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),
);
}
}

View File

@ -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 <token /> element',);
assert(token.xmlns == fastXmlns,
'Token can only be deserialised from a <token /> 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<XMLNode> 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<XMLNode> features) {
return features.firstWhereOrNull(
(child) => child.tag == 'fast' && child.xmlns == fastXmlns,) !=
null;
}
@override
Future<Result<NegotiatorState, NegotiatorError>> negotiate(
XMLNode nonza,
) async {
// TODO(Unknown): Is FAST supposed to work without SASL2?
return const Result(NegotiatorState.done);
}
@override
Future<Result<bool, NegotiatorError>> 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<void> onSasl2Failure(XMLNode response) async {
fastToken = null;
await attributes.sendEvent(
InvalidateFASTTokenEvent(),
);
}
@override
Future<List<XMLNode>> 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<String> getRawStep(String input) async {
return fastToken!.token;
}
@override
Future<void> postRegisterCallback() async {
attributes
.getNegotiatorById<Sasl2Negotiator>(sasl2Negotiator)
?.registerSaslNegotiator(this);
}
}

View File

@ -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<XMLNode> features) {
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
@ -78,6 +86,13 @@ class StreamManagementNegotiator extends Sasl2FeatureNegotiator
}
}
@override
Future<void> onXmppEvent(XmppEvent event) async {
if (event is ResourceBoundEvent) {
_resource = event.resource;
}
}
@override
bool matchesFeature(List<XMLNode> features) {
final sm = attributes.getManagerById<StreamManagementManager>(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<void> _onStreamEnablementSuccessful(XMLNode enabled) async {

View File

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

View File

@ -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(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
<mechanism>HT-SHA-256-NONE</mechanism>
</mechanisms>
<authentication xmlns='urn:xmpp:sasl:2'>
<mechanism>PLAIN</mechanism>
<mechanism>HT-SHA-256-NONE</mechanism>
<mechanism>HT-SHA-256-ENDP</mechanism>
<inline>
<bind xmlns="urn:xmpp:bind:0" />
<fast xmlns="urn:xmpp:fast:0">
<mechanism>HT-SHA-256-NONE</mechanism>
<mechanism>HT-SHA-256-ENDP</mechanism>
</fast>
</inline>
</authentication>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StanzaExpectation(
"<authenticate xmlns='urn:xmpp:sasl:2' mechanism='PLAIN'><user-agent id='d4565fa7-4d72-4749-b3d3-740edbf87770'><software>moxxmpp</software><device>PapaTutuWawa's awesome device</device></user-agent><initial-response>AHBvbHlub21kaXZpc2lvbgBhYWFh</initial-response><request-token xmlns='urn:xmpp:fast:0' mechanism='HT-SHA-256-NONE' /></authenticate>",
'''
<success xmlns='urn:xmpp:sasl:2'>
<authorization-identifier>polynomdivision@test.server</authorization-identifier>
<token xmlns='urn:xmpp:fast:0'
expiry='2020-03-12T14:36:15Z'
token='WXZzciBwYmFmdmZnZiBqdmd1IGp2eXFhcmZm' />
</success>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StringExpectation(
'',
'',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
<mechanism>HT-SHA-256-NONE</mechanism>
</mechanisms>
<authentication xmlns='urn:xmpp:sasl:2'>
<mechanism>PLAIN</mechanism>
<mechanism>HT-SHA-256-NONE</mechanism>
<mechanism>HT-SHA-256-ENDP</mechanism>
<inline>
<bind xmlns="urn:xmpp:bind:0" />
<fast xmlns="urn:xmpp:fast:0">
<mechanism>HT-SHA-256-NONE</mechanism>
<mechanism>HT-SHA-256-ENDP</mechanism>
</fast>
</inline>
</authentication>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StanzaExpectation(
"<authenticate xmlns='urn:xmpp:sasl:2' mechanism='HT-SHA-256-NONE'><user-agent id='d4565fa7-4d72-4749-b3d3-740edbf87770'><software>moxxmpp</software><device>PapaTutuWawa's awesome device</device></user-agent><initial-response>WXZzciBwYmFmdmZnZiBqdmd1IGp2eXFhcmZm</initial-response><fast xmlns='urn:xmpp:fast:0' /></authenticate>",
'''
<success xmlns='urn:xmpp:sasl:2'>
<authorization-identifier>polynomdivision@test.server</authorization-identifier>
</success>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
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<NegotiatorError>(), false);
expect(conn.resource, 'MU29eEZn');
expect(fakeSocket.getState(), 3);
final token = conn
.getNegotiatorById<FASTSaslNegotiator>(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<NegotiatorError>(), false);
expect(conn.resource, 'MU29eEZn');
expect(fakeSocket.getState(), 7);
});
}