Skip to content

Commit 45e5e85

Browse files
authored
New SASL authenticator (#437)
1 parent 7c0d0ba commit 45e5e85

File tree

4 files changed

+173
-33
lines changed

4 files changed

+173
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 3.5.8
44

55
- Upgraded SDK constraints and lints.
6+
- New SASL authenticator (reduce dependencies that haven't been updated for a while).
67
- Supporting more URL-based connection-string parameters (mostly for pool).
78
- Optimized `StackTrace` capture [#432](https://github.com/isoos/postgresql-dart/pull/432) by [gmpassos](https://github.com/gmpassos).
89

lib/src/auth/auth.dart

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import 'package:crypto/crypto.dart';
2-
import 'package:sasl_scram/sasl_scram.dart';
3-
41
import '../../messages.dart';
52
import 'clear_text_authenticator.dart';
63
import 'md5_authenticator.dart';
@@ -41,14 +38,7 @@ PostgresAuthenticator createAuthenticator(
4138
case AuthenticationScheme.md5:
4239
return MD5Authenticator(connection);
4340
case AuthenticationScheme.scramSha256:
44-
final credentials = UsernamePasswordCredential(
45-
username: connection.username,
46-
password: connection.password,
47-
);
48-
return PostgresSaslAuthenticator(
49-
connection,
50-
ScramAuthenticator('SCRAM-SHA-256', sha256, credentials),
51-
);
41+
return PostgresSaslAuthenticator(connection);
5242
case AuthenticationScheme.clear:
5343
return ClearAuthenticator(connection);
5444
}

lib/src/auth/sasl_authenticator.dart

Lines changed: 171 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,45 @@
1+
import 'dart:convert';
2+
import 'dart:math';
13
import 'dart:typed_data';
24

3-
import 'package:sasl_scram/sasl_scram.dart';
5+
import 'package:crypto/crypto.dart';
46

57
import '../buffer.dart';
68
import '../exceptions.dart';
79
import '../messages/client_messages.dart';
810
import '../messages/server_messages.dart';
911
import 'auth.dart';
1012

13+
final _random = Random.secure();
14+
1115
/// Structure for SASL Authenticator
1216
class PostgresSaslAuthenticator extends PostgresAuthenticator {
13-
final SaslAuthenticator authenticator;
17+
PostgresSaslAuthenticator(super.connection);
1418

15-
PostgresSaslAuthenticator(super.connection, this.authenticator);
19+
late final _authenticator = _ScramSha256Authenticator(
20+
username: connection.username ?? '',
21+
password: connection.password ?? '',
22+
);
1623

1724
@override
1825
void onMessage(AuthenticationMessage message) {
19-
ClientMessage msg;
26+
ClientMessage? msg;
2027
switch (message.type) {
2128
case AuthenticationMessageType.sasl:
22-
final bytesToSend = authenticator.handleMessage(
23-
SaslMessageType.AuthenticationSASL,
24-
message.bytes,
25-
);
26-
if (bytesToSend == null) {
27-
throw PgException('KindSASL: No bytes to send');
28-
}
29-
msg = SaslClientFirstMessage(bytesToSend, authenticator.mechanism.name);
29+
// Server sends list of supported mechanisms
30+
final bytesToSend = _authenticator.generateClientFirstMessage();
31+
msg = SaslClientFirstMessage(bytesToSend, 'SCRAM-SHA-256');
3032
break;
3133
case AuthenticationMessageType.saslContinue:
32-
final bytesToSend = authenticator.handleMessage(
33-
SaslMessageType.AuthenticationSASLContinue,
34+
// Server sends server-first-message
35+
final bytesToSend = _authenticator.processServerFirstMessage(
3436
message.bytes,
3537
);
36-
if (bytesToSend == null) {
37-
throw PgException('KindSASLContinue: No bytes to send');
38-
}
3938
msg = SaslClientLastMessage(bytesToSend);
4039
break;
4140
case AuthenticationMessageType.saslFinal:
42-
authenticator.handleMessage(
43-
SaslMessageType.AuthenticationSASLFinal,
44-
message.bytes,
45-
);
41+
// Server sends server-final-message
42+
_authenticator.verifyServerFinalMessage(message.bytes);
4643
return;
4744
default:
4845
throw PgException(
@@ -53,6 +50,159 @@ class PostgresSaslAuthenticator extends PostgresAuthenticator {
5350
}
5451
}
5552

53+
/// SCRAM-SHA-256 authenticator implementation
54+
class _ScramSha256Authenticator {
55+
final String username;
56+
final String password;
57+
58+
late String _clientNonce;
59+
late String _clientFirstMessageBare;
60+
String? _serverNonce;
61+
String? _salt;
62+
int? _iterations;
63+
String? _authMessage;
64+
65+
_ScramSha256Authenticator({required this.username, required this.password});
66+
67+
/// Generate client-first-message
68+
Uint8List generateClientFirstMessage() {
69+
_clientNonce = base64.encode(
70+
List<int>.generate(24, (_) => _random.nextInt(256)),
71+
);
72+
73+
final encodedUsername = username
74+
.replaceAll('=', '=3D')
75+
.replaceAll(',', '=2C');
76+
_clientFirstMessageBare = 'n=$encodedUsername,r=$_clientNonce';
77+
78+
// client-first-message: GS2 header + client-first-message-bare
79+
// GS2 header: "n,," (no channel binding)
80+
final clientFirstMessage = 'n,,$_clientFirstMessageBare';
81+
82+
return utf8.encode(clientFirstMessage);
83+
}
84+
85+
/// Process server-first-message and generate client-final-message
86+
Uint8List processServerFirstMessage(Uint8List serverFirstMessageBytes) {
87+
final serverFirstMessage = utf8.decode(serverFirstMessageBytes);
88+
89+
// Parse server-first-message: r=<nonce>,s=<salt>,i=<iteration-count>
90+
final parts = _parseMessage(serverFirstMessage);
91+
92+
_serverNonce = parts['r'];
93+
_salt = parts['s'];
94+
_iterations = int.parse(parts['i'] ?? '0');
95+
96+
if (_serverNonce == null || !_serverNonce!.startsWith(_clientNonce)) {
97+
throw PgException('Server nonce does not start with client nonce');
98+
}
99+
100+
// Build client-final-message-without-proof
101+
final channelBinding = 'c=${base64.encode(utf8.encode('n,,'))}';
102+
final clientFinalMessageWithoutProof = '$channelBinding,r=$_serverNonce';
103+
104+
// Calculate auth message
105+
_authMessage =
106+
'$_clientFirstMessageBare,$serverFirstMessage,$clientFinalMessageWithoutProof';
107+
108+
// Calculate client proof
109+
final saltedPassword = _hi(
110+
utf8.encode(password),
111+
base64.decode(_salt!),
112+
_iterations!,
113+
);
114+
115+
final clientKey = _hmac(saltedPassword, utf8.encode('Client Key'));
116+
final storedKey = sha256.convert(clientKey).bytes;
117+
final clientSignature = _hmac(storedKey, utf8.encode(_authMessage!));
118+
119+
final clientProof = Uint8List(clientKey.length);
120+
for (var i = 0; i < clientKey.length; i++) {
121+
clientProof[i] = clientKey[i] ^ clientSignature[i];
122+
}
123+
124+
// Build client-final-message
125+
final clientFinalMessage =
126+
'$clientFinalMessageWithoutProof,p=${base64.encode(clientProof)}';
127+
128+
return Uint8List.fromList(utf8.encode(clientFinalMessage));
129+
}
130+
131+
/// Verify server-final-message
132+
void verifyServerFinalMessage(Uint8List serverFinalMessageBytes) {
133+
final serverFinalMessage = utf8.decode(serverFinalMessageBytes);
134+
135+
// Parse server-final-message: v=<verifier> or e=<error>
136+
final parts = _parseMessage(serverFinalMessage);
137+
138+
if (parts.containsKey('e')) {
139+
throw PgException('SCRAM authentication failed: ${parts['e']}');
140+
}
141+
142+
final serverSignatureB64 = parts['v'];
143+
if (serverSignatureB64 == null) {
144+
throw PgException('Server final message missing verifier');
145+
}
146+
147+
// Calculate expected server signature
148+
final saltedPassword = _hi(
149+
utf8.encode(password),
150+
base64.decode(_salt!),
151+
_iterations!,
152+
);
153+
154+
final serverKey = _hmac(saltedPassword, utf8.encode('Server Key'));
155+
final serverSignature = _hmac(serverKey, utf8.encode(_authMessage!));
156+
157+
// Verify server signature
158+
final expectedSignature = base64.encode(serverSignature);
159+
if (serverSignatureB64 != expectedSignature) {
160+
throw PgException('Server signature verification failed');
161+
}
162+
}
163+
164+
/// Parse SASL message into key-value pairs
165+
Map<String, String> _parseMessage(String message) {
166+
final result = <String, String>{};
167+
final parts = message.split(',');
168+
169+
for (final part in parts) {
170+
final index = part.indexOf('=');
171+
if (index > 0) {
172+
final key = part.substring(0, index);
173+
final value = part.substring(index + 1);
174+
result[key] = value;
175+
}
176+
}
177+
178+
return result;
179+
}
180+
181+
/// HMAC-SHA256
182+
List<int> _hmac(List<int> key, List<int> message) {
183+
final hmacSha256 = Hmac(sha256, key);
184+
return hmacSha256.convert(message).bytes;
185+
}
186+
187+
/// PBKDF2 (Hi function): HMAC iterated i times
188+
List<int> _hi(List<int> password, List<int> salt, int iterations) {
189+
// First iteration: HMAC(password, salt + INT(1))
190+
final saltWithCount = [...salt, 0, 0, 0, 1];
191+
var u = _hmac(password, saltWithCount);
192+
final result = List<int>.from(u);
193+
194+
// Remaining iterations
195+
for (var i = 1; i < iterations; i++) {
196+
u = _hmac(password, u);
197+
for (var j = 0; j < result.length; j++) {
198+
result[j] ^= u[j];
199+
}
200+
}
201+
202+
return result;
203+
}
204+
}
205+
56206
class SaslClientFirstMessage extends ClientMessage {
57207
final Uint8List bytesToSendToServer;
58208
final String mechanismName;

pubspec.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ dependencies:
1515
buffer: ^1.2.3
1616
crypto: ^3.0.6
1717
collection: ^1.19.1
18-
sasl_scram: ^0.1.1
1918
stack_trace: ^1.12.1
2019
stream_channel: ^2.1.4
2120
async: ^2.12.0

0 commit comments

Comments
 (0)