source: node_modules/pg/lib/crypto/sasl.js@ 33517cc

finki-main main
Last change on this file since 33517cc was 62b2964, checked in by Klimentina Efremova <klimentina08642@…>, 2 weeks ago

Project Handcraft Marketplace

  • Property mode set to 100644
File size: 9.7 KB
Line 
1'use strict'
2const crypto = require('./utils')
3const { signatureAlgorithmHashFromCertificate } = require('./cert-signatures')
4
5// SASLprep (RFC 4013) — minimal in-tree implementation.
6//
7// Per RFC 5802 §2.2, the SCRAM-SHA-256 client must normalize the password via
8// SASLprep before feeding it into PBKDF2. PostgreSQL's server applies the same
9// SASLprep when computing the stored verifier, and libpq does the same client
10// side, so passwords whose NFKC form differs from the raw form
11// would otherwise authenticate against psql/libpq but fail against pg with `28P01`.
12//
13// We deliberately implement only the three steps that change the byte content:
14// 1. RFC 3454 Table C.1.2 (non-ASCII space) → U+0020 SPACE.
15// 2. RFC 3454 Table B.1 (commonly mapped to nothing) → empty.
16// 3. NFKC normalization.
17// We skip the prohibition (RFC 4013 §2.3) and bidi (RFC 3454 §6) checks.
18// libpq is forgiving on those paths and Postgres's own SASLprep matches that
19// leniency for legacy roles, so omitting the rejection logic keeps existing
20// roles working without adding complexity.
21function saslprep(password) {
22 // RFC 3454 Table C.1.2 — non-ASCII space characters, mapped to U+0020.
23 const nonAsciiSpace = /[\u00A0\u1680\u2000-\u200B\u202F\u205F\u3000]/g
24 // RFC 3454 Table B.1 — "commonly mapped to nothing". The set intentionally
25 // contains zero-width joiners and variation selectors — the very characters
26 // ESLint's no-misleading-character-class warns about — because they combine
27 // with their neighbors and the RFC strips them for that reason.
28 // eslint-disable-next-line no-misleading-character-class
29 const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200C\u200D\u2060\uFE00-\uFE0F\uFEFF]/g
30 return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC')
31}
32
33const DEFAULT_MAX_SCRAM_ITERATIONS = 100000
34
35function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) {
36 const candidates = ['SCRAM-SHA-256']
37 if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first
38
39 const mechanism = candidates.find((candidate) => mechanisms.includes(candidate))
40
41 if (!mechanism) {
42 throw new Error('SASL: Only mechanism(s) ' + candidates.join(' and ') + ' are supported')
43 }
44
45 if (mechanism === 'SCRAM-SHA-256-PLUS' && typeof stream.getPeerCertificate !== 'function') {
46 // this should never happen if we are really talking to a Postgres server
47 throw new Error('SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate')
48 }
49
50 const clientNonce = crypto.randomBytes(18).toString('base64')
51 const gs2Header = mechanism === 'SCRAM-SHA-256-PLUS' ? 'p=tls-server-end-point' : stream ? 'y' : 'n'
52
53 return {
54 mechanism,
55 clientNonce,
56 response: gs2Header + ',,n=*,r=' + clientNonce,
57 message: 'SASLInitialResponse',
58 scramMaxIterations,
59 }
60}
61
62async function continueSession(session, password, serverData, stream) {
63 if (session.message !== 'SASLInitialResponse') {
64 throw new Error('SASL: Last message was not SASLInitialResponse')
65 }
66 if (typeof password !== 'string') {
67 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string')
68 }
69 if (password === '') {
70 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a non-empty string')
71 }
72 if (typeof serverData !== 'string') {
73 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: serverData must be a string')
74 }
75
76 const sv = parseServerFirstMessage(serverData)
77
78 if (!sv.nonce.startsWith(session.clientNonce)) {
79 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce')
80 } else if (sv.nonce.length === session.clientNonce.length) {
81 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short')
82 }
83
84 const scramMaxIterations =
85 typeof session.scramMaxIterations === 'number' ? session.scramMaxIterations : DEFAULT_MAX_SCRAM_ITERATIONS
86 // a value of 0 disables the iteration count check
87 if (scramMaxIterations !== 0 && sv.iteration > scramMaxIterations) {
88 throw new Error(
89 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count ' +
90 sv.iteration +
91 ' exceeds scramMaxIterations of ' +
92 scramMaxIterations
93 )
94 }
95
96 const clientFirstMessageBare = 'n=*,r=' + session.clientNonce
97 const serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
98
99 // without channel binding:
100 let channelBinding = stream ? 'eSws' : 'biws' // 'y,,' or 'n,,', base64-encoded
101
102 // override if channel binding is in use:
103 if (session.mechanism === 'SCRAM-SHA-256-PLUS') {
104 const peerCert = stream.getPeerCertificate().raw
105 let hashName = signatureAlgorithmHashFromCertificate(peerCert)
106 if (hashName === 'MD5' || hashName === 'SHA-1') hashName = 'SHA-256'
107 const certHash = await crypto.hashByName(hashName, peerCert)
108 const bindingData = Buffer.concat([Buffer.from('p=tls-server-end-point,,'), Buffer.from(certHash)])
109 channelBinding = bindingData.toString('base64')
110 }
111
112 const clientFinalMessageWithoutProof = 'c=' + channelBinding + ',r=' + sv.nonce
113 const authMessage = clientFirstMessageBare + ',' + serverFirstMessage + ',' + clientFinalMessageWithoutProof
114
115 const saltBytes = Buffer.from(sv.salt, 'base64')
116 const saltedPassword = await crypto.deriveKey(saslprep(password), saltBytes, sv.iteration)
117 const clientKey = await crypto.hmacSha256(saltedPassword, 'Client Key')
118 const storedKey = await crypto.sha256(clientKey)
119 const clientSignature = await crypto.hmacSha256(storedKey, authMessage)
120 const clientProof = xorBuffers(Buffer.from(clientKey), Buffer.from(clientSignature)).toString('base64')
121 const serverKey = await crypto.hmacSha256(saltedPassword, 'Server Key')
122 const serverSignatureBytes = await crypto.hmacSha256(serverKey, authMessage)
123
124 session.message = 'SASLResponse'
125 session.serverSignature = Buffer.from(serverSignatureBytes).toString('base64')
126 session.response = clientFinalMessageWithoutProof + ',p=' + clientProof
127}
128
129function finalizeSession(session, serverData) {
130 if (session.message !== 'SASLResponse') {
131 throw new Error('SASL: Last message was not SASLResponse')
132 }
133 if (typeof serverData !== 'string') {
134 throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: serverData must be a string')
135 }
136
137 const { serverSignature } = parseServerFinalMessage(serverData)
138
139 if (serverSignature !== session.serverSignature) {
140 throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match')
141 }
142}
143
144/**
145 * printable = %x21-2B / %x2D-7E
146 * ;; Printable ASCII except ",".
147 * ;; Note that any "printable" is also
148 * ;; a valid "value".
149 */
150function isPrintableChars(text) {
151 if (typeof text !== 'string') {
152 throw new TypeError('SASL: text must be a string')
153 }
154 return text
155 .split('')
156 .map((_, i) => text.charCodeAt(i))
157 .every((c) => (c >= 0x21 && c <= 0x2b) || (c >= 0x2d && c <= 0x7e))
158}
159
160/**
161 * base64-char = ALPHA / DIGIT / "/" / "+"
162 *
163 * base64-4 = 4base64-char
164 *
165 * base64-3 = 3base64-char "="
166 *
167 * base64-2 = 2base64-char "=="
168 *
169 * base64 = *base64-4 [base64-3 / base64-2]
170 */
171function isBase64(text) {
172 return /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/.test(text)
173}
174
175function parseAttributePairs(text) {
176 if (typeof text !== 'string') {
177 throw new TypeError('SASL: attribute pairs text must be a string')
178 }
179
180 return new Map(
181 text.split(',').map((attrValue) => {
182 if (!/^.=/.test(attrValue)) {
183 throw new Error('SASL: Invalid attribute pair entry')
184 }
185 const name = attrValue[0]
186 const value = attrValue.substring(2)
187 return [name, value]
188 })
189 )
190}
191
192function parseServerFirstMessage(data) {
193 const attrPairs = parseAttributePairs(data)
194
195 const nonce = attrPairs.get('r')
196 if (!nonce) {
197 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing')
198 } else if (!isPrintableChars(nonce)) {
199 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce must only contain printable characters')
200 }
201 const salt = attrPairs.get('s')
202 if (!salt) {
203 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing')
204 } else if (!isBase64(salt)) {
205 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64')
206 }
207 const iterationText = attrPairs.get('i')
208 if (!iterationText) {
209 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing')
210 } else if (!/^[1-9][0-9]*$/.test(iterationText)) {
211 throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: invalid iteration count')
212 }
213 const iteration = parseInt(iterationText, 10)
214
215 return {
216 nonce,
217 salt,
218 iteration,
219 }
220}
221
222function parseServerFinalMessage(serverData) {
223 const attrPairs = parseAttributePairs(serverData)
224 const error = attrPairs.get('e')
225 const serverSignature = attrPairs.get('v')
226
227 if (error) {
228 throw new Error(`SASL: SCRAM-SERVER-FINAL-MESSAGE: server returned error: "${error}"`)
229 }
230
231 if (!serverSignature) {
232 throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing')
233 } else if (!isBase64(serverSignature)) {
234 throw new Error('SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64')
235 }
236 return {
237 serverSignature,
238 }
239}
240
241function xorBuffers(a, b) {
242 if (!Buffer.isBuffer(a)) {
243 throw new TypeError('first argument must be a Buffer')
244 }
245 if (!Buffer.isBuffer(b)) {
246 throw new TypeError('second argument must be a Buffer')
247 }
248 if (a.length !== b.length) {
249 throw new Error('Buffer lengths must match')
250 }
251 if (a.length === 0) {
252 throw new Error('Buffers cannot be empty')
253 }
254 return Buffer.from(a.map((_, i) => a[i] ^ b[i]))
255}
256
257module.exports = {
258 startSession,
259 continueSession,
260 finalizeSession,
261 DEFAULT_MAX_SCRAM_ITERATIONS,
262}
Note: See TracBrowser for help on using the repository browser.