Skip to content

Commit 7c38090

Browse files
committed
sasl/scram authentication
1 parent e0ebdef commit 7c38090

9 files changed

+473
-18
lines changed

lib/client.js

+23
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
var EventEmitter = require('events').EventEmitter
1111
var util = require('util')
1212
var utils = require('./utils')
13+
var sasl = require('./sasl')
1314
var pgPass = require('pgpass')
1415
var TypeOverrides = require('./type-overrides')
1516

@@ -126,6 +127,28 @@ Client.prototype._connect = function (callback) {
126127
con.password(utils.postgresMd5PasswordHash(self.user, self.password, msg.salt))
127128
}))
128129

130+
// password request handling (SASL)
131+
var saslSession
132+
con.on('authenticationSASL', checkPgPass(function (msg) {
133+
saslSession = sasl.startSession(msg.mechanisms)
134+
135+
con.sendSASLInitialResponseMessage(saslSession.mechanism, saslSession.response)
136+
}))
137+
138+
// password request handling (SASL)
139+
con.on('authenticationSASLContinue', function (msg) {
140+
sasl.continueSession(saslSession, self.password, msg.data)
141+
142+
con.sendSCRAMClientFinalMessage(saslSession.response)
143+
})
144+
145+
// password request handling (SASL)
146+
con.on('authenticationSASLFinal', function (msg) {
147+
sasl.finalizeSession(saslSession, msg.data)
148+
149+
saslSession = null
150+
})
151+
129152
con.once('backendKeyData', function (msg) {
130153
self.processID = msg.processID
131154
self.secretKey = msg.secretKey

lib/connection.js

+62-16
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,24 @@ Connection.prototype.password = function (password) {
191191
this._send(0x70, this.writer.addCString(password))
192192
}
193193

194+
Connection.prototype.sendSASLInitialResponseMessage = function (mechanism, initialResponse) {
195+
// 0x70 = 'p'
196+
this.writer
197+
.addCString(mechanism)
198+
.addInt32(Buffer.byteLength(initialResponse))
199+
.addString(initialResponse)
200+
201+
this._send(0x70)
202+
}
203+
204+
Connection.prototype.sendSCRAMClientFinalMessage = function (additionalData) {
205+
// 0x70 = 'p'
206+
this.writer
207+
.addString(additionalData)
208+
209+
this._send(0x70)
210+
}
211+
194212
Connection.prototype._send = function (code, more) {
195213
if (!this.stream.writable) {
196214
return false
@@ -421,25 +439,53 @@ Connection.prototype.parseMessage = function (buffer) {
421439
}
422440

423441
Connection.prototype.parseR = function (buffer, length) {
424-
var code = 0
442+
var code = this.parseInt32(buffer)
443+
425444
var msg = new Message('authenticationOk', length)
426-
if (msg.length === 8) {
427-
code = this.parseInt32(buffer)
428-
if (code === 3) {
429-
msg.name = 'authenticationCleartextPassword'
430-
}
431-
return msg
432-
}
433-
if (msg.length === 12) {
434-
code = this.parseInt32(buffer)
435-
if (code === 5) { // md5 required
436-
msg.name = 'authenticationMD5Password'
437-
msg.salt = Buffer.alloc(4)
438-
buffer.copy(msg.salt, 0, this.offset, this.offset + 4)
439-
this.offset += 4
445+
446+
switch (code) {
447+
case 0: // AuthenticationOk
448+
return msg
449+
case 3: // AuthenticationCleartextPassword
450+
if (msg.length === 8) {
451+
msg.name = 'authenticationCleartextPassword'
452+
return msg
453+
}
454+
break
455+
case 5: // AuthenticationMD5Password
456+
if (msg.length === 12) {
457+
msg.name = 'authenticationMD5Password'
458+
msg.salt = Buffer.alloc(4)
459+
buffer.copy(msg.salt, 0, this.offset, this.offset + 4)
460+
this.offset += 4
461+
return msg
462+
}
463+
464+
break
465+
case 10: // AuthenticationSASL
466+
msg.name = 'authenticationSASL'
467+
msg.mechanisms = []
468+
do {
469+
var mechanism = this.parseCString(buffer)
470+
471+
if (mechanism) {
472+
msg.mechanisms.push(mechanism)
473+
}
474+
} while (mechanism)
475+
476+
return msg
477+
case 11: // AuthenticationSASLContinue
478+
msg.name = 'authenticationSASLContinue'
479+
msg.data = this.readString(buffer, length - 4)
480+
481+
return msg
482+
case 12: // AuthenticationSASLFinal
483+
msg.name = 'authenticationSASLFinal'
484+
msg.data = this.readString(buffer, length - 4)
485+
440486
return msg
441-
}
442487
}
488+
443489
throw new Error('Unknown authenticationOk message type' + util.inspect(msg))
444490
}
445491

lib/sasl.js

+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
const crypto = require('crypto')
2+
3+
function startSession (mechanisms) {
4+
if (mechanisms.indexOf('SCRAM-SHA-256') === -1) {
5+
throw new Error('SASL: Only mechanism SCRAM-SHA-256 is currently supported')
6+
}
7+
8+
const clientNonce = crypto.randomBytes(18).toString('base64')
9+
10+
return {
11+
mechanism: 'SCRAM-SHA-256',
12+
clientNonce,
13+
response: 'n,,n=*,r=' + clientNonce,
14+
message: 'SASLInitialResponse'
15+
}
16+
}
17+
18+
function continueSession (session, password, serverData) {
19+
if (session.message !== 'SASLInitialResponse') {
20+
throw new Error('SASL: Last message was not SASLInitialResponse')
21+
}
22+
23+
const sv = extractVariablesFromFirstServerMessage(serverData)
24+
25+
if (!sv.nonce.startsWith(session.clientNonce)) {
26+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce')
27+
}
28+
29+
var saltBytes = Buffer.from(sv.salt, 'base64')
30+
31+
var saltedPassword = Hi(password, saltBytes, sv.iteration)
32+
33+
var clientKey = createHMAC(saltedPassword, 'Client Key')
34+
var storedKey = crypto.createHash('sha256').update(clientKey).digest()
35+
36+
var clientFirstMessageBare = 'n=*,r=' + session.clientNonce
37+
var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
38+
39+
var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce
40+
41+
var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof
42+
43+
var clientSignature = createHMAC(storedKey, authMessage)
44+
var clientProofBytes = xorBuffers(clientKey, clientSignature)
45+
var clientProof = clientProofBytes.toString('base64')
46+
47+
var serverKey = createHMAC(saltedPassword, 'Server Key')
48+
var serverSignatureBytes = createHMAC(serverKey, authMessage)
49+
50+
session.message = 'SASLResponse'
51+
session.serverSignature = serverSignatureBytes.toString('base64')
52+
session.response = clientFinalMessageWithoutProof + ',p=' + clientProof
53+
}
54+
55+
function finalizeSession (session, serverData) {
56+
if (session.message !== 'SASLResponse') {
57+
throw new Error('SASL: Last message was not SASLResponse')
58+
}
59+
60+
var serverSignature
61+
62+
String(serverData).split(',').forEach(function (part) {
63+
switch (part[0]) {
64+
case 'v':
65+
serverSignature = part.substr(2)
66+
break
67+
}
68+
})
69+
70+
if (serverSignature !== session.serverSignature) {
71+
throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match')
72+
}
73+
}
74+
75+
function extractVariablesFromFirstServerMessage (data) {
76+
var nonce, salt, iteration
77+
78+
String(data).split(',').forEach(function (part) {
79+
switch (part[0]) {
80+
case 'r':
81+
nonce = part.substr(2)
82+
break
83+
case 's':
84+
salt = part.substr(2)
85+
break
86+
case 'i':
87+
iteration = parseInt(part.substr(2), 10)
88+
break
89+
}
90+
})
91+
92+
if (!nonce) {
93+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing')
94+
}
95+
96+
if (!salt) {
97+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing')
98+
}
99+
100+
if (!iteration) {
101+
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing')
102+
}
103+
104+
return {
105+
nonce,
106+
salt,
107+
iteration
108+
}
109+
}
110+
111+
function xorBuffers (a, b) {
112+
if (!Buffer.isBuffer(a)) a = Buffer.from(a)
113+
if (!Buffer.isBuffer(b)) b = Buffer.from(b)
114+
var res = []
115+
if (a.length > b.length) {
116+
for (var i = 0; i < b.length; i++) {
117+
res.push(a[i] ^ b[i])
118+
}
119+
} else {
120+
for (var j = 0; j < a.length; j++) {
121+
res.push(a[j] ^ b[j])
122+
}
123+
}
124+
return Buffer.from(res)
125+
}
126+
127+
function createHMAC (key, msg) {
128+
return crypto.createHmac('sha256', key).update(msg).digest()
129+
}
130+
131+
function Hi (password, saltBytes, iterations) {
132+
var ui1 = createHMAC(password, Buffer.concat([saltBytes, Buffer.from([0, 0, 0, 1])]))
133+
var ui = ui1
134+
for (var i = 0; i < iterations - 1; i++) {
135+
ui1 = createHMAC(password, ui1)
136+
ui = xorBuffers(ui, ui1)
137+
}
138+
139+
return ui
140+
}
141+
142+
module.exports = {
143+
startSession,
144+
continueSession,
145+
finalizeSession
146+
}

test/buffer-list.js

+7
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ p.addCString = function (val, front) {
3636
return this.add(buffer, front)
3737
}
3838

39+
p.addString = function (val, front) {
40+
var len = Buffer.byteLength(val)
41+
var buffer = Buffer.alloc(len)
42+
buffer.write(val)
43+
return this.add(buffer, front)
44+
}
45+
3946
p.addChar = function (char, first) {
4047
return this.add(Buffer.from(char, 'utf8'), first)
4148
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use strict'
2+
var helper = require(__dirname + '/../test-helper')
3+
var pg = helper.pg
4+
5+
var suite = new helper.Suite()
6+
7+
/*
8+
SQL to create test role:
9+
10+
set password_encryption = 'scram-sha-256';
11+
create role npgtest login password 'test';
12+
13+
pg_hba:
14+
host all npgtest ::1/128 scram-sha-256
15+
host all npgtest 0.0.0.0/0 scram-sha-256
16+
17+
18+
*/
19+
/*
20+
suite.test('can connect using sasl/scram', function () {
21+
var connectionString = 'pg://npgtest:test@localhost/postgres'
22+
const pool = new pg.Pool({ connectionString: connectionString })
23+
pool.connect(
24+
assert.calls(function (err, client, done) {
25+
assert.ifError(err, 'should have connected')
26+
done()
27+
})
28+
)
29+
})
30+
31+
suite.test('sasl/scram fails when password is wrong', function () {
32+
var connectionString = 'pg://npgtest:bad@localhost/postgres'
33+
const pool = new pg.Pool({ connectionString: connectionString })
34+
pool.connect(
35+
assert.calls(function (err, client, done) {
36+
assert.ok(err, 'should have a connection error')
37+
done()
38+
})
39+
)
40+
})
41+
*/

test/test-buffers.js

+22
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,28 @@ buffers.authenticationMD5Password = function () {
2828
.join(true, 'R')
2929
}
3030

31+
buffers.authenticationSASL = function () {
32+
return new BufferList()
33+
.addInt32(10)
34+
.addCString('SCRAM-SHA-256')
35+
.addCString('')
36+
.join(true, 'R')
37+
}
38+
39+
buffers.authenticationSASLContinue = function () {
40+
return new BufferList()
41+
.addInt32(11)
42+
.addString('data')
43+
.join(true, 'R')
44+
}
45+
46+
buffers.authenticationSASLFinal = function () {
47+
return new BufferList()
48+
.addInt32(12)
49+
.addString('data')
50+
.join(true, 'R')
51+
}
52+
3153
buffers.parameterStatus = function (name, value) {
3254
return new BufferList()
3355
.addCString(name)

0 commit comments

Comments
 (0)