Skip to content
Merged
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
30 changes: 29 additions & 1 deletion packages/pg/lib/crypto/sasl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
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')
})
}
58 changes: 58 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,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',
Expand Down
Loading