1import { PgAuth } from "@effect/sql-pg"
2import { assert, describe, it } from "@effect/vitest"
3import * as Result from "effect/Result"
4import { bytes, md5, scram } from "./fixtures/goldens.ts"
5
6const encoder = new TextEncoder()
7const decoder = new TextDecoder()
8
9const success = <A, E>(result: Result.Result<A, E>): A => {
10 assert.isTrue(Result.isSuccess(result))
11 return (result as Result.Success<A, E>).success
12}
13
14const assertFailureTagged = (tag: string, run: () => Result.Result<unknown, { readonly _tag: string }>) => {
15 const result = run()
16 assert.isTrue(Result.isFailure(result))
17 if (Result.isFailure(result)) assert.strictEqual(result.failure._tag, tag)
18}
19
20describe("PgAuth", () => {
21 describe("md5Password", () => {
22 it("matches the response PostgreSQL accepted", () => {
23 assert.strictEqual(
24 success(PgAuth.md5Password({ user: md5.user, password: md5.password, salt: bytes(md5.salt) })),
25 md5.expected
26 )
27 })
28
29 it("depends on the salt", () => {
30 const other = success(PgAuth.md5Password({
31 user: md5.user,
32 password: md5.password,
33 salt: bytes("00000000")
34 }))
35 assert.notStrictEqual(other, md5.expected)
36 })
37
38 it("rejects a salt that is not exactly four bytes", () => {
39 for (const salt of [new Uint8Array(0), new Uint8Array(3), new Uint8Array(5)]) {
40 const result = PgAuth.md5Password({ user: "u", password: "p", salt })
41 assert.isTrue(Result.isFailure(result))
42 if (Result.isFailure(result)) assert.strictEqual(result.failure._tag, "PgAuthError")
43 }
44 })
45 })
46
47 describe("SCRAM-SHA-256", () => {
48 it("replays a captured PostgreSQL exchange", () => {
49 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
50 assert.strictEqual(decoder.decode(started.response), scram.clientFirstMessage)
51
52 const continued = success(PgAuth.scramContinue(started.state, encoder.encode(scram.serverFirstMessage)))
53 assert.strictEqual(decoder.decode(continued.response), scram.clientFinalMessage)
54
55 success(PgAuth.scramFinish(continued.state, encoder.encode(scram.serverFinalMessage)))
56 })
57
58 it("rejects a server nonce that does not extend the client nonce", () => {
59 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
60 assertFailureTagged(
61 "PgAuthError",
62 () => PgAuth.scramContinue(started.state, encoder.encode("r=other,s=DBRmN4Xi9iMOo1tZfsi+Hg==,i=4096"))
63 )
64 })
65
66 it("rejects a server nonce that only echoes the client nonce", () => {
67 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
68 assertFailureTagged(
69 "PgAuthError",
70 () =>
71 PgAuth.scramContinue(
72 started.state,
73 encoder.encode(`r=${scram.clientNonce},s=DBRmN4Xi9iMOo1tZfsi+Hg==,i=4096`)
74 )
75 )
76 })
77
78 it("rejects a missing attribute", () => {
79 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
80 assertFailureTagged(
81 "PgAuthError",
82 () => PgAuth.scramContinue(started.state, encoder.encode(`r=${scram.clientNonce}x,i=4096`))
83 )
84 })
85
86 it("rejects duplicate server attributes", () => {
87 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
88 assertFailureTagged(
89 "PgAuthError",
90 () =>
91 PgAuth.scramContinue(
92 started.state,
93 encoder.encode(`${scram.serverFirstMessage},r=${scram.clientNonce}replacement`)
94 )
95 )
96 })
97
98 it("rejects malformed salts and iteration counts", () => {
99 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
100 const nonce = `${scram.clientNonce}server`
101 for (
102 const challenge of [
103 `r=${nonce},s=,i=4096`,
104 `r=${nonce},s=*,i=4096`,
105 `r=${nonce},s=AA==,i=0`,
106 `r=${nonce},s=AA==,i=-1`,
107 `r=${nonce},s=AA==,i=1.5`,
108 `r=${nonce},s=AA==,i=1e3`,
109 `r=${nonce},s=AA==,i=1000001`,
110 `r=${nonce},s=AA==,i=2147483648`
111 ]
112 ) {
113 assertFailureTagged("PgAuthError", () => PgAuth.scramContinue(started.state, encoder.encode(challenge)))
114 }
115 })
116
117 it("rejects malformed SCRAM text", () => {
118 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
119 assertFailureTagged("PgAuthError", () => PgAuth.scramContinue(started.state, new Uint8Array([0xff])))
120 assertFailureTagged("PgAuthError", () => PgAuth.scramContinue(started.state, encoder.encode("not-an-attribute")))
121 })
122
123 it("rejects a tampered server signature", () => {
124 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
125 const continued = success(PgAuth.scramContinue(started.state, encoder.encode(scram.serverFirstMessage)))
126 assertFailureTagged(
127 "PgAuthError",
128 () => PgAuth.scramFinish(continued.state, encoder.encode("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="))
129 )
130 })
131
132 it("surfaces a server error attribute", () => {
133 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
134 const continued = success(PgAuth.scramContinue(started.state, encoder.encode(scram.serverFirstMessage)))
135 assertFailureTagged(
136 "PgAuthError",
137 () => PgAuth.scramFinish(continued.state, encoder.encode("e=invalid-proof"))
138 )
139 })
140
141 it("rejects malformed server-final messages", () => {
142 const started = success(PgAuth.scramInit({ password: scram.password, nonce: scram.clientNonce }))
143 const continued = success(PgAuth.scramContinue(started.state, encoder.encode(scram.serverFirstMessage)))
144 for (
145 const challenge of [
146 new Uint8Array([0xff]),
147 encoder.encode("x=missing-signature"),
148 encoder.encode("v=*"),
149 encoder.encode(`${scram.serverFinalMessage},${scram.serverFinalMessage}`)
150 ]
151 ) {
152 assertFailureTagged("PgAuthError", () => PgAuth.scramFinish(continued.state, challenge))
153 }
154 })
155
156 it("rejects a nonce outside SCRAM's printable ASCII range", () => {
157 for (const nonce of ["", "a,b", "with space", "line\nbreak", "nönce"]) {
158 assertFailureTagged("PgAuthError", () => PgAuth.scramInit({ password: "x", nonce }))
159 }
160 })
161
162 it("derives a different proof for a different password", () => {
163 const started = success(PgAuth.scramInit({ password: "wrong", nonce: scram.clientNonce }))
164 const continued = success(PgAuth.scramContinue(started.state, encoder.encode(scram.serverFirstMessage)))
165 assert.notStrictEqual(decoder.decode(continued.response), scram.clientFinalMessage)
166 assertFailureTagged(
167 "PgAuthError",
168 () => PgAuth.scramFinish(continued.state, encoder.encode(scram.serverFinalMessage))
169 )
170 })
171 })
172})