Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for SCRAM-SHA-256-PLUS i.e. channel binding #3356

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/pages/features/ssl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,17 @@ const config = {
},
}
```

## Channel binding

If the PostgreSQL server offers SCRAM-SHA-256-PLUS (i.e. channel binding) for TLS/SSL connections, you can enable this as follows:

```js
const client = new Client({ ...config, enableChannelBinding: true})
```

or

```js
const pool = new Pool({ ...config, enableChannelBinding: true})
```
10 changes: 8 additions & 2 deletions packages/pg/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Client extends EventEmitter {
this._connectionError = false
this._queryable = true

this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered
this.connection =
c.connection ||
new Connection({
Expand Down Expand Up @@ -258,7 +259,7 @@ class Client extends EventEmitter {
_handleAuthSASL(msg) {
this._checkPgPass(() => {
try {
this.saslSession = sasl.startSession(msg.mechanisms)
this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream)
this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response)
} catch (err) {
this.connection.emit('error', err)
Expand All @@ -268,7 +269,12 @@ class Client extends EventEmitter {

async _handleAuthSASLContinue(msg) {
try {
await sasl.continueSession(this.saslSession, this.password, msg.data)
await sasl.continueSession(
this.saslSession,
this.password,
msg.data,
this.enableChannelBinding && this.connection.stream
)
this.connection.sendSCRAMClientFinalMessage(this.saslSession.response)
} catch (err) {
this.connection.emit('error', err)
Expand Down
121 changes: 121 additions & 0 deletions packages/pg/lib/crypto/cert-signatures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
function x509Error(msg, cert) {
throw new Error('SASL channel binding: ' + msg + ' when parsing public certificate ' + cert.toString('base64'))
}

function readASN1Length(data, index) {
let length = data[index++]
if (length < 0x80) return { length, index }

const lengthBytes = length & 0x7f
if (lengthBytes > 4) x509Error('bad length', data)

length = 0
for (let i = 0; i < lengthBytes; i++) {
length = (length << 8) | data[index++]
}

return { length, index }
}

function readASN1OID(data, index) {
if (data[index++] !== 0x6) x509Error('non-OID data', data) // 6 = OID

const { length: OIDLength, index: indexAfterOIDLength } = readASN1Length(data, index)
index = indexAfterOIDLength
lastIndex = index + OIDLength

const byte1 = data[index++]
let oid = ((byte1 / 40) >> 0) + '.' + (byte1 % 40)

while (index < lastIndex) {
// loop over numbers in OID
let value = 0
while (index < lastIndex) {
// loop over bytes in number
const nextByte = data[index++]
value = (value << 7) | (nextByte & 0x7f)
if (nextByte < 0x80) break
}
oid += '.' + value
}

return { oid, index }
}

function expectASN1Seq(data, index) {
if (data[index++] !== 0x30) x509Error('non-sequence data', data) // 30 = Sequence
return readASN1Length(data, index)
}

function signatureAlgorithmHashFromCertificate(data, index) {
// read this thread: https://www.postgresql.org/message-id/17760-b6c61e752ec07060%40postgresql.org
if (index === undefined) index = 0
index = expectASN1Seq(data, index).index
const { length: certInfoLength, index: indexAfterCertInfoLength } = expectASN1Seq(data, index)
index = indexAfterCertInfoLength + certInfoLength // skip over certificate info
index = expectASN1Seq(data, index).index // skip over signature length field
const { oid, index: indexAfterOID } = readASN1OID(data, index)
switch (oid) {
// RSA
case '1.2.840.113549.1.1.4':
return 'MD5'
case '1.2.840.113549.1.1.5':
return 'SHA-1'
case '1.2.840.113549.1.1.11':
return 'SHA-256'
case '1.2.840.113549.1.1.12':
return 'SHA-384'
case '1.2.840.113549.1.1.13':
return 'SHA-512'
case '1.2.840.113549.1.1.14':
return 'SHA-224'
case '1.2.840.113549.1.1.15':
return 'SHA512-224'
case '1.2.840.113549.1.1.16':
return 'SHA512-256'
// ECDSA
case '1.2.840.10045.4.1':
return 'SHA-1'
case '1.2.840.10045.4.3.1':
return 'SHA-224'
case '1.2.840.10045.4.3.2':
return 'SHA-256'
case '1.2.840.10045.4.3.3':
return 'SHA-384'
case '1.2.840.10045.4.3.4':
return 'SHA-512'
// RSASSA-PSS: hash is indicated separately
case '1.2.840.113549.1.1.10':
index = indexAfterOID
index = expectASN1Seq(data, index).index
if (data[index++] !== 0xa0) x509Error('non-tag data', data) // a0 = constructed tag 0
index = readASN1Length(data, index).index // skip over tag length field
index = expectASN1Seq(data, index).index // skip over sequence length field
const { oid: hashOID } = readASN1OID(data, index)
switch (hashOID) {
// standalone hash OIDs
case '1.2.840.113549.2.5':
return 'MD5'
case '1.3.14.3.2.26':
return 'SHA-1'
case '2.16.840.1.101.3.4.2.1':
return 'SHA-256'
case '2.16.840.1.101.3.4.2.2':
return 'SHA-384'
case '2.16.840.1.101.3.4.2.3':
return 'SHA-512'
}
x509Error('unknown hash OID ' + hashOID, data)
// Ed25519 -- see https: return//github.com/openssl/openssl/issues/15477
case '1.3.101.110':
case '1.3.101.112': // ph
return 'SHA-512'
// Ed448 -- still not in pg 17.2 (if supported, digest would be SHAKE256 x 64 bytes)
case '1.3.101.111':
case '1.3.101.113': // ph
x509Error('Ed448 certificate channel binding is not currently supported by Postgres')
}
x509Error('unknown OID ' + oid, data)
}

module.exports = { signatureAlgorithmHashFromCertificate }
40 changes: 33 additions & 7 deletions packages/pg/lib/crypto/sasl.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
'use strict'
const crypto = require('./utils')
const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures')

function startSession(mechanisms) {
if (mechanisms.indexOf('SCRAM-SHA-256') === -1) {
throw new Error('SASL: Only mechanism SCRAM-SHA-256 is currently supported')
function startSession(mechanisms, stream) {
const candidates = ['SCRAM-SHA-256']
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first

const mechanism = candidates.find((candidate) => mechanisms.includes(candidate))

if (!mechanism) {
throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported')
}

if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') {
// this should never happen if we are really talking to a Postgres server
throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate')
}

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

return {
mechanism: 'SCRAM-SHA-256',
mechanism,
clientNonce,
response: 'n,,n=*,r=' + clientNonce,
response: gs2Header + ',,n=*,r=' + clientNonce,
message: 'SASLInitialResponse',
}
}

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

var clientFirstMessageBare = 'n=*,r=' + session.clientNonce
var serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
var clientFinalMessageWithoutProof = 'c=biws,r=' + sv.nonce

// without channel binding:
let channelBinding = stream ? 'eSws' : 'biws' // 'y,,' or 'n,,', base64-encoded

// override if channel binding is in use:
if (session.mechanism === 'SCRAM-SHA-256-PLUS') {
const peerCert = stream.getPeerCertificate().raw
let hashName = signatureAlgorithmHashFromCertificate(peerCert)
if (hashName === 'MD5' || hashName === 'SHA-1') hashName = 'SHA-256'
const certHash = await crypto.hashByName(hashName, peerCert)
const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)])
channelBinding = bindingData.toString('base64')
}

var clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce
var authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof

var saltBytes = Buffer.from(sv.salt, 'base64')
Expand Down
6 changes: 6 additions & 0 deletions packages/pg/lib/crypto/utils-legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ function sha256(text) {
return nodeCrypto.createHash('sha256').update(text).digest()
}

function hashByName(hashName, text) {
hashName = hashName.replace(/(\D)-/, '$1') // e.g. SHA-256 -> SHA256
return nodeCrypto.createHash(hashName).update(text).digest()
}

function hmacSha256(key, msg) {
return nodeCrypto.createHmac('sha256', key).update(msg).digest()
}
Expand All @@ -32,6 +37,7 @@ module.exports = {
randomBytes: nodeCrypto.randomBytes,
deriveKey,
sha256,
hashByName,
hmacSha256,
md5,
}
5 changes: 5 additions & 0 deletions packages/pg/lib/crypto/utils-webcrypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module.exports = {
randomBytes,
deriveKey,
sha256,
hashByName,
hmacSha256,
md5,
}
Expand Down Expand Up @@ -60,6 +61,10 @@ async function sha256(text) {
return await subtleCrypto.digest('SHA-256', text)
}

async function hashByName(hashName, text) {
return await subtleCrypto.digest(hashName, text)
}

/**
* Sign the message with the given key
* @param {ArrayBuffer} keyBuffer
Expand Down
23 changes: 17 additions & 6 deletions packages/pg/test/integration/client/sasl-scram-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,25 @@ if (!config.user || !config.password) {
return
}

suite.testAsync('can connect using sasl/scram', async () => {
const client = new pg.Client(config)
let usingSasl = false
client.connection.once('authenticationSASL', () => {
usingSasl = true
suite.testAsync('can connect using sasl/scram (channel binding enabled)', async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests would work just as well if there were no implementation of channel binding at all, so this PR probably needs a targeted test from someone.

Copy link
Author

@jawj jawj Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in an ideal world we would test using SSL certs with a variety of different signature algorithms. But I'm not sure how easily that fits with the current test setup.

Locally, I have been using:

#!/bin/zsh
docker run --rm --name postgres-tls-cert \
  -p 5432:5432 \
  -e POSTGRES_USER=frodo \
  -e POSTGRES_PASSWORD=friend \
  -v ./certs:/etc/pgssl \
  postgres:17.2-bookworm \
  -c ssl=on \
  -c ssl_cert_file=/etc/pgssl/server-$1-cert.pem \
  -c ssl_key_file=/etc/pgssl/server-$1-key.pem

Which is run like: ./docker-pg.sh ecdsa

  • And this test:
SCRAM_TEST_PGDATABASE=frodo \                                          
  SCRAM_TEST_PGUSER=frodo \
  SCRAM_TEST_PGPASSWORD=friend \
  PGSSLMODE=no-verify \
  node --tls-keylog=/path/to/keylog.txt packages/pg/test/integration/client/sasl-scram-tests.js

(The --tls-keylog enables the use of Wireshark if needed).

const client = new pg.Client({ ...config, enableChannelBinding: true })
let usingChannelBinding = false
client.connection.once('authenticationSASLContinue', () => {
usingChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256-PLUS'
})
await client.connect()
assert.ok(usingSasl, 'Should be using SASL for authentication')
assert.ok(usingChannelBinding, 'Should be using SCRAM-SHA-256-PLUS for authentication')
await client.end()
})

suite.testAsync('can connect using sasl/scram (channel binding disabled)', async () => {
const client = new pg.Client({ ...config, enableChannelBinding: false })
let usingSASLWithoutChannelBinding = false
client.connection.once('authenticationSASLContinue', () => {
usingSASLWithoutChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256'
})
await client.connect()
assert.ok(usingSASLWithoutChannelBinding, 'Should be using SCRAM-SHA-256 (no channel binding) for authentication')
await client.end()
})

Expand Down
Loading
Loading