mirror of
https://github.com/brianc/node-postgres.git
synced 2025-12-08 20:16:25 +00:00
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 <~@charmander.me> * Update packages/pg/lib/crypto/sasl.js Co-authored-by: Charmander <~@charmander.me> * Update packages/pg/lib/client.js Co-authored-by: Charmander <~@charmander.me> * 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 <~@charmander.me>
This commit is contained in:
parent
1876f2000a
commit
b4022aa5c0
@ -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})
|
||||
```
|
||||
|
||||
@ -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({
|
||||
@ -262,7 +263,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)
|
||||
@ -272,7 +273,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)
|
||||
|
||||
121
packages/pg/lib/crypto/cert-signatures.js
Normal file
121
packages/pg/lib/crypto/cert-signatures.js
Normal file
@ -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 }
|
||||
@ -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')
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -32,6 +37,7 @@ module.exports = {
|
||||
randomBytes: nodeCrypto.randomBytes,
|
||||
deriveKey,
|
||||
sha256,
|
||||
hashByName,
|
||||
hmacSha256,
|
||||
md5,
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ module.exports = {
|
||||
randomBytes,
|
||||
deriveKey,
|
||||
sha256,
|
||||
hashByName,
|
||||
hmacSha256,
|
||||
md5,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -45,14 +45,27 @@ 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 with channel binding enabled (if using SSL)', async () => {
|
||||
const client = new pg.Client({ ...config, enableChannelBinding: true })
|
||||
let usingChannelBinding = false
|
||||
let hasPeerCert = false
|
||||
client.connection.once('authenticationSASLContinue', () => {
|
||||
hasPeerCert = client.connection.stream.getPeerCertificate === 'function'
|
||||
usingChannelBinding = client.saslSession.mechanism === 'SCRAM-SHA-256-PLUS'
|
||||
})
|
||||
await client.connect()
|
||||
assert.ok(usingSasl, 'Should be using SASL for authentication')
|
||||
assert.ok(usingChannelBinding || !hasPeerCert, 'Should be using SCRAM-SHA-256-PLUS for authentication if using SSL')
|
||||
await client.end()
|
||||
})
|
||||
|
||||
suite.testAsync('can connect using sasl/scram with 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()
|
||||
})
|
||||
|
||||
|
||||
@ -14,19 +14,39 @@ suite.test('sasl/scram', function () {
|
||||
sasl.startSession([])
|
||||
},
|
||||
{
|
||||
message: 'SASL: Only mechanism SCRAM-SHA-256 is currently supported',
|
||||
message: 'SASL: Only mechanism(s) SCRAM-SHA-256 are supported',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
suite.test('returns expected session data', function () {
|
||||
const session = sasl.startSession(['SCRAM-SHA-256'])
|
||||
suite.test('returns expected session data for SCRAM-SHA-256 (channel binding disabled, offered)', function () {
|
||||
const session = sasl.startSession(['SCRAM-SHA-256', 'SCRAM-SHA-256-PLUS'])
|
||||
|
||||
assert.equal(session.mechanism, 'SCRAM-SHA-256')
|
||||
assert.equal(String(session.clientNonce).length, 24)
|
||||
assert.equal(session.message, 'SASLInitialResponse')
|
||||
|
||||
assert(session.response.match(/^n,,n=\*,r=.{24}/))
|
||||
assert(session.response.match(/^n,,n=\*,r=.{24}$/))
|
||||
})
|
||||
|
||||
suite.test('returns expected session data for SCRAM-SHA-256 (channel binding enabled, not offered)', function () {
|
||||
const session = sasl.startSession(['SCRAM-SHA-256'], { getPeerCertificate() {} })
|
||||
|
||||
assert.equal(session.mechanism, 'SCRAM-SHA-256')
|
||||
assert.equal(String(session.clientNonce).length, 24)
|
||||
assert.equal(session.message, 'SASLInitialResponse')
|
||||
|
||||
assert(session.response.match(/^y,,n=\*,r=.{24}$/))
|
||||
})
|
||||
|
||||
suite.test('returns expected session data for SCRAM-SHA-256 (channel binding enabled, offered)', function () {
|
||||
const session = sasl.startSession(['SCRAM-SHA-256', 'SCRAM-SHA-256-PLUS'], { getPeerCertificate() {} })
|
||||
|
||||
assert.equal(session.mechanism, 'SCRAM-SHA-256-PLUS')
|
||||
assert.equal(String(session.clientNonce).length, 24)
|
||||
assert.equal(session.message, 'SASLInitialResponse')
|
||||
|
||||
assert(session.response.match(/^p=tls-server-end-point,,n=\*,r=.{24}$/))
|
||||
})
|
||||
|
||||
suite.test('creates random nonces', function () {
|
||||
@ -156,7 +176,7 @@ suite.test('sasl/scram', function () {
|
||||
)
|
||||
})
|
||||
|
||||
suite.testAsync('sets expected session data', async function () {
|
||||
suite.testAsync('sets expected session data (SCRAM-SHA-256)', async function () {
|
||||
const session = {
|
||||
message: 'SASLInitialResponse',
|
||||
clientNonce: 'a',
|
||||
@ -169,6 +189,70 @@ suite.test('sasl/scram', function () {
|
||||
|
||||
assert.equal(session.response, 'c=biws,r=ab,p=mU8grLfTjDrJer9ITsdHk0igMRDejG10EJPFbIBL3D0=')
|
||||
})
|
||||
|
||||
suite.testAsync('sets expected session data (SCRAM-SHA-256, channel binding enabled)', async function () {
|
||||
const session = {
|
||||
message: 'SASLInitialResponse',
|
||||
clientNonce: 'a',
|
||||
}
|
||||
|
||||
await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1', { getPeerCertificate() {} })
|
||||
|
||||
assert.equal(session.message, 'SASLResponse')
|
||||
assert.equal(session.serverSignature, 'ETpURSc5OpddrPRSW3LaDPJzUzhh+rciM4uYwXSsohU=')
|
||||
|
||||
assert.equal(session.response, 'c=eSws,r=ab,p=YVTEOwOD7khu/NulscjFegHrZoTXJBFI/7L61AN9khc=')
|
||||
})
|
||||
|
||||
suite.testAsync('sets expected session data (SCRAM-SHA-256-PLUS)', async function () {
|
||||
const session = {
|
||||
message: 'SASLInitialResponse',
|
||||
mechanism: 'SCRAM-SHA-256-PLUS',
|
||||
clientNonce: 'a',
|
||||
}
|
||||
|
||||
await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=1', {
|
||||
getPeerCertificate() {
|
||||
return {
|
||||
raw: Buffer.from([
|
||||
// a minimal ASN.1 certificate structure which can be parsed for a hash type
|
||||
0x30, // cert ASN.1 seq
|
||||
0x16, // cert length (all bytes below)
|
||||
0x30, // cert info ASN.1 seq
|
||||
0x01, // cert info length
|
||||
0x00, // cert info (skipped)
|
||||
0x30, // signature algorithm ASN.1 seq
|
||||
0x0d, // signature algorithm length
|
||||
0x06, // ASN.1 OID
|
||||
0x09, // OID length
|
||||
0x2a, // OID: 1.2.840.113549.1.1.11 (RSASSA-PKCS1-v1_5 / SHA-256)
|
||||
0x86,
|
||||
0x48,
|
||||
0x86,
|
||||
0xf7,
|
||||
0x0d,
|
||||
0x01,
|
||||
0x01,
|
||||
0x0b,
|
||||
0x05, // ASN.1 null (no algorithm parameters)
|
||||
0x00, // null length
|
||||
0x03, // ASN.1 bitstring (signature)
|
||||
0x02, // bitstring length
|
||||
0x00, // zero right-padding bits
|
||||
0xff, // one-byte signature
|
||||
]),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(session.message, 'SASLResponse')
|
||||
assert.equal(session.serverSignature, 'pU1hc6JkjvjO8Wd+o0/jyGjc1DpITtsx1UF+ZPa5u5M=')
|
||||
|
||||
assert.equal(
|
||||
session.response,
|
||||
'c=cD10bHMtc2VydmVyLWVuZC1wb2ludCwsmwepqKDDRcOvo3BN0rplYMfLUTpbaf38btkM5aAXBhQ=,r=ab,p=j0v2LsthoNaIBrKV4YipskF/lV8zWEt6acNRtt99MA4='
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
suite.test('finalizeSession', function () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user