From c73a645779838a6c0cf7ae7400e71d94243f8cb2 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 14:10:58 -0700 Subject: [PATCH 1/4] =?UTF-8?q?test:=20Ensure=20failure=20to=20throw=20at?= =?UTF-8?q?=20all=20doesn=E2=80=99t=20pass=20(#3671)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pg/test/unit/client/simple-query-tests.js | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/pg/test/unit/client/simple-query-tests.js b/packages/pg/test/unit/client/simple-query-tests.js index 6c20c576b..8cc550830 100644 --- a/packages/pg/test/unit/client/simple-query-tests.js +++ b/packages/pg/test/unit/client/simple-query-tests.js @@ -118,39 +118,36 @@ test('executing query', function () { const client = helper.client() test('throws an error when config is null', function () { - try { - client.query(null, undefined) - } catch (error) { - assert.equal( - error.message, - 'Client was passed a null or undefined query', - 'Should have thrown an Error for null queries' - ) - } + assert.throws( + () => { + client.query(null, undefined) + }, + { + message: 'Client was passed a null or undefined query', + } + ) }) test('throws an error when config is undefined', function () { - try { - client.query() - } catch (error) { - assert.equal( - error.message, - 'Client was passed a null or undefined query', - 'Should have thrown an Error for null queries' - ) - } + assert.throws( + () => { + client.query() + }, + { + message: 'Client was passed a null or undefined query', + } + ) }) test('throws an error when callback is not a function', function () { - try { - client.query('SELECT $1', [1], 'notafunction') - } catch (error) { - assert.equal( - error.message, - 'callback is not a function', - 'Should have thrown an Error for non function callback' - ) - } + assert.throws( + () => { + client.query('SELECT $1', [1], 'notafunction') + }, + { + message: 'callback is not a function', + } + ) }) }) }) From be880d45552269f0b847a3e568014bde6536eae3 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 22:54:13 -0700 Subject: [PATCH 2/4] Assorted test fixes and cleanup (#3672) * cleanup: Remove duplicate test * cleanup: Remove nonsense test * cleanup: Simplify promise rejection test * test: Fix and tighten assertion that would always pass because of the `SELECTR` typo. * cleanup: Add missing `await`s when using `assert.rejects` in tests; remove unneeded function wrappers --- .../integration/client/promise-api-tests.js | 29 +--- .../test/integration/gh-issues/3174-tests.js | 8 +- .../pg/test/unit/client/sasl-scram-tests.js | 139 ++++++++---------- 3 files changed, 69 insertions(+), 107 deletions(-) diff --git a/packages/pg/test/integration/client/promise-api-tests.js b/packages/pg/test/integration/client/promise-api-tests.js index 9e2ffec0c..8c3cd076b 100644 --- a/packages/pg/test/integration/client/promise-api-tests.js +++ b/packages/pg/test/integration/client/promise-api-tests.js @@ -13,13 +13,6 @@ suite.test('valid connection completes promise', () => { }) }) -suite.test('valid connection completes promise', () => { - const client = new pg.Client() - return client.connect().then(() => { - return client.end().then(() => {}) - }) -}) - suite.test('valid connection returns the client in a promise', () => { const client = new pg.Client() return client.connect().then((clientInside) => { @@ -28,25 +21,7 @@ suite.test('valid connection returns the client in a promise', () => { }) }) -suite.test('invalid connection rejects promise', (done) => { +suite.test('invalid connection rejects promise', async () => { const client = new pg.Client({ host: 'alksdjflaskdfj', port: 1234 }) - return client.connect().catch((e) => { - assert(e instanceof Error) - done() - }) -}) - -suite.test('connected client does not reject promise after connection', (done) => { - const client = new pg.Client() - return client.connect().then(() => { - setTimeout(() => { - client.on('error', (e) => { - assert(e instanceof Error) - client.end() - done() - }) - // manually kill the connection - client.emit('error', new Error('something bad happened...but not really')) - }, 50) - }) + await assert.rejects(client.connect(), Error) }) diff --git a/packages/pg/test/integration/gh-issues/3174-tests.js b/packages/pg/test/integration/gh-issues/3174-tests.js index 99044df0e..cd920346a 100644 --- a/packages/pg/test/integration/gh-issues/3174-tests.js +++ b/packages/pg/test/integration/gh-issues/3174-tests.js @@ -104,7 +104,9 @@ const testErrorBuffer = (bufferName, errorBuffer) => { if (!cli.native) { assert(errorHit) // further queries on the client should fail since its in an invalid state - await assert.rejects(() => client.query('SELECTR NOW()'), 'Further queries on the client should reject') + await assert.rejects(client.query('SELECT NOW()'), { + message: 'Client has encountered a connection error and is not queryable', + }) } await closeServer() @@ -129,7 +131,9 @@ const testErrorBuffer = (bufferName, errorBuffer) => { if (!cli.native) { assert(errorHit) // further queries on the client should fail since its in an invalid state - await assert.rejects(() => client.query('SELECTR NOW()'), 'Further queries on the client should reject') + await assert.rejects(client.query('SELECT NOW()'), { + message: 'Client has encountered a connection error and is not queryable', + }) } await client.end() diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 7554a9814..8b0376d67 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -58,64 +58,53 @@ suite.test('sasl/scram', function () { }) suite.test('continueSession', function () { - suite.test('fails when last session message was not SASLInitialResponse', async function () { - assert.rejects( - function () { - return sasl.continueSession({}, '', '') - }, - { - message: 'SASL: Last message was not SASLInitialResponse', - } - ) + suite.test('fails when last session message was not SASLInitialResponse', async () => { + await assert.rejects(sasl.continueSession({}, '', ''), { + message: 'SASL: Last message was not SASLInitialResponse', + }) }) - suite.test('fails when nonce is missing in server message', function () { - assert.rejects( - function () { - return sasl.continueSession( - { - message: 'SASLInitialResponse', - }, - 'bad-password', - 's=1,i=1' - ) - }, + suite.test('fails when nonce is missing in server message', async () => { + await assert.rejects( + sasl.continueSession( + { + message: 'SASLInitialResponse', + }, + 'bad-password', + 's=1,i=1' + ), { message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing', } ) }) - suite.test('fails when salt is missing in server message', function () { - assert.rejects( - function () { - return sasl.continueSession( - { - message: 'SASLInitialResponse', - }, - 'bad-password', - 'r=1,i=1' - ) - }, + suite.test('fails when salt is missing in server message', async () => { + await assert.rejects( + sasl.continueSession( + { + message: 'SASLInitialResponse', + }, + 'bad-password', + 'r=1,i=1' + ), { message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing', } ) }) - suite.test('fails when client password is not a string', function () { + suite.test('fails when client password is not a string', async () => { for (const badPasswordValue of [null, undefined, 123, new Date(), {}]) { - assert.rejects( - function () { - return sasl.continueSession( - { - message: 'SASLInitialResponse', - clientNonce: 'a', - }, - badPasswordValue, - 'r=1,i=1' - ) - }, + await assert.rejects( + sasl.continueSession( + { + message: 'SASLInitialResponse', + clientNonce: 'a', + }, + badPasswordValue, + 'r=1,i=1' + ), { message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string', } @@ -123,53 +112,47 @@ suite.test('sasl/scram', function () { } }) - suite.test('fails when client password is an empty string', function () { - assert.rejects( - function () { - return sasl.continueSession( - { - message: 'SASLInitialResponse', - clientNonce: 'a', - }, - '', - 'r=1,i=1' - ) - }, + suite.test('fails when client password is an empty string', async () => { + await assert.rejects( + sasl.continueSession( + { + message: 'SASLInitialResponse', + clientNonce: 'a', + }, + '', + 'r=1,i=1' + ), { message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a non-empty string', } ) }) - suite.test('fails when iteration is missing in server message', function () { - assert.rejects( - function () { - return sasl.continueSession( - { - message: 'SASLInitialResponse', - }, - 'bad-password', - 'r=1,s=abcd' - ) - }, + suite.test('fails when iteration is missing in server message', async () => { + await assert.rejects( + sasl.continueSession( + { + message: 'SASLInitialResponse', + }, + 'bad-password', + 'r=1,s=abcd' + ), { message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing', } ) }) - suite.test('fails when server nonce does not start with client nonce', function () { - assert.rejects( - function () { - return sasl.continueSession( - { - message: 'SASLInitialResponse', - clientNonce: '2', - }, - 'bad-password', - 'r=1,s=abcd,i=1' - ) - }, + suite.test('fails when server nonce does not start with client nonce', async () => { + await assert.rejects( + sasl.continueSession( + { + message: 'SASLInitialResponse', + clientNonce: '2', + }, + 'bad-password', + 'r=1,s=abcd,i=1' + ), { message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce', } From 63c921bbc7dfba684a75f3da6bc10e4f2cdc27fd Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Tue, 12 May 2026 22:54:41 -0700 Subject: [PATCH 3/4] ci: Node 26 followup (#3670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert unneeded pg-native→libpq dependency range adjustment This reverts part of commit 1025d12b24f277f9b7cdba2d5488103745939d6b. * dev: Upgrade libpq/nan in lockfile for Node 26 compatibility --- packages/pg-native/package.json | 2 +- yarn.lock | 23 +++++++++-------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/pg-native/package.json b/packages/pg-native/package.json index b75b2ffdb..4c8148ac1 100644 --- a/packages/pg-native/package.json +++ b/packages/pg-native/package.json @@ -34,7 +34,7 @@ }, "homepage": "https://github.com/brianc/node-postgres/tree/master/packages/pg-native", "dependencies": { - "libpq": "^1.11.0", + "libpq": "^1.8.15", "pg-types": "2.2.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 6e45ace78..92028ecc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5907,13 +5907,13 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -libpq@^1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/libpq/-/libpq-1.10.0.tgz#238d01d416abca8768aab09bc82d81af9c7ffa23" - integrity sha512-PHY+JGD3+9X5b2emXLh+WJEnz1jhczO1xs25ZH0xbMWvQi+Hd9X/mTZOrGA99Rcw/DvNjsBRlegroqigpNfaJA== +libpq@^1.8.15: + version "1.11.0" + resolved "https://registry.yarnpkg.com/libpq/-/libpq-1.11.0.tgz#1baf0920eb51ebe1399de942414e012142dcead8" + integrity sha512-mHoPlvMwYDMJV36bS2w3eSdFD4eDSm7P9FsvruUldQxzE23/W6qitT9VU/yD1+g2vpgpDktnk2iEYJyhy1RR5g== dependencies: bindings "1.5.0" - nan "~2.23.1" + nan "~2.26.2" lines-and-columns@^1.1.6: version "1.1.6" @@ -6627,15 +6627,10 @@ mz@^2.5.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@~2.22.2: - version "2.22.2" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.2.tgz#6b504fd029fb8f38c0990e52ad5c26772fdacfbb" - integrity sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ== - -nan@~2.23.1: - version "2.23.1" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.23.1.tgz#6f86a31dd87e3d1eb77512bf4b9e14c8aded3975" - integrity sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw== +nan@~2.26.2: + version "2.26.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.26.2.tgz#2e5e25764224c737b9897790b57c3294d4dcee9c" + integrity sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw== nanoid@^3.3.11: version "3.3.11" From 0ac3eddef6481f4e4f9359c65d3c0cfd7d2124e1 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Wed, 13 May 2026 07:55:52 +0200 Subject: [PATCH 4/4] fix: apply SASLprep (RFC 4013) to passwords before SCRAM-SHA-256 PBKDF2 (#3669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply SASLprep (RFC 4013) to passwords before SCRAM-SHA-256 PBKDF2 `pg`'s SCRAM-SHA-256 client passes the raw password into PBKDF2 with no normalization, while PostgreSQL's server (and libpq) apply SASLprep (B.1 mapping -> NFKC -> prohibition + bidi check) when computing the stored verifier. Passwords whose NFKC form differs from themselves (e.g. containing U+00A8 dieresis, U+2011 non-breaking hyphen, U+00BC vulgar one quarter, NBSP, soft hyphen) authenticate with psql/libpq but fail against pg with `28P01`. Wire `@mongodb-js/saslprep` (the maintained fork used by mongodb's official Node driver) into `continueSession` before `crypto.deriveKey`, with a try/catch fallback to the raw password on prohibited / bidi violations to match `libpq`'s `pg_saslprep` behavior. Also adds: - Unit tests covering the soft-hyphen B.1 mapping equivalence, the Roman-numeral-IX NFKC asymmetry, the prohibited-char fallback, and a deterministic snapshot for the original bug-report password. - A gated integration test block (SCRAM_TEST_PGUSER_UNICODE / SCRAM_TEST_PGPASSWORD_UNICODE) covering raw + NFKC-equivalent + wrong password. - A `scram_unicode_test` role (password `U&'IX-\2168'`) provisioned in CI plus matching env vars so the new integration tests run on every Node version. - A Cloudflare Workers regression guard that exercises `sasl.continueSession` to ensure `@mongodb-js/saslprep` resolves cleanly under workerd. - A `pg@8.21.0` CHANGELOG entry. * fix: inline SASLprep, drop @mongodb-js/saslprep dependency Per review feedback on #3669: ship the SASLprep step as a small in-tree function instead of pulling a runtime dep with an unpinned transitive. The function performs only the three byte-changing steps from RFC 4013 (Table C.1.2 -> SPACE, Table B.1 -> empty, NFKC) and skips the prohibition (RFC 4013 section 2.3) and bidi (RFC 3454 section 6) checks, since libpq is forgiving on those paths and Postgres's own SASLprep is similarly lenient. Removes the try/catch fallback (no code path throws). The deterministic snapshot tests stay byte-for-byte valid because none of them touch U+200B, the only edge case where the inline impl diverges from `@mongodb-js/saslprep`. RFC 3454 places U+200B in Table B.1 (mapped to nothing); the dep maps it to SPACE. PostgreSQL's saslprep.c follows the RFC, so the inline impl matches libpq more closely on that codepoint. The B.1 unit-test rename ("passes ASCII control characters through normalization unchanged") keeps the same snapshot bytes since BEL is unchanged by all three steps. Co-authored-by: charmander * Revert unrelated no-op changes to yarn.lock now that the associated dependency isn’t being added. * cleanup: Allow Prettier to format some lines * cleanup: Remove changelog entry for unreleased pg version normally added as part of the release process * refactor: Simplify comments in sasl.js and remove unused test cases Updated comments in sasl.js to clarify the password normalization process and removed redundant test cases from vitest-cf.test.ts, streamlining the codebase. * Remove redundant NFKC-only SASLprep test Confirmed in pull request comments that the “macOS/iOS” thing was an AI inventing an unneeded justification, and NFKC is already covered by another test. * fix: SASLprep zero-width space the same way PostgreSQL does As mentioned in the test comment, RFC 3454 defines appendix B for mapping tables and appendix C for prohibition tables. RFC 4013 SASLprep is probably misusing that list of non-ASCII spaces, and says nothing about the overlap. (At least it’s obsoleted.) * cleanup: Simplify regex character classes with ranges --------- Co-authored-by: charmander Co-authored-by: Charmander <~@charmander.me> --- .github/workflows/ci.yml | 10 ++- packages/pg/lib/crypto/sasl.js | 30 ++++++- .../integration/client/sasl-scram-tests.js | 79 +++++++++++++++++++ .../pg/test/unit/client/sasl-scram-tests.js | 58 ++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aae36233..1a266291d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,13 @@ 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: | @@ -63,7 +70,8 @@ jobs: - 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 diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index a782ae48a..39af4e4cf 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -2,6 +2,34 @@ 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 +// 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. + const nonAsciiSpace = /[\u00A0\u1680\u2000-\u200B\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. + // eslint-disable-next-line no-misleading-character-class + const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200C\u200D\u2060\uFE00-\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 @@ -70,7 +98,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) diff --git a/packages/pg/test/integration/client/sasl-scram-tests.js b/packages/pg/test/integration/client/sasl-scram-tests.js index 85bf2cd34..bf1dfcb0d 100644 --- a/packages/pg/test/integration/client/sasl-scram-tests.js +++ b/packages/pg/test/integration/client/sasl-scram-tests.js @@ -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') + }) +} diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index 8b0376d67..fc75a748a 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -187,6 +187,64 @@ suite.test('sasl/scram', function () { assert.equal(session.response, 'c=eSws,r=ab,p=YVTEOwOD7khu/NulscjFegHrZoTXJBFI/7L61AN9khc=') }) + suite.test('SASLprep maps non-ASCII space characters (RFC 3454 C.1.2) to U+0020 SPACE', async function () { + // SASLprep probably misuses the C.1.2 table; U+200B, in particular, is listed in both the C.1.2 and B.1 tables. We treat it as a space for compatibility with PostgreSQL. + const sessionPrepped = { message: 'SASLInitialResponse', clientNonce: 'a' } + const sessionRef = { message: 'SASLInitialResponse', clientNonce: 'a' } + + await sasl.continueSession(sessionPrepped, '\u200bfoo\xa0bar', 'r=ab,s=abcd,i=1') + await sasl.continueSession(sessionRef, ' foo bar', 'r=ab,s=abcd,i=1') + + assert.equal(sessionPrepped.serverSignature, sessionRef.serverSignature) + assert.equal(sessionPrepped.response, sessionRef.response) + }) + + suite.test('SASLprep maps mapped-to-nothing characters before PBKDF2 (RFC 3454 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('sets expected session data (SCRAM-SHA-256-PLUS)', async function () { const session = { message: 'SASLInitialResponse',