From aed30fdc8e4ca573adab8081ee11834a3a0867d7 Mon Sep 17 00:00:00 2001 From: George MacKerron Date: Sat, 11 Jan 2025 22:19:18 +0000 Subject: [PATCH] Add SCRAM-SHA-256-PLUS support --- cf/src/connection.js | 43 +++++++++++++++++++++++++++++++++++++----- cf/src/index.js | 2 +- cjs/src/connection.js | 43 +++++++++++++++++++++++++++++++++++++----- cjs/src/index.js | 2 +- deno/README.md | 23 +++++++++++++--------- deno/src/connection.js | 43 +++++++++++++++++++++++++++++++++++++----- deno/src/index.js | 2 +- package.json | 5 ++++- src/connection.js | 42 ++++++++++++++++++++++++++++++++++++----- 9 files changed, 172 insertions(+), 33 deletions(-) diff --git a/cf/src/connection.js b/cf/src/connection.js index ee8b1e69..516becfc 100644 --- a/cf/src/connection.js +++ b/cf/src/connection.js @@ -94,6 +94,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , delay = 0 , rows = 0 , serverSignature = null + , saslMechanism = null , nextWriteTimer = null , terminated = false , incomings = null @@ -680,11 +681,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ) } - async function SASL() { + async function SASL(x) { + const length = x.readUInt32BE(1) + const mechanisms = x.subarray(9, length - 1).toString('utf8').split('\x00') // `length - 1` excludes 2 terminal nulls (string and list) + + for (const m of mechanisms) { + if (m === 'SCRAM-SHA-256-PLUS' && socket instanceof tls.TLSSocket) { + saslMechanism = m + break + } + if (m === 'SCRAM-SHA-256') saslMechanism = m + } + if (!saslMechanism) errored(Errors.generic('SASL_MECHANISMS_UNSUPPORTED', 'No supported SASL mechanism was offered')) + + const gs2Header = saslMechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : 'y' nonce = (await crypto.randomBytes(18)).toString('base64') - b().p().str('SCRAM-SHA-256' + b.N) + + b().p().str(saslMechanism + b.N) const i = b.i - write(b.inc(4).str('n,,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) + write(b.inc(4).str(gs2Header + ',,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) } async function SASLContinue(x) { @@ -699,13 +714,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const clientKey = await hmac(saltedPassword, 'Client Key') + let channelBinding = 'eSws' // 'y,,' base64-encoded + if (saslMechanism === 'SCRAM-SHA-256-PLUS') { + const peerCert = socket.getPeerCertificate().raw + const x509 = await import('@peculiar/x509') + const parsedCert = new x509.X509Certificate(peerCert) + const sigAlgo = parsedCert.signatureAlgorithm + if (!sigAlgo || !sigAlgo.hash || !sigAlgo.hash.name) errored(Errors.generic('SASL_CERT_ERROR', 'Unable to identify certificate digest type for channel binding')) + let hashName = sigAlgo.hash.name; + if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'sha256' // for MD5 and SHA-1, we substitute SHA-256 + const certHash = await namedDigest(hashName, peerCert) + const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) + channelBinding = bindingData.toString('base64') + } + const auth = 'n=*,r=' + nonce + ',' + 'r=' + res.r + ',s=' + res.s + ',i=' + res.i - + ',c=biws,r=' + res.r + + ',c=' + channelBinding + ',r=' + res.r serverSignature = (await hmac(await hmac(saltedPassword, 'Server Key'), auth)).toString('base64') - const payload = 'c=biws,r=' + res.r + ',p=' + xor( + const payload = 'c=' + channelBinding + ',r=' + res.r + ',p=' + xor( clientKey, Buffer.from(await hmac(await sha256(clientKey), auth)) ).toString('base64') @@ -1007,6 +1036,10 @@ function sha256(x) { return crypto.createHash('sha256').update(x).digest() } +function namedDigest(name, x) { + return crypto.createHash(name).update(x).digest() +} + function xor(a, b) { const length = Math.max(a.length, b.length) const buffer = Buffer.allocUnsafe(length) diff --git a/cf/src/index.js b/cf/src/index.js index d24e9f9c..d27ea0c6 100644 --- a/cf/src/index.js +++ b/cf/src/index.js @@ -481,7 +481,7 @@ function parseOptions(a, b) { {} ), connection : { - application_name: 'postgres.js', + application_name: env.PGAPPNAME || 'postgres.js', ...o.connection, ...Object.entries(query).reduce((acc, [k, v]) => (k in defaults || (acc[k] = v), acc), {}) }, diff --git a/cjs/src/connection.js b/cjs/src/connection.js index f7f58d14..8c08adaa 100644 --- a/cjs/src/connection.js +++ b/cjs/src/connection.js @@ -92,6 +92,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , delay = 0 , rows = 0 , serverSignature = null + , saslMechanism = null , nextWriteTimer = null , terminated = false , incomings = null @@ -678,11 +679,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ) } - async function SASL() { + async function SASL(x) { + const length = x.readUInt32BE(1) + const mechanisms = x.subarray(9, length - 1).toString('utf8').split('\x00') // `length - 1` excludes 2 terminal nulls (string and list) + + for (const m of mechanisms) { + if (m === 'SCRAM-SHA-256-PLUS' && socket instanceof tls.TLSSocket) { + saslMechanism = m + break + } + if (m === 'SCRAM-SHA-256') saslMechanism = m + } + if (!saslMechanism) errored(Errors.generic('SASL_MECHANISMS_UNSUPPORTED', 'No supported SASL mechanism was offered')) + + const gs2Header = saslMechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : 'y' nonce = (await crypto.randomBytes(18)).toString('base64') - b().p().str('SCRAM-SHA-256' + b.N) + + b().p().str(saslMechanism + b.N) const i = b.i - write(b.inc(4).str('n,,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) + write(b.inc(4).str(gs2Header + ',,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) } async function SASLContinue(x) { @@ -697,13 +712,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const clientKey = await hmac(saltedPassword, 'Client Key') + let channelBinding = 'eSws' // 'y,,' base64-encoded + if (saslMechanism === 'SCRAM-SHA-256-PLUS') { + const peerCert = socket.getPeerCertificate().raw + const x509 = await import('@peculiar/x509') + const parsedCert = new x509.X509Certificate(peerCert) + const sigAlgo = parsedCert.signatureAlgorithm + if (!sigAlgo || !sigAlgo.hash || !sigAlgo.hash.name) errored(Errors.generic('SASL_CERT_ERROR', 'Unable to identify certificate digest type for channel binding')) + let hashName = sigAlgo.hash.name; + if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'sha256' // for MD5 and SHA-1, we substitute SHA-256 + const certHash = await namedDigest(hashName, peerCert) + const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) + channelBinding = bindingData.toString('base64') + } + const auth = 'n=*,r=' + nonce + ',' + 'r=' + res.r + ',s=' + res.s + ',i=' + res.i - + ',c=biws,r=' + res.r + + ',c=' + channelBinding + ',r=' + res.r serverSignature = (await hmac(await hmac(saltedPassword, 'Server Key'), auth)).toString('base64') - const payload = 'c=biws,r=' + res.r + ',p=' + xor( + const payload = 'c=' + channelBinding + ',r=' + res.r + ',p=' + xor( clientKey, Buffer.from(await hmac(await sha256(clientKey), auth)) ).toString('base64') @@ -1005,6 +1034,10 @@ function sha256(x) { return crypto.createHash('sha256').update(x).digest() } +function namedDigest(name, x) { + return crypto.createHash(name).update(x).digest() +} + function xor(a, b) { const length = Math.max(a.length, b.length) const buffer = Buffer.allocUnsafe(length) diff --git a/cjs/src/index.js b/cjs/src/index.js index 40ac2c18..8f8d68df 100644 --- a/cjs/src/index.js +++ b/cjs/src/index.js @@ -480,7 +480,7 @@ function parseOptions(a, b) { {} ), connection : { - application_name: 'postgres.js', + application_name: env.PGAPPNAME || 'postgres.js', ...o.connection, ...Object.entries(query).reduce((acc, [k, v]) => (k in defaults || (acc[k] = v), acc), {}) }, diff --git a/deno/README.md b/deno/README.md index 6f8085cf..4e4ac618 100644 --- a/deno/README.md +++ b/deno/README.md @@ -1121,20 +1121,25 @@ It is also possible to connect to the database without a connection string or an const sql = postgres() ``` -| Option | Environment Variables | -| ----------------- | ------------------------ | -| `host` | `PGHOST` | -| `port` | `PGPORT` | -| `database` | `PGDATABASE` | -| `username` | `PGUSERNAME` or `PGUSER` | -| `password` | `PGPASSWORD` | -| `idle_timeout` | `PGIDLE_TIMEOUT` | -| `connect_timeout` | `PGCONNECT_TIMEOUT` | +| Option | Environment Variables | +| ------------------ | ------------------------ | +| `host` | `PGHOST` | +| `port` | `PGPORT` | +| `database` | `PGDATABASE` | +| `username` | `PGUSERNAME` or `PGUSER` | +| `password` | `PGPASSWORD` | +| `application_name` | `PGAPPNAME` | +| `idle_timeout` | `PGIDLE_TIMEOUT` | +| `connect_timeout` | `PGCONNECT_TIMEOUT` | ### Prepared statements Prepared statements will automatically be created for any queries where it can be inferred that the query is static. This can be disabled by using the `prepare: false` option. For instance — this is useful when [using PGBouncer in `transaction mode`](https://github.com/porsager/postgres/issues/93#issuecomment-656290493). +**update**: [since 1.21.0](https://www.pgbouncer.org/2023/10/pgbouncer-1-21-0) +PGBouncer supports protocol-level named prepared statements when [configured +properly](https://www.pgbouncer.org/config.html#max_prepared_statements) + ## Custom Types You can add ergonomic support for custom types, or simply use `sql.typed(value, type)` inline, where type is the PostgreSQL `oid` for the type and the correctly serialized string. _(`oid` values for types can be found in the `pg_catalog.pg_type` table.)_ diff --git a/deno/src/connection.js b/deno/src/connection.js index 1726a9aa..42a7217f 100644 --- a/deno/src/connection.js +++ b/deno/src/connection.js @@ -95,6 +95,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , delay = 0 , rows = 0 , serverSignature = null + , saslMechanism = null , nextWriteTimer = null , terminated = false , incomings = null @@ -681,11 +682,25 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ) } - async function SASL() { + async function SASL(x) { + const length = x.readUInt32BE(1) + const mechanisms = x.subarray(9, length - 1).toString('utf8').split('\x00') // `length - 1` excludes 2 terminal nulls (string and list) + + for (const m of mechanisms) { + if (m === 'SCRAM-SHA-256-PLUS' && socket instanceof tls.TLSSocket) { + saslMechanism = m + break + } + if (m === 'SCRAM-SHA-256') saslMechanism = m + } + if (!saslMechanism) errored(Errors.generic('SASL_MECHANISMS_UNSUPPORTED', 'No supported SASL mechanism was offered')) + + const gs2Header = saslMechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : 'y' nonce = (await crypto.randomBytes(18)).toString('base64') - b().p().str('SCRAM-SHA-256' + b.N) + + b().p().str(saslMechanism + b.N) const i = b.i - write(b.inc(4).str('n,,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) + write(b.inc(4).str(gs2Header + ',,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) } async function SASLContinue(x) { @@ -700,13 +715,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const clientKey = await hmac(saltedPassword, 'Client Key') + let channelBinding = 'eSws' // 'y,,' base64-encoded + if (saslMechanism === 'SCRAM-SHA-256-PLUS') { + const peerCert = socket.getPeerCertificate().raw + const x509 = await import('@peculiar/x509') + const parsedCert = new x509.X509Certificate(peerCert) + const sigAlgo = parsedCert.signatureAlgorithm + if (!sigAlgo || !sigAlgo.hash || !sigAlgo.hash.name) errored(Errors.generic('SASL_CERT_ERROR', 'Unable to identify certificate digest type for channel binding')) + let hashName = sigAlgo.hash.name; + if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'sha256' // for MD5 and SHA-1, we substitute SHA-256 + const certHash = await namedDigest(hashName, peerCert) + const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) + channelBinding = bindingData.toString('base64') + } + const auth = 'n=*,r=' + nonce + ',' + 'r=' + res.r + ',s=' + res.s + ',i=' + res.i - + ',c=biws,r=' + res.r + + ',c=' + channelBinding + ',r=' + res.r serverSignature = (await hmac(await hmac(saltedPassword, 'Server Key'), auth)).toString('base64') - const payload = 'c=biws,r=' + res.r + ',p=' + xor( + const payload = 'c=' + channelBinding + ',r=' + res.r + ',p=' + xor( clientKey, Buffer.from(await hmac(await sha256(clientKey), auth)) ).toString('base64') @@ -1008,6 +1037,10 @@ function sha256(x) { return crypto.createHash('sha256').update(x).digest() } +function namedDigest(name, x) { + return crypto.createHash(name).update(x).digest() +} + function xor(a, b) { const length = Math.max(a.length, b.length) const buffer = Buffer.allocUnsafe(length) diff --git a/deno/src/index.js b/deno/src/index.js index 3bbdf2ba..17796505 100644 --- a/deno/src/index.js +++ b/deno/src/index.js @@ -481,7 +481,7 @@ function parseOptions(a, b) { {} ), connection : { - application_name: 'postgres.js', + application_name: env.PGAPPNAME || 'postgres.js', ...o.connection, ...Object.entries(query).reduce((acc, [k, v]) => (k in defaults || (acc[k] = v), acc), {}) }, diff --git a/package.json b/package.json index d53fe2ca..2e0db485 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,8 @@ "db", "pg", "database" - ] + ], + "dependencies": { + "@peculiar/x509": "^1.12.3" + } } diff --git a/src/connection.js b/src/connection.js index 97cc97e1..f71d3bd0 100644 --- a/src/connection.js +++ b/src/connection.js @@ -92,6 +92,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose , delay = 0 , rows = 0 , serverSignature = null + , saslMechanism = null , nextWriteTimer = null , terminated = false , incomings = null @@ -678,11 +679,24 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose ) } - async function SASL() { + async function SASL(x) { + const length = x.readUInt32BE(1) + const mechanisms = x.subarray(9, length - 1).toString('utf8').split('\x00') // `length - 1` excludes 2 terminal nulls (string and list) + + for (const m of mechanisms) { + if (m === 'SCRAM-SHA-256-PLUS' && socket instanceof tls.TLSSocket) { + saslMechanism = m + break + } + if (m === 'SCRAM-SHA-256') saslMechanism = m + } + if (!saslMechanism) errored(Errors.generic('SASL_MECHANISMS_UNSUPPORTED', 'No supported SASL mechanism was offered')) + + const gs2Header = saslMechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : 'y' nonce = (await crypto.randomBytes(18)).toString('base64') - b().p().str('SCRAM-SHA-256' + b.N) + b().p().str(saslMechanism + b.N) const i = b.i - write(b.inc(4).str('n,,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) + write(b.inc(4).str(gs2Header + ',,n=*,r=' + nonce).i32(b.i - i - 4, i).end()) } async function SASLContinue(x) { @@ -697,13 +711,27 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose const clientKey = await hmac(saltedPassword, 'Client Key') + let channelBinding = 'eSws' // 'y,,' base64-encoded + if (saslMechanism === 'SCRAM-SHA-256-PLUS') { + const peerCert = socket.getPeerCertificate().raw + const x509 = await import('@peculiar/x509') + const parsedCert = new x509.X509Certificate(peerCert) + const sigAlgo = parsedCert.signatureAlgorithm + if (!sigAlgo || !sigAlgo.hash || !sigAlgo.hash.name) errored(Errors.generic('SASL_CERT_ERROR', 'Unable to identify certificate digest type for channel binding')) + let hashName = sigAlgo.hash.name; + if (/^(md5)|(sha-?1)$/i.test(hashName)) hashName = 'sha256' // for MD5 and SHA-1, we substitute SHA-256 + const certHash = await namedDigest(hashName, peerCert) + const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)]) + channelBinding = bindingData.toString('base64') + } + const auth = 'n=*,r=' + nonce + ',' + 'r=' + res.r + ',s=' + res.s + ',i=' + res.i - + ',c=biws,r=' + res.r + + ',c=' + channelBinding + ',r=' + res.r serverSignature = (await hmac(await hmac(saltedPassword, 'Server Key'), auth)).toString('base64') - const payload = 'c=biws,r=' + res.r + ',p=' + xor( + const payload = 'c=' + channelBinding + ',r=' + res.r + ',p=' + xor( clientKey, Buffer.from(await hmac(await sha256(clientKey), auth)) ).toString('base64') @@ -1005,6 +1033,10 @@ function sha256(x) { return crypto.createHash('sha256').update(x).digest() } +function namedDigest(name, x) { + return crypto.createHash(name).update(x).digest() +} + function xor(a, b) { const length = Math.max(a.length, b.length) const buffer = Buffer.allocUnsafe(length)