Skip to content

Commit 65e1c72

Browse files
committed
Added support for SCRAM-SHA-256-PLUS i.e. channel binding
1 parent 373093d commit 65e1c72

File tree

6 files changed

+80
-10
lines changed

6 files changed

+80
-10
lines changed

packages/pg/lib/client.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Client extends EventEmitter {
2020
this.database = this.connectionParameters.database
2121
this.port = this.connectionParameters.port
2222
this.host = this.connectionParameters.host
23+
this.enableChannelBinding = true
2324

2425
// "hiding" the password so it doesn't show up in stack traces
2526
// or if the client is console.logged
@@ -258,7 +259,7 @@ class Client extends EventEmitter {
258259
_handleAuthSASL(msg) {
259260
this._checkPgPass(() => {
260261
try {
261-
this.saslSession = sasl.startSession(msg.mechanisms)
262+
this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream)
262263
this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response)
263264
} catch (err) {
264265
this.connection.emit('error', err)
@@ -268,7 +269,7 @@ class Client extends EventEmitter {
268269

269270
async _handleAuthSASLContinue(msg) {
270271
try {
271-
await sasl.continueSession(this.saslSession, this.password, msg.data)
272+
await sasl.continueSession(this.saslSession, this.password, msg.data, this.enableChannelBinding && this.connection.stream)
272273
this.connection.sendSCRAMClientFinalMessage(this.saslSession.response)
273274
} catch (err) {
274275
this.connection.emit('error', err)

packages/pg/lib/crypto/sasl.js

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
'use strict'
22
const crypto = require('./utils')
3+
const tls = require('tls');
34

4-
function startSession(mechanisms) {
5-
if (mechanisms.indexOf('SCRAM-SHA-256') === -1) {
6-
throw new Error('SASL: Only mechanism SCRAM-SHA-256 is currently supported')
5+
function startSession(mechanisms, stream) {
6+
const candidates = ['SCRAM-SHA-256']
7+
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first
8+
9+
let mechanism
10+
for (const candidate of candidates) {
11+
if (mechanisms.indexOf(candidate) !== -1) {
12+
mechanism = candidate
13+
break
14+
}
15+
}
16+
17+
if (!mechanism) {
18+
throw new Error('SASL: Only mechanisms ' + candidates.join(' and ') + ' are supported')
19+
}
20+
21+
if (mechanism === 'SCRAM-SHA-256-PLUS' && !(stream instanceof tls.TLSSocket)) {
22+
// this should never happen if we are really talking to a Postgres server
23+
throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a secure connection')
724
}
825

926
const clientNonce = crypto.randomBytes(18).toString('base64')
27+
const gs2Header = mechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : stream ? 'y' : 'n'
1028

1129
return {
12-
mechanism: 'SCRAM-SHA-256',
30+
mechanism,
1331
clientNonce,
14-
response: 'n,,n=*,r=' + clientNonce,
32+
response: gs2Header + ',,n=*,r=' + clientNonce,
1533
message: 'SASLInitialResponse',
1634
}
1735
}
1836

19-
async function continueSession(session, password, serverData) {
37+
async function continueSession(session, password, serverData, stream) {
2038
if (session.message !== 'SASLInitialResponse') {
2139
throw new Error('SASL: Last message was not SASLInitialResponse')
2240
}
@@ -40,7 +58,34 @@ async function continueSession(session, password, serverData) {
4058

4159
var clientFirstMessageBare = 'n=*,r=' + session.clientNonce
4260
var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
43-
var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce
61+
62+
// without channel binding:
63+
let channelBinding = stream ? 'eSws' : 'biws' // 'y,,' or 'n,,', base64-encoded
64+
65+
// override if channel binding is in use:
66+
if (session.mechanism === 'SCRAM-SHA-256-PLUS') {
67+
const peerCert = stream.getPeerCertificate().raw
68+
const x509 = await import('@peculiar/x509')
69+
const parsedCert = new x509.X509Certificate(peerCert)
70+
const sigAlgo = parsedCert.signatureAlgorithm
71+
if (!sigAlgo) {
72+
throw new Error('Could not extract signature algorithm from certificate')
73+
}
74+
const hash = sigAlgo.hash
75+
if (!hash) {
76+
throw new Error('Could not extract hash from certificate signature algorithm')
77+
}
78+
let hashName = hash.name
79+
if (!hashName) {
80+
throw new Error('Could not extract name from certificate signature algorithm hash')
81+
}
82+
if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'SHA-256' // for MD5 and SHA-1, we substitute SHA-256
83+
const certHash = await crypto.hashByName(hashName, peerCert)
84+
const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)])
85+
channelBinding = bindingData.toString('base64')
86+
}
87+
88+
var clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce
4489
var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof
4590

4691
var saltBytes = Buffer.from(sv.salt, 'base64')

packages/pg/lib/crypto/utils-legacy.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ function sha256(text) {
1919
return nodeCrypto.createHash('sha256').update(text).digest()
2020
}
2121

22+
function hashByName(hashName, text) {
23+
return nodeCrypto.createHash(hashName).update(text).digest()
24+
}
25+
2226
function hmacSha256(key, msg) {
2327
return nodeCrypto.createHmac('sha256', key).update(msg).digest()
2428
}
@@ -32,6 +36,7 @@ module.exports = {
3236
randomBytes: nodeCrypto.randomBytes,
3337
deriveKey,
3438
sha256,
39+
hashByName,
3540
hmacSha256,
3641
md5,
3742
}

packages/pg/lib/crypto/utils-webcrypto.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module.exports = {
55
randomBytes,
66
deriveKey,
77
sha256,
8+
hashByName,
89
hmacSha256,
910
md5,
1011
}
@@ -60,6 +61,10 @@ async function sha256(text) {
6061
return await subtleCrypto.digest('SHA-256', text)
6162
}
6263

64+
async function hashByName(hashName, text) {
65+
return await subtleCrypto.digest(hashName, text)
66+
}
67+
6368
/**
6469
* Sign the message with the given key
6570
* @param {ArrayBuffer} keyBuffer

packages/pg/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"author": "Brian Carlson <[email protected]>",
2121
"main": "./lib",
2222
"dependencies": {
23+
"@peculiar/x509": "^1.12.3",
2324
"pg-connection-string": "^2.7.0",
2425
"pg-pool": "^3.7.0",
2526
"pg-protocol": "^1.7.0",

packages/pg/test/integration/client/sasl-scram-tests.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,25 @@ if (!config.user || !config.password) {
4545
return
4646
}
4747

48-
suite.testAsync('can connect using sasl/scram', async () => {
48+
suite.testAsync('can connect using sasl/scram (channel binding enabled)', async () => {
4949
const client = new pg.Client(config)
5050
let usingSasl = false
5151
client.connection.once('authenticationSASL', () => {
5252
usingSasl = true
5353
})
54+
client.enableChannelBinding = true // default
55+
await client.connect()
56+
assert.ok(usingSasl, 'Should be using SASL for authentication')
57+
await client.end()
58+
})
59+
60+
suite.testAsync('can connect using sasl/scram (channel binding disabled)', async () => {
61+
const client = new pg.Client(config)
62+
let usingSasl = false
63+
client.connection.once('authenticationSASL', () => {
64+
usingSasl = true
65+
})
66+
client.enableChannelBinding = false
5467
await client.connect()
5568
assert.ok(usingSasl, 'Should be using SASL for authentication')
5669
await client.end()

0 commit comments

Comments
 (0)