feat(xep): Implement SASL2 inline stream resumption

This commit is contained in:
PapaTutuWawa 2023-04-01 15:50:13 +02:00
parent 4e01d32e90
commit 51edb61443
7 changed files with 271 additions and 68 deletions

View File

@ -279,7 +279,7 @@ class XmppConnection {
() => _socket, () => _socket,
() => _isAuthenticated, () => _isAuthenticated,
_setAuthenticated, _setAuthenticated,
_setResource, setResource,
_removeNegotiatingFeature, _removeNegotiatingFeature,
), ),
); );
@ -675,7 +675,8 @@ class XmppConnection {
} }
/// Sets the resource of the connection /// Sets the resource of the connection
void _setResource(String resource, {bool triggerEvent = true}) { @visibleForTesting
void setResource(String resource, {bool triggerEvent = true}) {
_log.finest('Updating _resource to $resource'); _log.finest('Updating _resource to $resource');
_resource = resource; _resource = resource;
@ -1134,9 +1135,7 @@ class XmppConnection {
} }
if (lastResource != null) { if (lastResource != null) {
_setResource(lastResource, triggerEvent: false); setResource(lastResource, triggerEvent: false);
} else {
_setResource('', triggerEvent: false);
} }
_enableReconnectOnSuccess = enableReconnectOnSuccess; _enableReconnectOnSuccess = enableReconnectOnSuccess;

View File

@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:moxxmpp/src/jid.dart'; import 'package:moxxmpp/src/jid.dart';
import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart';
import 'package:moxxmpp/src/negotiators/namespaces.dart'; import 'package:moxxmpp/src/negotiators/namespaces.dart';
@ -28,6 +27,11 @@ abstract class Sasl2FeatureNegotiator extends XmppFeatureNegotiatorBase {
/// This method is only called when the previous <inline /> element contains an /// This method is only called when the previous <inline /> element contains an
/// item with xmlns equal to [negotiatingXmlns]. /// item with xmlns equal to [negotiatingXmlns].
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response); Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response);
/// 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.
bool canInlineFeature(List<XMLNode> features);
} }
/// A special type of [SaslNegotiator] that is aware of SASL2. /// A special type of [SaslNegotiator] that is aware of SASL2.
@ -38,6 +42,11 @@ abstract class Sasl2AuthenticationNegotiator extends SaslNegotiator
/// Perform a SASL step with [input] as the already parsed input data. Returns /// Perform a SASL step with [input] as the already parsed input data. Returns
/// the base64-encoded response data. /// the base64-encoded response data.
Future<String> getRawStep(String input); Future<String> getRawStep(String input);
@override
bool canInlineFeature(List<XMLNode> features) {
return true;
}
} }
class NoSASLMechanismSelectedError extends NegotiatorError { class NoSASLMechanismSelectedError extends NegotiatorError {
@ -125,6 +134,8 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase {
/// The SASL2 <authentication /> element we received with the stream features. /// The SASL2 <authentication /> element we received with the stream features.
XMLNode? _sasl2Data; XMLNode? _sasl2Data;
final List<String> _activeSasl2Negotiators =
List<String>.empty(growable: true);
/// Register a SASL negotiator so that we can use that SASL implementation during /// Register a SASL negotiator so that we can use that SASL implementation during
/// SASL2. /// SASL2.
@ -141,18 +152,6 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase {
_featureNegotiators.add(negotiator); _featureNegotiators.add(negotiator);
} }
/// Returns true, if an item with xmlns of [xmlns] is contained inside [_sasl2Data]'s
/// <inline /> block. If not, returns false.
bool _isInliningPossible(String xmlns) {
final inline = _sasl2Data!.firstTag('inline');
if (inline == null) {
return false;
}
return inline.children.firstWhereOrNull((child) => child.xmlns == xmlns) !=
null;
}
@override @override
bool matchesFeature(List<XMLNode> features) { bool matchesFeature(List<XMLNode> features) {
// Only do SASL2 when the socket is secure // Only do SASL2 when the socket is secure
@ -185,14 +184,18 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase {
} }
// Collect additional data by interested negotiators // Collect additional data by interested negotiators
final inline = _sasl2Data!.firstTag('inline');
final children = List<XMLNode>.empty(growable: true); final children = List<XMLNode>.empty(growable: true);
if (inline != null && inline.children.isNotEmpty) {
for (final negotiator in _featureNegotiators) { for (final negotiator in _featureNegotiators) {
if (_isInliningPossible(negotiator.negotiatingXmlns)) { if (negotiator.canInlineFeature(inline.children)) {
_activeSasl2Negotiators.add(negotiator.id);
children.addAll( children.addAll(
await negotiator.onSasl2FeaturesReceived(_sasl2Data!), await negotiator.onSasl2FeaturesReceived(_sasl2Data!),
); );
} }
} }
}
// Build the authenticate nonza // Build the authenticate nonza
final authenticate = XMLNode.xmlns( final authenticate = XMLNode.xmlns(
@ -217,19 +220,18 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase {
case Sasl2State.authenticateSent: case Sasl2State.authenticateSent:
if (nonza.tag == 'success') { if (nonza.tag == 'success') {
// Tell the dependent negotiators about the result // Tell the dependent negotiators about the result
// TODO(Unknown): This can be written in a better way final negotiators = _featureNegotiators
for (final negotiator in _featureNegotiators) { .where(
if (_isInliningPossible(negotiator.negotiatingXmlns)) { (negotiator) => _activeSasl2Negotiators.contains(negotiator.id),
)
.toList()
..add(_currentSaslNegotiator!);
for (final negotiator in negotiators) {
final result = await negotiator.onSasl2Success(nonza); final result = await negotiator.onSasl2Success(nonza);
if (!result.isType<bool>()) { if (!result.isType<bool>()) {
return Result(result.get<NegotiatorError>()); return Result(result.get<NegotiatorError>());
} }
} }
}
final result = await _currentSaslNegotiator!.onSasl2Success(nonza);
if (!result.isType<bool>()) {
return Result(result.get<NegotiatorError>());
}
// We're done // We're done
attributes.setAuthenticated(); attributes.setAuthenticated();
@ -264,6 +266,7 @@ class Sasl2Negotiator extends XmppFeatureNegotiatorBase {
_currentSaslNegotiator = null; _currentSaslNegotiator = null;
_sasl2State = Sasl2State.idle; _sasl2State = Sasl2State.idle;
_sasl2Data = null; _sasl2Data = null;
_activeSasl2Negotiators.clear();
super.reset(); super.reset();
} }

View File

@ -1,9 +1,11 @@
import 'package:collection/collection.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/src/events.dart'; import 'package:moxxmpp/src/events.dart';
import 'package:moxxmpp/src/managers/namespaces.dart'; import 'package:moxxmpp/src/managers/namespaces.dart';
import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart';
import 'package:moxxmpp/src/negotiators/namespaces.dart'; import 'package:moxxmpp/src/negotiators/namespaces.dart';
import 'package:moxxmpp/src/negotiators/negotiator.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/stringxml.dart';
import 'package:moxxmpp/src/types/result.dart'; import 'package:moxxmpp/src/types/result.dart';
import 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart'; import 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart';
@ -23,27 +25,51 @@ enum _StreamManagementNegotiatorState {
/// NOTE: The stream management negotiator requires that loadState has been called on the /// NOTE: The stream management negotiator requires that loadState has been called on the
/// StreamManagementManager at least once before connecting, if stream resumption /// StreamManagementManager at least once before connecting, if stream resumption
/// is wanted. /// is wanted.
class StreamManagementNegotiator extends XmppFeatureNegotiatorBase { class StreamManagementNegotiator extends Sasl2FeatureNegotiator {
StreamManagementNegotiator() StreamManagementNegotiator()
: _state = _StreamManagementNegotiatorState.ready, : super(10, false, smXmlns, streamManagementNegotiator);
_supported = false,
_resumeFailed = false,
_isResumed = false,
_log = Logger('StreamManagementNegotiator'),
super(10, false, smXmlns, streamManagementNegotiator);
_StreamManagementNegotiatorState _state;
bool _resumeFailed;
bool _isResumed;
final Logger _log; /// Stream Management negotiation state.
_StreamManagementNegotiatorState _state =
_StreamManagementNegotiatorState.ready;
/// Flag indicating whether the resume failed (true) or succeeded (false).
bool _resumeFailed = false;
/// Flag indicating whether the current stream is resumed (true) or not (false).
bool _isResumed = false;
/// Logger
final Logger _log = Logger('StreamManagementNegotiator');
/// True if Stream Management is supported on this stream. /// True if Stream Management is supported on this stream.
bool _supported; bool _supported = false;
bool get isSupported => _supported; bool get isSupported => _supported;
/// True if the current stream is resumed. False if not. /// True if the current stream is resumed. False if not.
bool get isResumed => _isResumed; bool get isResumed => _isResumed;
@override
bool canInlineFeature(List<XMLNode> features) {
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
// We do not check here for authentication as enabling/resuming happens inline
// with the authentication.
if (sm.state.streamResumptionId != null && !_resumeFailed) {
// We can try to resume the stream or enable the stream
return features.firstWhereOrNull(
(child) => child.xmlns == smXmlns,
) !=
null;
} else {
// We can try to enable SM
return features.firstWhereOrNull(
(child) => child.tag == 'enable' && child.xmlns == smXmlns,
) !=
null;
}
}
@override @override
bool matchesFeature(List<XMLNode> features) { bool matchesFeature(List<XMLNode> features) {
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!; final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
@ -53,13 +79,37 @@ class StreamManagementNegotiator extends XmppFeatureNegotiatorBase {
return super.matchesFeature(features) && attributes.isAuthenticated(); return super.matchesFeature(features) && attributes.isAuthenticated();
} else { } else {
// We cannot do a stream resumption // We cannot do a stream resumption
final br = attributes.getNegotiatorById(resourceBindingNegotiator);
return super.matchesFeature(features) && return super.matchesFeature(features) &&
br?.state == NegotiatorState.done && attributes.getConnection().resource.isNotEmpty &&
attributes.isAuthenticated(); attributes.isAuthenticated();
} }
} }
Future<void> _onStreamResumptionFailed() async {
await attributes.sendEvent(StreamResumeFailedEvent());
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
// We have to do this because we otherwise get a stanza stuck in the queue,
// thus spamming the server on every <a /> nonza we receive.
// ignore: cascade_invocations
await sm.setState(StreamManagementState(0, 0));
await sm.commitState();
_resumeFailed = true;
_isResumed = false;
_state = _StreamManagementNegotiatorState.ready;
}
Future<void> _onStreamResumptionSuccessful(XMLNode resumed) async {
assert(resumed.tag == 'resumed', 'The correct element must be passed');
final h = int.parse(resumed.attributes['h']! as String);
await attributes.sendEvent(StreamResumedEvent(h: h));
_resumeFailed = false;
_isResumed = true;
}
@override @override
Future<Result<NegotiatorState, NegotiatorError>> negotiate( Future<Result<NegotiatorState, NegotiatorError>> negotiate(
XMLNode nonza, XMLNode nonza,
@ -103,30 +153,14 @@ class StreamManagementNegotiator extends XmppFeatureNegotiatorBase {
csi.restoreCSIState(); csi.restoreCSIState();
} }
final h = int.parse(nonza.attributes['h']! as String); await _onStreamResumptionSuccessful(nonza);
await attributes.sendEvent(StreamResumedEvent(h: h));
_resumeFailed = false;
_isResumed = true;
return const Result(NegotiatorState.skipRest); return const Result(NegotiatorState.skipRest);
} else { } else {
// We assume it is <failed /> // We assume it is <failed />
_log.info( _log.info(
'Stream resumption failed. Expected <resumed />, got ${nonza.tag}, Proceeding with new stream...', 'Stream resumption failed. Expected <resumed />, got ${nonza.tag}, Proceeding with new stream...',
); );
await attributes.sendEvent(StreamResumeFailedEvent()); await _onStreamResumptionFailed();
final sm =
attributes.getManagerById<StreamManagementManager>(smManager)!;
// We have to do this because we otherwise get a stanza stuck in the queue,
// thus spamming the server on every <a /> nonza we receive.
// ignore: cascade_invocations
await sm.setState(StreamManagementState(0, 0));
await sm.commitState();
_resumeFailed = true;
_isResumed = false;
_state = _StreamManagementNegotiatorState.ready;
return const Result(NegotiatorState.retryLater); return const Result(NegotiatorState.retryLater);
} }
case _StreamManagementNegotiatorState.enableRequested: case _StreamManagementNegotiatorState.enableRequested:
@ -165,4 +199,60 @@ class StreamManagementNegotiator extends XmppFeatureNegotiatorBase {
super.reset(); super.reset();
} }
@override
Future<List<XMLNode>> onSasl2FeaturesReceived(XMLNode sasl2Features) async {
final inline = sasl2Features.firstTag('inline')!;
final resume = inline.firstTag('resume', xmlns: smXmlns);
if (resume == null) {
return [];
}
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
final srid = sm.state.streamResumptionId;
final h = sm.state.s2c;
if (srid == null) {
_log.finest('No srid');
return [];
}
return [
XMLNode.xmlns(
tag: 'resume',
xmlns: smXmlns,
attributes: {
'h': h.toString(),
'previd': srid,
},
),
];
}
@override
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async {
final resumed = response.firstTag('resumed', xmlns: smXmlns);
if (resumed == null) {
_log.warning('Inline stream resumption failed');
await _onStreamResumptionFailed();
state = NegotiatorState.retryLater;
return const Result(true);
}
_log.finest('Inline stream resumption successful');
await _onStreamResumptionSuccessful(resumed);
state = NegotiatorState.skipRest;
attributes.removeNegotiatingFeature(smXmlns);
attributes.removeNegotiatingFeature(bindXmlns);
return const Result(true);
}
@override
Future<void> postRegisterCallback() async {
attributes
.getNegotiatorById<Sasl2Negotiator>(sasl2Negotiator)
?.registerNegotiator(this);
}
} }

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart';
import 'package:moxxmpp/src/negotiators/namespaces.dart'; import 'package:moxxmpp/src/negotiators/namespaces.dart';
import 'package:moxxmpp/src/negotiators/negotiator.dart'; import 'package:moxxmpp/src/negotiators/negotiator.dart';
@ -37,6 +38,14 @@ class Bind2Negotiator extends Sasl2FeatureNegotiator {
]; ];
} }
@override
bool canInlineFeature(List<XMLNode> features) {
return features.firstWhereOrNull(
(child) => child.tag == 'bind' && child.xmlns == bind2Xmlns,
) !=
null;
}
@override @override
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async { Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async {
attributes.removeNegotiatingFeature(bindXmlns); attributes.removeNegotiatingFeature(bindXmlns);

View File

@ -788,4 +788,98 @@ void main() {
expect(sm.streamResumed, true); expect(sm.streamResumed, true);
}); });
}); });
test('Test SASL2 inline stream resumption', () 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>
</mechanisms>
<authentication xmlns='urn:xmpp:sasl:2'>
<mechanism>PLAIN</mechanism>
<inline>
<resume xmlns="urn:xmpp:sm:3" />
</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><resume xmlns='urn:xmpp:sm:3' previd='test-prev-id' h='2' /></authenticate>",
'''
<success xmlns='urn:xmpp:sasl:2'>
<authorization-identifier>polynomdivision@test.server</authorization-identifier>
<resumed xmlns='urn:xmpp:sm:3' h='25' previd='test-prev-id' />
</success>
''',
),
]);
final sm = StreamManagementManager();
await sm.setState(
sm.state.copyWith(
c2s: 25,
s2c: 2,
streamResumptionId: 'test-prev-id',
),
);
final conn = XmppConnection(
TestingReconnectionPolicy(),
AlwaysConnectedConnectivityManager(),
fakeSocket,
)
..setConnectionSettings(
ConnectionSettings(
jid: JID.fromString('polynomdivision@test.server'),
password: 'aaaa',
useDirectTLS: true,
),
)
..setResource('test-resource', triggerEvent: false);
await conn.registerManagers([
RosterManager(TestingRosterStateManager('', [])),
DiscoManager([]),
sm,
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
StreamManagementNegotiator(),
Sasl2Negotiator(
userAgent: const UserAgent(
id: 'd4565fa7-4d72-4749-b3d3-740edbf87770',
software: 'moxxmpp',
device: "PapaTutuWawa's awesome device",
),
),
]);
final result = await conn.connect(
waitUntilLogin: true,
shouldReconnect: false,
enableReconnectOnSuccess: false,
);
expect(result.isType<NegotiatorError>(), false);
expect(
sm.state.c2s,
25,
);
expect(
sm.state.s2c,
2,
);
expect(conn.resource, 'test-resource');
});
} }

View File

@ -132,8 +132,7 @@ void main() {
await conn.registerFeatureNegotiators([ await conn.registerFeatureNegotiators([
SaslPlainNegotiator(), SaslPlainNegotiator(),
ResourceBindingNegotiator(), ResourceBindingNegotiator(),
Bind2Negotiator() Bind2Negotiator()..tag = 'moxxmpp',
..tag = 'moxxmpp',
Sasl2Negotiator( Sasl2Negotiator(
userAgent: const UserAgent( userAgent: const UserAgent(
id: 'd4565fa7-4d72-4749-b3d3-740edbf87770', id: 'd4565fa7-4d72-4749-b3d3-740edbf87770',

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../helpers/logging.dart'; import '../helpers/logging.dart';
@ -16,6 +17,14 @@ class ExampleNegotiator extends Sasl2FeatureNegotiator {
return const Result(NegotiatorState.done); return const Result(NegotiatorState.done);
} }
@override
bool canInlineFeature(List<XMLNode> features) {
return features.firstWhereOrNull(
(child) => child.xmlns == 'invalid:example:dont:use',
) !=
null;
}
@override @override
Future<void> postRegisterCallback() async { Future<void> postRegisterCallback() async {
attributes attributes