diff --git a/CHANGELOG.md b/CHANGELOG.md index c87e644..9583ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 3.5.8 - Upgraded SDK constraints and lints. +- New SASL authenticator (reduce dependencies that haven't been updated for a while). - Supporting more URL-based connection-string parameters (mostly for pool). - Optimized `StackTrace` capture [#432](https://github.com/isoos/postgresql-dart/pull/432) by [gmpassos](https://github.com/gmpassos). diff --git a/lib/src/auth/auth.dart b/lib/src/auth/auth.dart index d809c5b..a74cd56 100644 --- a/lib/src/auth/auth.dart +++ b/lib/src/auth/auth.dart @@ -1,6 +1,3 @@ -import 'package:crypto/crypto.dart'; -import 'package:sasl_scram/sasl_scram.dart'; - import '../../messages.dart'; import 'clear_text_authenticator.dart'; import 'md5_authenticator.dart'; @@ -41,14 +38,7 @@ PostgresAuthenticator createAuthenticator( case AuthenticationScheme.md5: return MD5Authenticator(connection); case AuthenticationScheme.scramSha256: - final credentials = UsernamePasswordCredential( - username: connection.username, - password: connection.password, - ); - return PostgresSaslAuthenticator( - connection, - ScramAuthenticator('SCRAM-SHA-256', sha256, credentials), - ); + return PostgresSaslAuthenticator(connection); case AuthenticationScheme.clear: return ClearAuthenticator(connection); } diff --git a/lib/src/auth/sasl_authenticator.dart b/lib/src/auth/sasl_authenticator.dart index 39521db..e6fc437 100644 --- a/lib/src/auth/sasl_authenticator.dart +++ b/lib/src/auth/sasl_authenticator.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; -import 'package:sasl_scram/sasl_scram.dart'; +import 'package:crypto/crypto.dart'; import '../buffer.dart'; import '../exceptions.dart'; @@ -8,41 +10,36 @@ import '../messages/client_messages.dart'; import '../messages/server_messages.dart'; import 'auth.dart'; +final _random = Random.secure(); + /// Structure for SASL Authenticator class PostgresSaslAuthenticator extends PostgresAuthenticator { - final SaslAuthenticator authenticator; + PostgresSaslAuthenticator(super.connection); - PostgresSaslAuthenticator(super.connection, this.authenticator); + late final _authenticator = _ScramSha256Authenticator( + username: connection.username ?? '', + password: connection.password ?? '', + ); @override void onMessage(AuthenticationMessage message) { - ClientMessage msg; + ClientMessage? msg; switch (message.type) { case AuthenticationMessageType.sasl: - final bytesToSend = authenticator.handleMessage( - SaslMessageType.AuthenticationSASL, - message.bytes, - ); - if (bytesToSend == null) { - throw PgException('KindSASL: No bytes to send'); - } - msg = SaslClientFirstMessage(bytesToSend, authenticator.mechanism.name); + // Server sends list of supported mechanisms + final bytesToSend = _authenticator.generateClientFirstMessage(); + msg = SaslClientFirstMessage(bytesToSend, 'SCRAM-SHA-256'); break; case AuthenticationMessageType.saslContinue: - final bytesToSend = authenticator.handleMessage( - SaslMessageType.AuthenticationSASLContinue, + // Server sends server-first-message + final bytesToSend = _authenticator.processServerFirstMessage( message.bytes, ); - if (bytesToSend == null) { - throw PgException('KindSASLContinue: No bytes to send'); - } msg = SaslClientLastMessage(bytesToSend); break; case AuthenticationMessageType.saslFinal: - authenticator.handleMessage( - SaslMessageType.AuthenticationSASLFinal, - message.bytes, - ); + // Server sends server-final-message + _authenticator.verifyServerFinalMessage(message.bytes); return; default: throw PgException( @@ -53,6 +50,159 @@ class PostgresSaslAuthenticator extends PostgresAuthenticator { } } +/// SCRAM-SHA-256 authenticator implementation +class _ScramSha256Authenticator { + final String username; + final String password; + + late String _clientNonce; + late String _clientFirstMessageBare; + String? _serverNonce; + String? _salt; + int? _iterations; + String? _authMessage; + + _ScramSha256Authenticator({required this.username, required this.password}); + + /// Generate client-first-message + Uint8List generateClientFirstMessage() { + _clientNonce = base64.encode( + List.generate(24, (_) => _random.nextInt(256)), + ); + + final encodedUsername = username + .replaceAll('=', '=3D') + .replaceAll(',', '=2C'); + _clientFirstMessageBare = 'n=$encodedUsername,r=$_clientNonce'; + + // client-first-message: GS2 header + client-first-message-bare + // GS2 header: "n,," (no channel binding) + final clientFirstMessage = 'n,,$_clientFirstMessageBare'; + + return utf8.encode(clientFirstMessage); + } + + /// Process server-first-message and generate client-final-message + Uint8List processServerFirstMessage(Uint8List serverFirstMessageBytes) { + final serverFirstMessage = utf8.decode(serverFirstMessageBytes); + + // Parse server-first-message: r=,s=,i= + final parts = _parseMessage(serverFirstMessage); + + _serverNonce = parts['r']; + _salt = parts['s']; + _iterations = int.parse(parts['i'] ?? '0'); + + if (_serverNonce == null || !_serverNonce!.startsWith(_clientNonce)) { + throw PgException('Server nonce does not start with client nonce'); + } + + // Build client-final-message-without-proof + final channelBinding = 'c=${base64.encode(utf8.encode('n,,'))}'; + final clientFinalMessageWithoutProof = '$channelBinding,r=$_serverNonce'; + + // Calculate auth message + _authMessage = + '$_clientFirstMessageBare,$serverFirstMessage,$clientFinalMessageWithoutProof'; + + // Calculate client proof + final saltedPassword = _hi( + utf8.encode(password), + base64.decode(_salt!), + _iterations!, + ); + + final clientKey = _hmac(saltedPassword, utf8.encode('Client Key')); + final storedKey = sha256.convert(clientKey).bytes; + final clientSignature = _hmac(storedKey, utf8.encode(_authMessage!)); + + final clientProof = Uint8List(clientKey.length); + for (var i = 0; i < clientKey.length; i++) { + clientProof[i] = clientKey[i] ^ clientSignature[i]; + } + + // Build client-final-message + final clientFinalMessage = + '$clientFinalMessageWithoutProof,p=${base64.encode(clientProof)}'; + + return Uint8List.fromList(utf8.encode(clientFinalMessage)); + } + + /// Verify server-final-message + void verifyServerFinalMessage(Uint8List serverFinalMessageBytes) { + final serverFinalMessage = utf8.decode(serverFinalMessageBytes); + + // Parse server-final-message: v= or e= + final parts = _parseMessage(serverFinalMessage); + + if (parts.containsKey('e')) { + throw PgException('SCRAM authentication failed: ${parts['e']}'); + } + + final serverSignatureB64 = parts['v']; + if (serverSignatureB64 == null) { + throw PgException('Server final message missing verifier'); + } + + // Calculate expected server signature + final saltedPassword = _hi( + utf8.encode(password), + base64.decode(_salt!), + _iterations!, + ); + + final serverKey = _hmac(saltedPassword, utf8.encode('Server Key')); + final serverSignature = _hmac(serverKey, utf8.encode(_authMessage!)); + + // Verify server signature + final expectedSignature = base64.encode(serverSignature); + if (serverSignatureB64 != expectedSignature) { + throw PgException('Server signature verification failed'); + } + } + + /// Parse SASL message into key-value pairs + Map _parseMessage(String message) { + final result = {}; + final parts = message.split(','); + + for (final part in parts) { + final index = part.indexOf('='); + if (index > 0) { + final key = part.substring(0, index); + final value = part.substring(index + 1); + result[key] = value; + } + } + + return result; + } + + /// HMAC-SHA256 + List _hmac(List key, List message) { + final hmacSha256 = Hmac(sha256, key); + return hmacSha256.convert(message).bytes; + } + + /// PBKDF2 (Hi function): HMAC iterated i times + List _hi(List password, List salt, int iterations) { + // First iteration: HMAC(password, salt + INT(1)) + final saltWithCount = [...salt, 0, 0, 0, 1]; + var u = _hmac(password, saltWithCount); + final result = List.from(u); + + // Remaining iterations + for (var i = 1; i < iterations; i++) { + u = _hmac(password, u); + for (var j = 0; j < result.length; j++) { + result[j] ^= u[j]; + } + } + + return result; + } +} + class SaslClientFirstMessage extends ClientMessage { final Uint8List bytesToSendToServer; final String mechanismName; diff --git a/pubspec.yaml b/pubspec.yaml index 9944d01..3a3266e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: buffer: ^1.2.3 crypto: ^3.0.6 collection: ^1.19.1 - sasl_scram: ^0.1.1 stack_trace: ^1.12.1 stream_channel: ^2.1.4 async: ^2.12.0