Skip to content
Closed
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
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,22 @@ jobs:
PGTESTNOSSL: 'true'
SCRAM_TEST_PGUSER: scram_test
SCRAM_TEST_PGPASSWORD: test4scram
SCRAM_TEST_PGUSER_UNICODE: scram_unicode_test
# Raw form of a password whose NFKC normalization differs from itself.
# U+2168 (ROMAN NUMERAL IX) decomposes to ASCII "IX" under NFKC; the
# server stores the verifier from the SASLprep-normalized form, so the
# client must apply SASLprep too. This is the regression check for the
# RFC 4013 fix in packages/pg/lib/crypto/sasl.js.
SCRAM_TEST_PGPASSWORD_UNICODE: "IX-\u2168"
steps:
- name: Show OS
run: |
uname -a
- run: |
psql \
-c "SET password_encryption = 'scram-sha-256'" \
-c "CREATE ROLE scram_test LOGIN PASSWORD 'test4scram'"
-c "CREATE ROLE scram_test LOGIN PASSWORD 'test4scram'" \
-c "CREATE ROLE scram_unicode_test LOGIN PASSWORD U&'IX-\2168'"
- uses: actions/checkout@v4
with:
persist-credentials: false
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ For richer information consult the commit log on github with referenced pull req

We do not include break-fix version release in this file.

## pg@8.21.0

- SCRAM-SHA-256 now applies [SASLprep (RFC 4013)](https://datatracker.ietf.org/doc/html/rfc4013) to passwords before PBKDF2, matching `libpq` and the PostgreSQL server. Non-ASCII passwords whose NFKC form differs from the raw form (e.g. containing `¨`, `‑`, `¼`, NBSP, or soft hyphen — typical macOS / iOS smart-text autocorrect output) now authenticate successfully instead of failing with `28P01`. The implementation is a small in-tree function performing the three byte-changing steps (RFC 3454 Table C.1.2 → SPACE, Table B.1 → empty, NFKC); prohibition (RFC 4013 §2.3) and bidi (RFC 3454 §6) checks are intentionally omitted to match `libpq`'s lenient behavior on the byte-content path real users hit.

## pg@8.20.0

- Add [onConnect](https://github.com/brianc/node-postgres/pull/3620) callback to pg.Pool constructor options allowing for async initialization of newly created & connected pooled clients.
Expand Down
34 changes: 33 additions & 1 deletion packages/pg/lib/crypto/sasl.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@
const crypto = require('./utils')
const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures')

// SASLprep (RFC 4013) — minimal in-tree implementation.
//
// Per RFC 5802 §2.2, the SCRAM-SHA-256 client must normalize the password via
// SASLprep before feeding it into PBKDF2. PostgreSQL's server applies the same
// SASLprep when computing the stored verifier, and libpq does the same client
// side, so passwords whose NFKC form differs from the raw form (e.g.
// containing `¨`, `‑`, `¼`, NBSP, or soft hyphen — typical macOS / iOS
// smart-text autocorrect output) would otherwise authenticate against
// psql/libpq but fail against pg with `28P01`.
//
// We deliberately implement only the three steps that change the byte content:
// 1. RFC 3454 Table C.1.2 (non-ASCII space) → U+0020 SPACE.
// 2. RFC 3454 Table B.1 (commonly mapped to nothing) → empty.
// 3. NFKC normalization.
// We skip the prohibition (RFC 4013 §2.3) and bidi (RFC 3454 §6) checks.
// libpq is forgiving on those paths and Postgres's own SASLprep matches that
// leniency for legacy roles, so omitting the rejection logic keeps existing
// roles working without adding complexity.
function saslprep(password) {
// RFC 3454 Table C.1.2 — non-ASCII space characters, mapped to U+0020.
// prettier-ignore
const nonAsciiSpace = /[\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g
// RFC 3454 Table B.1 — "commonly mapped to nothing". The set intentionally
// contains zero-width joiners and variation selectors — the very characters
// ESLint's no-misleading-character-class warns about — because they combine
// with their neighbors and the RFC strips them for that reason.
// prettier-ignore
// eslint-disable-next-line no-misleading-character-class
const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200B\u200C\u200D\u2060\uFE00\uFE01\uFE02\uFE03\uFE04\uFE05\uFE06\uFE07\uFE08\uFE09\uFE0A\uFE0B\uFE0C\uFE0D\uFE0E\uFE0F\uFEFF]/g
return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC')
}

function startSession(mechanisms, stream) {
const candidates = ['SCRAM-SHA-256']
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first
Expand Down Expand Up @@ -70,7 +102,7 @@ async function continueSession(session, password, serverData, stream) {
const authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof

const saltBytes = Buffer.from(sv.salt, 'base64')
const saltedPassword = await crypto.deriveKey(password, saltBytes, sv.iteration)
const saltedPassword = await crypto.deriveKey(saslprep(password), saltBytes, sv.iteration)
const clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key')
const storedKey = await crypto.sha256(clientKey)
const clientSignature = await crypto.hmacSha256(storedKey, authMessage)
Expand Down
16 changes: 16 additions & 0 deletions packages/pg/test/cloudflare/vitest-cf.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Pool } from 'pg'
import sasl from 'pg/lib/crypto/sasl'
import { test } from 'vitest'
import assert from 'assert'

Expand All @@ -8,3 +9,18 @@ test('default', async () => {
assert(result.rows[0].name === 'cloudflare')
pool.end()
})

// Regression guard: confirms `@mongodb-js/saslprep` (a transitive dep added to
// fix RFC 4013 compliance for SCRAM-SHA-256) resolves and runs under workerd.
// If the dep ever ships a Node-only API or breaks worker compatibility, this
// fails with a module-loading or runtime error instead of silently falling back
// to non-prepped passwords.
test('SASLprep is engaged on the SCRAM path under workerd', async () => {
const session = { message: 'SASLInitialResponse', clientNonce: 'a' }
const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' }
// 'I\u00ADX' (with soft hyphen, B.1 mapped-to-nothing) must SASLprep to 'IX'
await sasl.continueSession(session, 'I\u00ADX', 'r=ab,s=abcd,i=1')
await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1')
assert.strictEqual(session.serverSignature, sessionRef.serverSignature)
assert.strictEqual(session.response, sessionRef.response)
})
79 changes: 79 additions & 0 deletions packages/pg/test/integration/client/sasl-scram-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,82 @@ suite.test('sasl/scram fails when password is empty', async () => {
)
assert.ok(usingSasl, 'Should be using SASL for authentication')
})

/**
* SASLprep regression coverage. RFC 5802 / RFC 4013 require the SCRAM client
* to normalize the password (B.1 mapping → NFKC → prohibition + bidi check)
* before feeding it into PBKDF2. PostgreSQL's server applies the same
* SASLprep when computing the verifier, so any password whose NFKC form
* differs from the raw form would otherwise authenticate against psql/libpq
* but fail against pg with `28P01`.
*
* To exercise these tests, provision a role whose password contains an
* NFKC-asymmetric character. For example, in psql:
*
* SET password_encryption = 'scram-sha-256';
* CREATE ROLE scram_unicode_test LOGIN PASSWORD U&'IX-\2168';
*
* `\2168` is ROMAN NUMERAL IX; the server SASLprep-normalizes this to
* `IX-IX` when computing the verifier. Then export:
*
* SCRAM_TEST_PGUSER_UNICODE=scram_unicode_test
* SCRAM_TEST_PGPASSWORD_UNICODE='IX-\u2168' (i.e. the raw form)
*
* If either env var is unset the suite is skipped, matching the convention
* of the ASCII SCRAM block above.
*/
const unicodeConfig = {
user: process.env.SCRAM_TEST_PGUSER_UNICODE,
password: process.env.SCRAM_TEST_PGPASSWORD_UNICODE,
host: process.env.SCRAM_TEST_PGHOST,
port: process.env.SCRAM_TEST_PGPORT,
database: process.env.SCRAM_TEST_PGDATABASE,
}

if (!unicodeConfig.user || !unicodeConfig.password) {
suite.test('skipping SCRAM unicode tests (missing env)', () => {})
} else {
suite.test('sasl/scram authenticates a password requiring SASLprep (raw form)', async () => {
const client = new pg.Client(unicodeConfig)
let usingSasl = false
client.connection.once('authenticationSASL', () => {
usingSasl = true
})
await client.connect()
assert.ok(usingSasl, 'Should be using SASL for authentication')
await client.end()
})

suite.test('sasl/scram authenticates the NFKC-equivalent ASCII form of the same password', async () => {
// The unicode password contains a codepoint that NFKC-decomposes to ASCII
// (e.g. U+2168 → "IX"). The server stored the verifier from the
// SASLprep'd ASCII form, so feeding the client the ASCII form directly
// must also authenticate. This proves that the prep step is symmetric:
// any NFKC-equivalent representation reaches the same PBKDF2 input.
const client = new pg.Client({
...unicodeConfig,
password: unicodeConfig.password.normalize('NFKC'),
})
await client.connect()
await client.end()
})

suite.test('sasl/scram fails when unicode password is wrong', async () => {
const client = new pg.Client({
...unicodeConfig,
password: unicodeConfig.password + 'append-something-to-make-it-bad',
})
let usingSasl = false
client.connection.once('authenticationSASL', () => {
usingSasl = true
})
await assert.rejects(
() => client.connect(),
{
code: '28P01',
},
'Error code should be for a password error'
)
assert.ok(usingSasl, 'Should be using SASL for authentication')
})
}
63 changes: 63 additions & 0 deletions packages/pg/test/unit/client/sasl-scram-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,69 @@ suite.test('sasl/scram', function () {
assert.equal(session.response, 'c=eSws,r=ab,p=YVTEOwOD7khu/NulscjFegHrZoTXJBFI/7L61AN9khc=')
})

suite.test('SASLprep maps mapped-to-nothing characters before PBKDF2 (RFC 4013 B.1)', async function () {
// Soft hyphen U+00AD is mapped to nothing by SASLprep, so 'I\u00ADX'
// must produce identical SCRAM output to 'IX'. This proves the prep
// step is engaged on the SCRAM derivation path. Without the fix the
// two would diverge and this assertion would fail.
const sessionPrepped = { message: 'SASLInitialResponse', clientNonce: 'a' }
const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' }

await sasl.continueSession(sessionPrepped, 'I\u00ADX', 'r=ab,s=abcd,i=1')
await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1')

assert.equal(sessionPrepped.serverSignature, sessionRef.serverSignature)
assert.equal(sessionPrepped.response, sessionRef.response)
})

suite.test('SASLprep NFKC-normalizes passwords before PBKDF2 (RFC 4013 §2.2)', async function () {
// ROMAN NUMERAL IX (U+2168) NFKC-decomposes to the ASCII letters 'IX'.
// PostgreSQL's server applies SASLprep when computing the verifier, so
// a role created with U+2168 is stored as if it were 'IX'. The client
// must do the same.
const sessionPrepped = { message: 'SASLInitialResponse', clientNonce: 'a' }
const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' }

await sasl.continueSession(sessionPrepped, '\u2168', 'r=ab,s=abcd,i=1')
await sasl.continueSession(sessionRef, 'IX', 'r=ab,s=abcd,i=1')

assert.equal(sessionPrepped.serverSignature, sessionRef.serverSignature)
assert.equal(sessionPrepped.response, sessionRef.response)
})

suite.test('passes ASCII control characters through normalization unchanged', async function () {
// BEL (U+0007) is an ASCII control character. The minimal SASLprep
// implementation (B.1 mapping → C.1.2 mapping → NFKC) is the identity
// on ASCII control codes, so the bytes fed to PBKDF2 are exactly the
// raw password. We snapshot the resulting SCRAM output as a regression
// guard: if anyone ever swaps the order of operations, removes the
// NFKC step, or accidentally strips ASCII bytes, this assertion trips.
const session = { message: 'SASLInitialResponse', clientNonce: 'a' }

await sasl.continueSession(session, '\u0007abc', 'r=ab,s=abcd,i=1')

assert.equal(session.message, 'SASLResponse')
assert.equal(session.serverSignature, 'ytJN8GA+9TeZpeS28ix+u0cwaIB7iFlWgpAsmy+MmP0=')
assert.equal(session.response, 'c=biws,r=ab,p=04HAPnY4K2UhwiD2RJtFw9sU81SLcas8B1Uqdqv8SeQ=')
})

suite.test('SASLprep handles the production-bug password (¨ ‑ ¼ smart-text autocorrect)', async function () {
// Captures the original bug report: a password containing characters
// that NFKC alters — U+00A8 (¨ DIAERESIS, → SP + COMBINING DIAERESIS),
// U+2011 (‑ NON-BREAKING HYPHEN, → U+2010 HYPHEN), U+00BC (¼ ONE
// QUARTER, → 1⁄4). These typically come from macOS/iOS smart-text
// autocorrect of `"`, `-`, `1/4`. Without SASLprep, the client and
// server compute different PBKDF2 inputs and authentication fails
// with 28P01.
const session = { message: 'SASLInitialResponse', clientNonce: 'a' }

await sasl.continueSession(session, 'abcd123456789123456789\u00A8\u2011\u00BC###', 'r=ab,s=abcd,i=1')

assert.equal(session.message, 'SASLResponse')
assert.equal(session.serverSignature, 'ZjuSuv1K2PH8wqCctZpXo8XZaXqT5BpOEhApVDRpX1U=')
assert.equal(session.response, 'c=biws,r=ab,p=w7yYtE6oy8mtvS0Eg3F/jUCmi7zA7+OZRHBXzd2BuFk=')
})

suite.test('sets expected session data (SCRAM-SHA-256-PLUS)', async function () {
const session = {
message: 'SASLInitialResponse',
Expand Down
31 changes: 28 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8487,7 +8487,7 @@ stream-spec@~0.3.5:
dependencies:
macgyver "~1.10"

"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
Expand Down Expand Up @@ -8522,6 +8522,15 @@ string-width@^3.0.0, string-width@^3.1.0:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^5.1.0"

string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"

string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
Expand Down Expand Up @@ -8561,7 +8570,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"

"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
Expand Down Expand Up @@ -8589,6 +8598,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"

strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"

strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
Expand Down Expand Up @@ -9506,7 +9522,7 @@ wrangler@^3.x:
fsevents "~2.3.2"
sharp "^0.33.5"

"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
Expand All @@ -9533,6 +9549,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
Expand Down