1/**
2 * Password authentication for the PostgreSQL protocol: MD5 and
3 * SCRAM-SHA-256.
4 *
5 * Message framing lives in `PgProtocol` - SASL payloads travel as opaque
6 * bytes - so this module only computes what goes inside them. Cleartext
7 * authentication needs nothing from here; send the password as a
8 * `PasswordMessage`.
9 *
10 * `SCRAM-SHA-256-PLUS` is not implemented: channel binding needs the TLS
11 * socket, which this codec does not own. Passwords are used as UTF-8 without
12 * SASLprep normalisation, so non-ASCII passwords that require normalisation
13 * are not supported. Server iteration counts above 1,000,000 are rejected
14 * before password derivation.
15 *
16 * @since 4.0.0
17 */
18import * as Data from "effect/Data"
19import * as Base64 from "effect/encoding/Base64"
20import * as Result from "effect/Result"
21import { createHash, createHmac, pbkdf2Sync } from "node:crypto"
22
23/**
24 * Failure returned when an authentication exchange cannot be completed.
25 *
26 * @category errors
27 * @since 4.0.0
28 */
29export class AuthError extends Data.TaggedError("PgAuthError")<{
30 readonly message: string
31}> {}
32
33const fail = (message: string): never => {
34 throw new AuthError({ message })
35}
36
37const result = <A>(evaluate: () => A): Result.Result<A, AuthError> => {
38 try {
39 return Result.succeed(evaluate())
40 } catch (error) {
41 if (error instanceof AuthError) return Result.fail(error)
42 throw error
43 }
44}
45
46const textEncoder = new TextEncoder()
47const textDecoder = new TextDecoder("utf-8", { fatal: true })
48/** Bounds synchronous PBKDF2 work requested by an untrusted server. */
49const maxScramIterations = 1_000_000
50
51const bytesOf = (value: Uint8Array): Uint8Array => Uint8Array.from(value)
52
53const sha256 = (data: Uint8Array): Uint8Array => bytesOf(createHash("sha256").update(data).digest())
54
55const hmacSha256 = (key: Uint8Array, data: Uint8Array): Uint8Array =>
56 bytesOf(createHmac("sha256", key).update(data).digest())
57
58const xor = (left: Uint8Array, right: Uint8Array): Uint8Array => {
59 const result = new Uint8Array(left.length)
60 for (let i = 0; i < left.length; i++) {
61 result[i] = left[i] ^ right[i]
62 }
63 return result
64}
65
66const toBase64 = (bytes: Uint8Array): string => Base64.encode(bytes)
67
68const fromBase64 = (text: string, field: string): Uint8Array => {
69 const decoded = Base64.decode(text)
70 if (Result.isFailure(decoded)) {
71 return fail(`Invalid base64 in SCRAM attribute "${field}"`)
72 }
73 return decoded.success
74}
75
76const decodeUtf8 = (bytes: Uint8Array, what: string): string => {
77 try {
78 return textDecoder.decode(bytes)
79 } catch {
80 return fail(`Invalid UTF-8 in ${what}`)
81 }
82}
83
84/**
85 * Computes the password string for an `AuthenticationMD5Password` challenge:
86 * `"md5" + md5(md5(password + user) + salt)`. Send the result verbatim as a
87 * `PasswordMessage`.
88 *
89 * @category MD5
90 * @since 4.0.0
91 */
92const md5PasswordUnsafe = (options: {
93 readonly user: string
94 readonly password: string
95 readonly salt: Uint8Array
96}): string => {
97 if (options.salt.length !== 4) {
98 return fail(`PostgreSQL MD5 salt must be exactly 4 bytes, received ${options.salt.length}`)
99 }
100 const inner = createHash("md5").update(textEncoder.encode(options.password + options.user)).digest("hex")
101 const outer = createHash("md5").update(textEncoder.encode(inner)).update(options.salt).digest("hex")
102 return `md5${outer}`
103}
104
105/**
106 * Computes the legacy PostgreSQL MD5 password response.
107 *
108 * @category authentication
109 * @since 4.0.0
110 */
111export const md5Password = (options: {
112 readonly user: string
113 readonly password: string
114 readonly salt: Uint8Array
115}): Result.Result<string, AuthError> => result(() => md5PasswordUnsafe(options))
116
117/**
118 * The only SASL mechanism this module implements.
119 *
120 * @category SCRAM
121 * @since 4.0.0
122 */
123export const SCRAM_SHA_256 = "SCRAM-SHA-256"
124
125/**
126 * State after the client's first message, awaiting the server's challenge.
127 *
128 * @category SCRAM
129 * @since 4.0.0
130 */
131export interface ScramFirst {
132 readonly _tag: "ScramFirst"
133 readonly password: string
134 readonly clientNonce: string
135 readonly clientFirstMessageBare: string
136}
137
138/**
139 * State after the client's final message, awaiting the server signature.
140 *
141 * @category SCRAM
142 * @since 4.0.0
143 */
144export interface ScramFinal {
145 readonly _tag: "ScramFinal"
146 readonly serverSignature: Uint8Array
147}
148
149/**
150 * The SCRAM exchange state.
151 *
152 * @category SCRAM
153 * @since 4.0.0
154 */
155export type ScramState = ScramFirst | ScramFinal
156
157const GS2_HEADER = "n,,"
158/** Base64 of the GS2 header, the `c=` attribute value without channel binding. */
159const CHANNEL_BINDING = "biws"
160
161const parseAttributes = (message: string): Map<string, string> => {
162 const attributes = new Map<string, string>()
163 for (const part of message.split(",")) {
164 const separator = part.indexOf("=")
165 if (separator < 1) {
166 return fail(`Malformed SCRAM attribute: "${part}"`)
167 }
168 const key = part.slice(0, separator)
169 if (attributes.has(key)) {
170 return fail(`Duplicate SCRAM attribute "${key}"`)
171 }
172 attributes.set(key, part.slice(separator + 1))
173 }
174 return attributes
175}
176
177const attribute = (attributes: Map<string, string>, key: string): string =>
178 attributes.get(key) ?? fail(`Missing SCRAM attribute "${key}"`)
179
180/**
181 * Starts a SCRAM-SHA-256 exchange. The `nonce` must be a fresh, random,
182 * printable ASCII string chosen by the caller; this module never generates
183 * randomness.
184 *
185 * The successful result contains the `initialResponse` bytes for a
186 * `SASLInitialResponse` message with mechanism `SCRAM_SHA_256`.
187 *
188 * @category SCRAM
189 * @since 4.0.0
190 */
191const scramInitUnsafe = (options: {
192 readonly password: string
193 readonly nonce: string
194}): { readonly state: ScramFirst; readonly response: Uint8Array } => {
195 if (!/^[\x21-\x2b\x2d-\x7e]+$/.test(options.nonce)) {
196 return fail("SCRAM nonce must contain only printable ASCII characters other than comma")
197 }
198 const clientFirstMessageBare = `n=,r=${options.nonce}`
199 return {
200 state: {
201 _tag: "ScramFirst",
202 password: options.password,
203 clientNonce: options.nonce,
204 clientFirstMessageBare
205 },
206 response: textEncoder.encode(GS2_HEADER + clientFirstMessageBare)
207 }
208}
209
210/**
211 * Creates the first SCRAM-SHA-256 client message.
212 *
213 * @category SCRAM
214 * @since 4.0.0
215 */
216export const scramInit = (options: {
217 readonly password: string
218 readonly nonce: string
219}): Result.Result<{ readonly state: ScramFirst; readonly response: Uint8Array }, AuthError> =>
220 result(() => scramInitUnsafe(options))
221
222/**
223 * Answers an `AuthenticationSASLContinue` challenge.
224 *
225 * The successful result contains the payload bytes for a `SASLResponse`
226 * message.
227 *
228 * @category SCRAM
229 * @since 4.0.0
230 */
231const scramContinueUnsafe = (
232 state: ScramFirst,
233 challenge: Uint8Array
234): { readonly state: ScramFinal; readonly response: Uint8Array } => {
235 const serverFirstMessage = decodeUtf8(challenge, "the SCRAM server-first-message")
236 const attributes = parseAttributes(serverFirstMessage)
237 const nonce = attribute(attributes, "r")
238 if (!nonce.startsWith(state.clientNonce) || nonce.length === state.clientNonce.length) {
239 return fail("SCRAM server nonce does not extend the client nonce")
240 }
241 const saltText = attribute(attributes, "s")
242 if (saltText.length === 0) {
243 return fail("SCRAM salt must not be empty")
244 }
245 const salt = fromBase64(saltText, "s")
246 const iterationText = attribute(attributes, "i")
247 const iterations = Number(iterationText)
248 if (
249 !/^[1-9]\d*$/.test(iterationText) ||
250 !Number.isSafeInteger(iterations) ||
251 iterations > maxScramIterations
252 ) {
253 return fail(`Invalid SCRAM iteration count: ${iterationText}`)
254 }
255
256 const saltedPassword = bytesOf(
257 pbkdf2Sync(textEncoder.encode(state.password), salt, iterations, 32, "sha256")
258 )
259 const clientKey = hmacSha256(saltedPassword, textEncoder.encode("Client Key"))
260 const storedKey = sha256(clientKey)
261 const clientFinalMessageWithoutProof = `c=${CHANNEL_BINDING},r=${nonce}`
262 const authMessage = textEncoder.encode(
263 `${state.clientFirstMessageBare},${serverFirstMessage},${clientFinalMessageWithoutProof}`
264 )
265 const clientProof = xor(clientKey, hmacSha256(storedKey, authMessage))
266 const serverKey = hmacSha256(saltedPassword, textEncoder.encode("Server Key"))
267
268 return {
269 state: {
270 _tag: "ScramFinal",
271 serverSignature: hmacSha256(serverKey, authMessage)
272 },
273 response: textEncoder.encode(`${clientFinalMessageWithoutProof},p=${toBase64(clientProof)}`)
274 }
275}
276
277/**
278 * Processes the server's SCRAM challenge and creates the client proof.
279 *
280 * @category SCRAM
281 * @since 4.0.0
282 */
283export const scramContinue = (
284 state: ScramFirst,
285 challenge: Uint8Array
286): Result.Result<{ readonly state: ScramFinal; readonly response: Uint8Array }, AuthError> =>
287 result(() => scramContinueUnsafe(state, challenge))
288
289/**
290 * Verifies the server signature carried by `AuthenticationSASLFinal`. Returns
291 * `Result.void` on success and an `AuthError` failure when the server could
292 * not prove that it knows the stored key.
293 *
294 * @category SCRAM
295 * @since 4.0.0
296 */
297const scramFinishUnsafe = (state: ScramFinal, challenge: Uint8Array): void => {
298 const attributes = parseAttributes(decodeUtf8(challenge, "the SCRAM server-final-message"))
299 const error = attributes.get("e")
300 if (error !== undefined) {
301 fail(`SCRAM authentication failed: ${error}`)
302 }
303 const signature = fromBase64(attribute(attributes, "v"), "v")
304 if (toBase64(state.serverSignature) !== toBase64(signature)) {
305 fail("SCRAM server signature mismatch")
306 }
307}
308
309/**
310 * Verifies the server's final SCRAM message.
311 *
312 * @category SCRAM
313 * @since 4.0.0
314 */
315export const scramFinish = (
316 state: ScramFinal,
317 challenge: Uint8Array
318): Result.Result<void, AuthError> => result(() => scramFinishUnsafe(state, challenge))