| 1 | import { PgAuth } from "@effect/sql-pg" |
| 2 | import { assert, describe, it } from "@effect/vitest" |
| 3 | import * as Result from "effect/Result" |
| 4 | import { bytes, md5, scram } from "./fixtures/goldens.ts" |
| 5 | |
| 6 | const encoder = new TextEncoder() |
| 7 | const decoder = new TextDecoder() |
| 8 | |
| 9 | const 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 | |
| 14 | const 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 | |
| 20 | describe("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 | }) |