Skip to content

Commit b4022aa

Browse files
jawjcharmander
andauthored
Add support for SCRAM-SHA-256-PLUS i.e. channel binding (#3356)
* Added support for SCRAM-SHA-256-PLUS i.e. channel binding * Requested tweaks to channel binding * Additional tweaks to channel binding * Fixed lint complaints * Update packages/pg/lib/crypto/sasl.js Co-authored-by: Charmander <[email protected]> * Update packages/pg/lib/crypto/sasl.js Co-authored-by: Charmander <[email protected]> * Update packages/pg/lib/client.js Co-authored-by: Charmander <[email protected]> * Tweaks to channel binding * Now using homegrown certificate signature algorithm identification * Update ssl.mdx with channel binding changes * Allow for config object being undefined when assigning enableChannelBinding * Fixed a test failing on an updated error message * Removed - from hash names like SHA-256 for legacy crypto (Node 14 and below) * Removed packageManager key from package.json * Added some SASL/channel binding unit tests * Added a unit test for continueSession to check expected SASL session data * Modify tests: don't require channel binding (which cannot then work) if not using SSL --------- Co-authored-by: Charmander <[email protected]>
1 parent 1876f20 commit b4022aa

File tree

8 files changed

+295
-20
lines changed

8 files changed

+295
-20
lines changed

docs/pages/features/ssl.mdx

+14
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,17 @@ const config = {
5151
},
5252
}
5353
```
54+
55+
## Channel binding
56+
57+
If the PostgreSQL server offers SCRAM-SHA-256-PLUS (i.e. channel binding) for TLS/SSL connections, you can enable this as follows:
58+
59+
```js
60+
const client = new Client({ ...config, enableChannelBinding: true})
61+
```
62+
63+
or
64+
65+
```js
66+
const pool = new Pool({ ...config, enableChannelBinding: true})
67+
```

packages/pg/lib/client.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Client extends EventEmitter {
4343
this._connectionError = false
4444
this._queryable = true
4545

46+
this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered
4647
this.connection =
4748
c.connection ||
4849
new Connection({
@@ -262,7 +263,7 @@ class Client extends EventEmitter {
262263
_handleAuthSASL(msg) {
263264
this._checkPgPass(() => {
264265
try {
265-
this.saslSession = sasl.startSession(msg.mechanisms)
266+
this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream)
266267
this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response)
267268
} catch (err) {
268269
this.connection.emit('error', err)
@@ -272,7 +273,12 @@ class Client extends EventEmitter {
272273

273274
async _handleAuthSASLContinue(msg) {
274275
try {
275-
await sasl.continueSession(this.saslSession, this.password, msg.data)
276+
await sasl.continueSession(
277+
this.saslSession,
278+
this.password,
279+
msg.data,
280+
this.enableChannelBinding && this.connection.stream
281+
)
276282
this.connection.sendSCRAMClientFinalMessage(this.saslSession.response)
277283
} catch (err) {
278284
this.connection.emit('error', err)
+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
function x509Error(msg, cert) {
2+
throw new Error('SASL channel binding: ' + msg + ' when parsing public certificate ' + cert.toString('base64'))
3+
}
4+
5+
function readASN1Length(data, index) {
6+
let length = data[index++]
7+
if (length < 0x80) return { length, index }
8+
9+
const lengthBytes = length & 0x7f
10+
if (lengthBytes > 4) x509Error('bad length', data)
11+
12+
length = 0
13+
for (let i = 0; i < lengthBytes; i++) {
14+
length = (length << 8) | data[index++]
15+
}
16+
17+
return { length, index }
18+
}
19+
20+
function readASN1OID(data, index) {
21+
if (data[index++] !== 0x6) x509Error('non-OID data', data) // 6 = OID
22+
23+
const { length: OIDLength, index: indexAfterOIDLength } = readASN1Length(data, index)
24+
index = indexAfterOIDLength
25+
lastIndex = index + OIDLength
26+
27+
const byte1 = data[index++]
28+
let oid = ((byte1 / 40) >> 0) + '.' + (byte1 % 40)
29+
30+
while (index < lastIndex) {
31+
// loop over numbers in OID
32+
let value = 0
33+
while (index < lastIndex) {
34+
// loop over bytes in number
35+
const nextByte = data[index++]
36+
value = (value << 7) | (nextByte & 0x7f)
37+
if (nextByte < 0x80) break
38+
}
39+
oid += '.' + value
40+
}
41+
42+
return { oid, index }
43+
}
44+
45+
function expectASN1Seq(data, index) {
46+
if (data[index++] !== 0x30) x509Error('non-sequence data', data) // 30 = Sequence
47+
return readASN1Length(data, index)
48+
}
49+
50+
function signatureAlgorithmHashFromCertificate(data, index) {
51+
// read this thread: https://www.postgresql.org/message-id/17760-b6c61e752ec07060%40postgresql.org
52+
if (index === undefined) index = 0
53+
index = expectASN1Seq(data, index).index
54+
const { length: certInfoLength, index: indexAfterCertInfoLength } = expectASN1Seq(data, index)
55+
index = indexAfterCertInfoLength + certInfoLength // skip over certificate info
56+
index = expectASN1Seq(data, index).index // skip over signature length field
57+
const { oid, index: indexAfterOID } = readASN1OID(data, index)
58+
switch (oid) {
59+
// RSA
60+
case '1.2.840.113549.1.1.4':
61+
return 'MD5'
62+
case '1.2.840.113549.1.1.5':
63+
return 'SHA-1'
64+
case '1.2.840.113549.1.1.11':
65+
return 'SHA-256'
66+
case '1.2.840.113549.1.1.12':
67+
return 'SHA-384'
68+
case '1.2.840.113549.1.1.13':
69+
return 'SHA-512'
70+
case '1.2.840.113549.1.1.14':
71+
return 'SHA-224'
72+
case '1.2.840.113549.1.1.15':
73+
return 'SHA512-224'
74+
case '1.2.840.113549.1.1.16':
75+
return 'SHA512-256'
76+
// ECDSA
77+
case '1.2.840.10045.4.1':
78+
return 'SHA-1'
79+
case '1.2.840.10045.4.3.1':
80+
return 'SHA-224'
81+
case '1.2.840.10045.4.3.2':
82+
return 'SHA-256'
83+
case '1.2.840.10045.4.3.3':
84+
return 'SHA-384'
85+
case '1.2.840.10045.4.3.4':
86+
return 'SHA-512'
87+
// RSASSA-PSS: hash is indicated separately
88+
case '1.2.840.113549.1.1.10':
89+
index = indexAfterOID
90+
index = expectASN1Seq(data, index).index
91+
if (data[index++] !== 0xa0) x509Error('non-tag data', data) // a0 = constructed tag 0
92+
index = readASN1Length(data, index).index // skip over tag length field
93+
index = expectASN1Seq(data, index).index // skip over sequence length field
94+
const { oid: hashOID } = readASN1OID(data, index)
95+
switch (hashOID) {
96+
// standalone hash OIDs
97+
case '1.2.840.113549.2.5':
98+
return 'MD5'
99+
case '1.3.14.3.2.26':
100+
return 'SHA-1'
101+
case '2.16.840.1.101.3.4.2.1':
102+
return 'SHA-256'
103+
case '2.16.840.1.101.3.4.2.2':
104+
return 'SHA-384'
105+
case '2.16.840.1.101.3.4.2.3':
106+
return 'SHA-512'
107+
}
108+
x509Error('unknown hash OID ' + hashOID, data)
109+
// Ed25519 -- see https: return//github.com/openssl/openssl/issues/15477
110+
case '1.3.101.110':
111+
case '1.3.101.112': // ph
112+
return 'SHA-512'
113+
// Ed448 -- still not in pg 17.2 (if supported, digest would be SHAKE256 x 64 bytes)
114+
case '1.3.101.111':
115+
case '1.3.101.113': // ph
116+
x509Error('Ed448 certificate channel binding is not currently supported by Postgres')
117+
}
118+
x509Error('unknown OID ' + oid, data)
119+
}
120+
121+
module.exports = { signatureAlgorithmHashFromCertificate }

packages/pg/lib/crypto/sasl.js

+33-7
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
'use strict'
22
const crypto = require('./utils')
3+
const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures')
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+
const mechanism = candidates.find((candidate) => mechanisms.includes(candidate))
10+
11+
if (!mechanism) {
12+
throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported')
13+
}
14+
15+
if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') {
16+
// this should never happen if we are really talking to a Postgres server
17+
throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate')
718
}
819

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

1123
return {
12-
mechanism: 'SCRAM-SHA-256',
24+
mechanism,
1325
clientNonce,
14-
response: 'n,,n=*,r=' + clientNonce,
26+
response: gs2Header + ',,n=*,r=' + clientNonce,
1527
message: 'SASLInitialResponse',
1628
}
1729
}
1830

19-
async function continueSession(session, password, serverData) {
31+
async function continueSession(session, password, serverData, stream) {
2032
if (session.message !== 'SASLInitialResponse') {
2133
throw new Error('SASL: Last message was not SASLInitialResponse')
2234
}
@@ -40,7 +52,21 @@ async function continueSession(session, password, serverData) {
4052

4153
var clientFirstMessageBare = 'n=*,r=' + session.clientNonce
4254
var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
43-
var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce
55+
56+
// without channel binding:
57+
let channelBinding = stream ? 'eSws' : 'biws' // 'y,,' or 'n,,', base64-encoded
58+
59+
// override if channel binding is in use:
60+
if (session.mechanism === 'SCRAM-SHA-256-PLUS') {
61+
const peerCert = stream.getPeerCertificate().raw
62+
let hashName = signatureAlgorithmHashFromCertificate(peerCert)
63+
if (hashName === 'MD5' || hashName === 'SHA-1') hashName = 'SHA-256'
64+
const certHash = await crypto.hashByName(hashName, peerCert)
65+
const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)])
66+
channelBinding = bindingData.toString('base64')
67+
}
68+
69+
var clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce
4470
var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof
4571

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

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

+6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function sha256(text) {
1919
return nodeCrypto.createHash('sha256').update(text).digest()
2020
}
2121

22+
function hashByName(hashName, text) {
23+
hashName = hashName.replace(/(\D)-/, '$1') // e.g. SHA-256 -> SHA256
24+
return nodeCrypto.createHash(hashName).update(text).digest()
25+
}
26+
2227
function hmacSha256(key, msg) {
2328
return nodeCrypto.createHmac('sha256', key).update(msg).digest()
2429
}
@@ -32,6 +37,7 @@ module.exports = {
3237
randomBytes: nodeCrypto.randomBytes,
3338
deriveKey,
3439
sha256,
40+
hashByName,
3541
hmacSha256,
3642
md5,
3743
}

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

+5
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/test/integration/client/sasl-scram-tests.js

+19-6
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,27 @@ if (!config.user || !config.password) {
4545
return
4646
}
4747

48-
suite.testAsync('can connect using sasl/scram', async () => {
49-
const client = new pg.Client(config)
50-
let usingSasl = false
51-
client.connection.once('authenticationSASL', () => {
52-
usingSasl = true
48+
suite.testAsync('can connect using sasl/scram with channel binding enabled (if using SSL)', async () => {
49+
const client = new pg.Client({ ...config, enableChannelBinding: true })
50+
let usingChannelBinding = false
51+
let hasPeerCert = false
52+
client.connection.once('authenticationSASLContinue', () => {
53+
hasPeerCert = client.connection.stream.getPeerCertificate === 'function'
54+
usingChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256-PLUS'
5355
})
5456
await client.connect()
55-
assert.ok(usingSasl, 'Should be using SASL for authentication')
57+
assert.ok(usingChannelBinding || !hasPeerCert, 'Should be using SCRAM-SHA-256-PLUS for authentication if using SSL')
58+
await client.end()
59+
})
60+
61+
suite.testAsync('can connect using sasl/scram with channel binding disabled', async () => {
62+
const client = new pg.Client({ ...config, enableChannelBinding: false })
63+
let usingSASLWithoutChannelBinding = false
64+
client.connection.once('authenticationSASLContinue', () => {
65+
usingSASLWithoutChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256'
66+
})
67+
await client.connect()
68+
assert.ok(usingSASLWithoutChannelBinding, 'Should be using SCRAM-SHA-256 (no channel binding) for authentication')
5669
await client.end()
5770
})
5871

0 commit comments

Comments
 (0)