diff --git a/connection/connection.ts b/connection/connection.ts index 9f05f91f..15d8f0ba 100644 --- a/connection/connection.ts +++ b/connection/connection.ts @@ -40,6 +40,7 @@ import { ResultType, } from "../query/query.ts"; import type { ConnectionParams } from "./connection_params.ts"; +import * as scram from "./scram.ts"; export enum Format { TEXT = 0, @@ -363,9 +364,10 @@ export class Connection { } // scram-sha-256 password case 10: { - throw new Error( - "Database server expected scram-sha-256 authentication, which is not supported at the moment", + await assertSuccessfulAuthentication( + await this.authenticateWithScramSha256(), ); + break; } default: throw new Error(`Unknown auth message code ${code}`); @@ -403,6 +405,77 @@ export class Connection { return this.readMessage(); } + private async authenticateWithScramSha256(): Promise { + if (!this.connParams.password) { + throw new Error( + "Auth Error: attempting SCRAM-SHA-256 auth with password unset", + ); + } + + const client = new scram.Client( + this.connParams.user, + this.connParams.password, + ); + const utf8 = new TextDecoder("utf-8"); + + // SASLInitialResponse + const clientFirstMessage = client.composeChallenge(); + this.#packetWriter.clear(); + this.#packetWriter.addCString("SCRAM-SHA-256"); + this.#packetWriter.addInt32(clientFirstMessage.length); + this.#packetWriter.addString(clientFirstMessage); + this.#bufWriter.write(this.#packetWriter.flush(0x70)); + this.#bufWriter.flush(); + + // AuthenticationSASLContinue + const saslContinue = await this.readMessage(); + switch (saslContinue.type) { + case "R": { + if (saslContinue.reader.readInt32() != 11) { + throw new Error("AuthenticationSASLContinue is expected"); + } + break; + } + case "E": { + throw parseError(saslContinue); + } + default: { + throw new Error("unexpected message"); + } + } + const serverFirstMessage = utf8.decode(saslContinue.reader.readAllBytes()); + client.receiveChallenge(serverFirstMessage); + + // SASLResponse + const clientFinalMessage = client.composeResponse(); + this.#packetWriter.clear(); + this.#packetWriter.addString(clientFinalMessage); + this.#bufWriter.write(this.#packetWriter.flush(0x70)); + this.#bufWriter.flush(); + + // AuthenticationSASLFinal + const saslFinal = await this.readMessage(); + switch (saslFinal.type) { + case "R": { + if (saslFinal.reader.readInt32() !== 12) { + throw new Error("AuthenticationSASLFinal is expected"); + } + break; + } + case "E": { + throw parseError(saslFinal); + } + default: { + throw new Error("unexpected message"); + } + } + const serverFinalMessage = utf8.decode(saslFinal.reader.readAllBytes()); + client.receiveResponse(serverFinalMessage); + + // AuthenticationOK + return this.readMessage(); + } + private _processBackendKeyData(msg: Message) { this.#pid = msg.reader.readInt32(); this.#secretKey = msg.reader.readInt32(); diff --git a/connection/packet_reader.ts b/connection/packet_reader.ts index 188e0d15..7b360a9e 100644 --- a/connection/packet_reader.ts +++ b/connection/packet_reader.ts @@ -30,6 +30,12 @@ export class PacketReader { return slice; } + readAllBytes(): Uint8Array { + const slice = this.buffer.slice(this.offset); + this.offset = this.buffer.length; + return slice; + } + readString(length: number): string { const bytes = this.readBytes(length); return this.decoder.decode(bytes); diff --git a/connection/scram.ts b/connection/scram.ts new file mode 100644 index 00000000..da5c0de8 --- /dev/null +++ b/connection/scram.ts @@ -0,0 +1,308 @@ +import { base64, HmacSha256, Sha256 } from "../deps.ts"; + +function assert(cond: unknown): asserts cond { + if (!cond) { + throw new Error("assertion failed"); + } +} + +/** Error thrown on SCRAM authentication failure. */ +export class AuthError extends Error { + constructor(public reason: Reason, message?: string) { + super(message ?? reason); + } +} + +/** Reason of authentication failure. */ +export enum Reason { + BadMessage = "server sent an ill-formed message", + BadServerNonce = "server sent an invalid nonce", + BadSalt = "server specified an invalid salt", + BadIterationCount = "server specified an invalid iteration count", + BadVerifier = "server sent a bad verifier", + Rejected = "rejected by server", +} + +/** SCRAM authentication state. */ +enum State { + Init, + ClientChallenge, + ServerChallenge, + ClientResponse, + ServerResponse, + Failed, +} + +/** Number of random bytes used to generate a nonce. */ +const defaultNonceSize = 16; + +/** + * Client composes and verifies SCRAM authentication messages, keeping track + * of authentication state and parameters. + * @see {@link https://tools.ietf.org/html/rfc5802} + */ +export class Client { + private username: string; + private password: string; + private keys?: Keys; + private clientNonce: string; + private serverNonce?: string; + private authMessage: string; + private state: State; + + /** Constructor sets credentials and parameters used in an authentication. */ + constructor(username: string, password: string, nonce?: string) { + this.username = username; + this.password = password; + this.clientNonce = nonce ?? generateNonce(defaultNonceSize); + this.authMessage = ""; + this.state = State.Init; + } + + /** Composes client-first-message. */ + composeChallenge(): string { + assert(this.state === State.Init); + + try { + // "n" for no channel binding, then an empty authzid option follows. + const header = "n,,"; + + const username = escape(normalize(this.username)); + const challenge = `n=${username},r=${this.clientNonce}`; + const message = header + challenge; + + this.authMessage += challenge; + this.state = State.ClientChallenge; + return message; + } catch (e) { + this.state = State.Failed; + throw e; + } + } + + /** Processes server-first-message. */ + receiveChallenge(challenge: string) { + assert(this.state === State.ClientChallenge); + + try { + const attrs = parseAttributes(challenge); + + const nonce = attrs.r; + if (!attrs.r || !attrs.r.startsWith(this.clientNonce)) { + throw new AuthError(Reason.BadServerNonce); + } + this.serverNonce = nonce; + + let salt: Uint8Array | undefined; + if (!attrs.s) { + throw new AuthError(Reason.BadSalt); + } + try { + salt = base64.decode(attrs.s); + } catch { + throw new AuthError(Reason.BadSalt); + } + + const iterCount = parseInt(attrs.i) | 0; + if (iterCount <= 0) { + throw new AuthError(Reason.BadIterationCount); + } + + this.keys = deriveKeys(this.password, salt, iterCount); + + this.authMessage += "," + challenge; + this.state = State.ServerChallenge; + } catch (e) { + this.state = State.Failed; + throw e; + } + } + + /** Composes client-final-message. */ + composeResponse(): string { + assert(this.state === State.ServerChallenge); + assert(this.keys); + assert(this.serverNonce); + + try { + // "biws" is the base-64 encoded form of the gs2-header "n,,". + const responseWithoutProof = `c=biws,r=${this.serverNonce}`; + + this.authMessage += "," + responseWithoutProof; + + const proof = base64.encode( + computeProof( + computeSignature(this.authMessage, this.keys.stored), + this.keys.client, + ), + ); + const message = `${responseWithoutProof},p=${proof}`; + + this.state = State.ClientResponse; + return message; + } catch (e) { + this.state = State.Failed; + throw e; + } + } + + /** Processes server-final-message. */ + receiveResponse(response: string) { + assert(this.state === State.ClientResponse); + assert(this.keys); + + try { + const attrs = parseAttributes(response); + + if (attrs.e) { + throw new AuthError(Reason.Rejected, attrs.e); + } + + const verifier = base64.encode( + computeSignature(this.authMessage, this.keys.server), + ); + if (attrs.v !== verifier) { + throw new AuthError(Reason.BadVerifier); + } + + this.state = State.ServerResponse; + } catch (e) { + this.state = State.Failed; + throw e; + } + } +} + +/** Generates a random nonce string. */ +function generateNonce(size: number): string { + return base64.encode(crypto.getRandomValues(new Uint8Array(size))); +} + +/** Parses attributes out of a SCRAM message. */ +function parseAttributes(str: string): Record { + const attrs: Record = {}; + + for (const entry of str.split(",")) { + const pos = entry.indexOf("="); + if (pos < 1) { + throw new AuthError(Reason.BadMessage); + } + + const key = entry.substr(0, pos); + const value = entry.substr(pos + 1); + attrs[key] = value; + } + + return attrs; +} + +/** HMAC-derived binary key. */ +type Key = Uint8Array; + +/** Binary digest. */ +type Digest = Uint8Array; + +/** Collection of SCRAM authentication keys derived from a plaintext password. */ +interface Keys { + server: Key; + client: Key; + stored: Key; +} + +/** Derives authentication keys from a plaintext password. */ +function deriveKeys( + password: string, + salt: Uint8Array, + iterCount: number, +): Keys { + const ikm = bytes(normalize(password)); + const key = pbkdf2((msg: Uint8Array) => sign(msg, ikm), salt, iterCount, 1); + const server = sign(bytes("Server Key"), key); + const client = sign(bytes("Client Key"), key); + const stored = digest(client); + return { server, client, stored }; +} + +/** Computes SCRAM signature. */ +function computeSignature(message: string, key: Key): Digest { + return sign(bytes(message), key); +} + +/** Computes SCRAM proof. */ +function computeProof(signature: Digest, key: Key): Digest { + const proof = new Uint8Array(signature.length); + for (let i = 0; i < proof.length; i++) { + proof[i] = signature[i] ^ key[i]; + } + return proof; +} + +/** Returns UTF-8 bytes encoding given string. */ +function bytes(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +/** + * Normalizes string per SASLprep. + * @see {@link https://tools.ietf.org/html/rfc3454} + * @see {@link https://tools.ietf.org/html/rfc4013} + */ +function normalize(str: string): string { + // TODO: Handle mapping and maybe unicode normalization. + const unsafe = /[^\x21-\x7e]/; + if (unsafe.test(str)) { + throw new Error( + "scram username/password is currently limited to safe ascii characters", + ); + } + return str; +} + +/** Escapes "=" and "," in a string. */ +function escape(str: string): string { + return str + .replace(/=/g, "=3D") + .replace(/,/g, "=2C"); +} + +/** Computes message digest. */ +function digest(msg: Uint8Array): Digest { + const hash = new Sha256(); + hash.update(msg); + return new Uint8Array(hash.arrayBuffer()); +} + +/** Computes HMAC of a message using given key. */ +function sign(msg: Uint8Array, key: Key): Digest { + const hmac = new HmacSha256(key); + hmac.update(msg); + return new Uint8Array(hmac.arrayBuffer()); +} + +/** + * Computes a PBKDF2 key block. + * @see {@link https://tools.ietf.org/html/rfc2898} + */ +function pbkdf2( + prf: (_: Uint8Array) => Digest, + salt: Uint8Array, + iterCount: number, + index: number, +): Key { + let block = new Uint8Array(salt.length + 4); + block.set(salt); + block[salt.length + 0] = (index >> 24) & 0xFF; + block[salt.length + 1] = (index >> 16) & 0xFF; + block[salt.length + 2] = (index >> 8) & 0xFF; + block[salt.length + 3] = index & 0xFF; + block = prf(block); + + const key = block; + for (let r = 1; r < iterCount; r++) { + block = prf(block); + for (let i = 0; i < key.length; i++) { + key[i] ^= block[i]; + } + } + return key; +} diff --git a/deps.ts b/deps.ts index ab97f514..11b43112 100644 --- a/deps.ts +++ b/deps.ts @@ -1,6 +1,11 @@ export { BufReader, BufWriter } from "https://deno.land/std@0.85.0/io/bufio.ts"; export { copy } from "https://deno.land/std@0.85.0/bytes/mod.ts"; export { createHash } from "https://deno.land/std@0.85.0/hash/mod.ts"; +export { + HmacSha256, + Sha256, +} from "https://deno.land/std@0.85.0/hash/sha256.ts"; +export * as base64 from "https://deno.land/std@0.85.0/encoding/base64.ts"; export { deferred, delay } from "https://deno.land/std@0.85.0/async/mod.ts"; export { bold, yellow } from "https://deno.land/std@0.85.0/fmt/colors.ts"; export type { Deferred } from "https://deno.land/std@0.85.0/async/mod.ts"; diff --git a/docker-compose.yml b/docker-compose.yml index f5fdccf0..3dd7a0e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3.8' services: - database: + postgres: image: postgres hostname: postgres environment: @@ -9,14 +9,27 @@ services: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres volumes: - - ./docker/data/:/var/lib/postgresql/host/ - - ./docker/init/:/docker-entrypoint-initdb.d/ + - ./docker/postgres/data/:/var/lib/postgresql/host/ + - ./docker/postgres/init/:/docker-entrypoint-initdb.d/ + postgres_scram: + image: postgres + hostname: postgres_scram + environment: + - POSTGRES_DB=postgres + - POSTGRES_HOST_AUTH_METHOD=scram-sha-256 + - POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 + - POSTGRES_PASSWORD=postgres + - POSTGRES_USER=postgres + volumes: + - ./docker/postgres_scram/data/:/var/lib/postgresql/host/ + - ./docker/postgres_scram/init/:/docker-entrypoint-initdb.d/ tests: build: . depends_on: - - database + - postgres + - postgres_scram environment: - - WAIT_HOSTS=postgres:5432 + - WAIT_HOSTS=postgres:5432,postgres_scram:5432 # Wait thirty seconds after database goes online # For database metadata initialization - WAIT_AFTER_HOSTS=15 diff --git a/docker/init/initialize_test_server.sql b/docker/init/initialize_test_server.sql deleted file mode 100644 index d3f919c9..00000000 --- a/docker/init/initialize_test_server.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE USER MD5 WITH PASSWORD 'postgres'; -GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO MD5; - -CREATE USER CLEAR WITH PASSWORD 'postgres'; -GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO CLEAR; diff --git a/docker/data/pg_hba.conf b/docker/postgres/data/pg_hba.conf similarity index 64% rename from docker/data/pg_hba.conf rename to docker/postgres/data/pg_hba.conf index 99211f56..ca7efe5a 100644 --- a/docker/data/pg_hba.conf +++ b/docker/postgres/data/pg_hba.conf @@ -1,3 +1,3 @@ hostnossl all postgres 0.0.0.0/0 md5 +hostnossl postgres clear 0.0.0.0/0 password hostnossl postgres md5 0.0.0.0/0 md5 -hostnossl postgres clear 0.0.0.0/0 password \ No newline at end of file diff --git a/docker/data/postgresql.conf b/docker/postgres/data/postgresql.conf similarity index 100% rename from docker/data/postgresql.conf rename to docker/postgres/data/postgresql.conf diff --git a/docker/init/initialize_test_server.sh b/docker/postgres/init/initialize_test_server.sh similarity index 100% rename from docker/init/initialize_test_server.sh rename to docker/postgres/init/initialize_test_server.sh diff --git a/docker/postgres/init/initialize_test_server.sql b/docker/postgres/init/initialize_test_server.sql new file mode 100644 index 00000000..cc9cfdbe --- /dev/null +++ b/docker/postgres/init/initialize_test_server.sql @@ -0,0 +1,5 @@ +CREATE USER clear WITH PASSWORD 'postgres'; +GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO clear; + +CREATE USER MD5 WITH PASSWORD 'postgres'; +GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO MD5; diff --git a/docker/postgres_scram/data/pg_hba.conf b/docker/postgres_scram/data/pg_hba.conf new file mode 100644 index 00000000..b97cce44 --- /dev/null +++ b/docker/postgres_scram/data/pg_hba.conf @@ -0,0 +1,2 @@ +hostnossl all postgres 0.0.0.0/0 scram-sha-256 +hostnossl postgres scram 0.0.0.0/0 scram-sha-256 diff --git a/docker/postgres_scram/data/postgresql.conf b/docker/postgres_scram/data/postgresql.conf new file mode 100644 index 00000000..91f4196c --- /dev/null +++ b/docker/postgres_scram/data/postgresql.conf @@ -0,0 +1,3 @@ +ssl = off +# ssl_cert_file = 'server.crt' +# ssl_key_file = 'server.key' \ No newline at end of file diff --git a/docker/postgres_scram/init/initialize_test_server.sh b/docker/postgres_scram/init/initialize_test_server.sh new file mode 100644 index 00000000..2bba73f0 --- /dev/null +++ b/docker/postgres_scram/init/initialize_test_server.sh @@ -0,0 +1,4 @@ +cat /var/lib/postgresql/host/postgresql.conf >> /var/lib/postgresql/data/postgresql.conf +cp /var/lib/postgresql/host/pg_hba.conf /var/lib/postgresql/data +# chmod 600 /var/lib/postgresql/data/server.crt +# chmod 600 /var/lib/postgresql/data/server.key \ No newline at end of file diff --git a/docker/postgres_scram/init/initialize_test_server.sql b/docker/postgres_scram/init/initialize_test_server.sql new file mode 100644 index 00000000..45a8a3aa --- /dev/null +++ b/docker/postgres_scram/init/initialize_test_server.sql @@ -0,0 +1,2 @@ +CREATE USER SCRAM WITH PASSWORD 'postgres'; +GRANT ALL PRIVILEGES ON DATABASE POSTGRES TO SCRAM; diff --git a/tests/config.json b/tests/config.json index 457395b3..260aae4b 100644 --- a/tests/config.json +++ b/tests/config.json @@ -1,12 +1,24 @@ { - "applicationName": "deno_postgres", - "database": "postgres", - "hostname": "postgres", - "password": "postgres", - "port": 5432, - "users": { - "clear": "clear", - "main": "postgres", - "md5": "md5" + "postgres": { + "applicationName": "deno_postgres", + "database": "postgres", + "hostname": "postgres", + "password": "postgres", + "port": 5432, + "users": { + "clear": "clear", + "main": "postgres", + "md5": "md5" + } + }, + "postgres_scram": { + "applicationName": "deno_postgres", + "database": "postgres", + "hostname": "postgres_scram", + "password": "postgres", + "port": 5432, + "users": { + "scram": "scram" + } } } diff --git a/tests/config.ts b/tests/config.ts index 57caadbc..b4e9c322 100644 --- a/tests/config.ts +++ b/tests/config.ts @@ -17,47 +17,70 @@ try { } const config: { - applicationName: string; - database: string; - hostname: string; - password: string; - port: string | number; - users: { - clear: string; - main: string; - md5: string; + postgres: { + applicationName: string; + database: string; + hostname: string; + password: string; + port: string | number; + users: { + clear: string; + main: string; + md5: string; + }; + }; + postgres_scram: { + applicationName: string; + database: string; + hostname: string; + password: string; + port: string | number; + users: { + scram: string; + }; }; } = JSON.parse(content); export const getClearConfiguration = (): ConnectionOptions => { return { - applicationName: config.applicationName, - database: config.database, - hostname: config.hostname, - password: config.password, - port: config.port, - user: config.users.main, + applicationName: config.postgres.applicationName, + database: config.postgres.database, + hostname: config.postgres.hostname, + password: config.postgres.password, + port: config.postgres.port, + user: config.postgres.users.clear, }; }; export const getMainConfiguration = (): ConnectionOptions => { return { - applicationName: config.applicationName, - database: config.database, - hostname: config.hostname, - password: config.password, - port: config.port, - user: config.users.main, + applicationName: config.postgres.applicationName, + database: config.postgres.database, + hostname: config.postgres.hostname, + password: config.postgres.password, + port: config.postgres.port, + user: config.postgres.users.main, }; }; export const getMd5Configuration = (): ConnectionOptions => { return { - applicationName: config.applicationName, - database: config.database, - hostname: config.hostname, - password: config.password, - port: config.port, - user: config.users.main, + applicationName: config.postgres.applicationName, + database: config.postgres.database, + hostname: config.postgres.hostname, + password: config.postgres.password, + port: config.postgres.port, + user: config.postgres.users.md5, + }; +}; + +export const getScramSha256Configuration = (): ConnectionOptions => { + return { + applicationName: config.postgres_scram.applicationName, + database: config.postgres_scram.database, + hostname: config.postgres_scram.hostname, + password: config.postgres_scram.password, + port: config.postgres_scram.port, + user: config.postgres_scram.users.scram, }; }; diff --git a/tests/connection_test.ts b/tests/connection_test.ts index 048a6672..b64297d5 100644 --- a/tests/connection_test.ts +++ b/tests/connection_test.ts @@ -3,6 +3,7 @@ import { getClearConfiguration, getMainConfiguration, getMd5Configuration, + getScramSha256Configuration, } from "./config.ts"; import { Client, PostgresError } from "../mod.ts"; @@ -39,6 +40,12 @@ Deno.test("MD5 authentication (no tls)", async () => { await client.end(); }); +Deno.test("SCRAM-SHA-256 authentication (no tls)", async () => { + const client = new Client(getScramSha256Configuration()); + await client.connect(); + await client.end(); +}); + // This test requires current user database connection permissions // on "pg_hba.conf" set to "all" Deno.test("Startup error when database does not exist", async function () { diff --git a/tests/scram_test.ts b/tests/scram_test.ts new file mode 100644 index 00000000..8e0aa0bc --- /dev/null +++ b/tests/scram_test.ts @@ -0,0 +1,86 @@ +import { assertEquals, assertNotEquals, assertThrows } from "./test_deps.ts"; +import * as scram from "../connection/scram.ts"; + +Deno.test("scram.Client reproduces RFC 7677 example", () => { + // Example seen in https://tools.ietf.org/html/rfc7677 + const client = new scram.Client("user", "pencil", "rOprNGfwEbeRWgbNEkqO"); + + assertEquals( + client.composeChallenge(), + "n,,n=user,r=rOprNGfwEbeRWgbNEkqO", + ); + client.receiveChallenge( + "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," + + "s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096", + ); + assertEquals( + client.composeResponse(), + "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0," + + "p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=", + ); + client.receiveResponse( + "v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=", + ); +}); + +Deno.test("scram.Client catches bad server nonce", () => { + const testCases = [ + "s=c2FsdA==,i=4096", // no server nonce + "r=,s=c2FsdA==,i=4096", // empty + "r=nonce2,s=c2FsdA==,i=4096", // not prefixed with client nonce + ]; + for (const testCase of testCases) { + const client = new scram.Client("user", "password", "nonce1"); + client.composeChallenge(); + assertThrows(() => client.receiveChallenge(testCase)); + } +}); + +Deno.test("scram.Client catches bad salt", () => { + const testCases = [ + "r=nonce12,i=4096", // no salt + "r=nonce12,s=*,i=4096", // ill-formed base-64 string + ]; + for (const testCase of testCases) { + const client = new scram.Client("user", "password", "nonce1"); + client.composeChallenge(); + assertThrows(() => client.receiveChallenge(testCase)); + } +}); + +Deno.test("scram.Client catches bad iteration count", () => { + const testCases = [ + "r=nonce12,s=c2FsdA==", // no iteration count + "r=nonce12,s=c2FsdA==,i=", // empty + "r=nonce12,s=c2FsdA==,i=*", // not a number + "r=nonce12,s=c2FsdA==,i=0", // non-positive integer + "r=nonce12,s=c2FsdA==,i=-1", // non-positive integer + ]; + for (const testCase of testCases) { + const client = new scram.Client("user", "password", "nonce1"); + client.composeChallenge(); + assertThrows(() => client.receiveChallenge(testCase)); + } +}); + +Deno.test("scram.Client catches bad verifier", () => { + const client = new scram.Client("user", "password", "nonce1"); + client.composeChallenge(); + client.receiveChallenge("r=nonce12,s=c2FsdA==,i=4096"); + client.composeResponse(); + assertThrows(() => client.receiveResponse("v=xxxx")); +}); + +Deno.test("scram.Client catches server rejection", () => { + const client = new scram.Client("user", "password", "nonce1"); + client.composeChallenge(); + client.receiveChallenge("r=nonce12,s=c2FsdA==,i=4096"); + client.composeResponse(); + assertThrows(() => client.receiveResponse("e=auth error")); +}); + +Deno.test("scram.Client generates unique challenge", () => { + const challenge1 = new scram.Client("user", "password").composeChallenge(); + const challenge2 = new scram.Client("user", "password").composeChallenge(); + assertNotEquals(challenge1, challenge2); +}); diff --git a/tests/test_deps.ts b/tests/test_deps.ts index 88c018e6..2a3e4ef4 100644 --- a/tests/test_deps.ts +++ b/tests/test_deps.ts @@ -2,6 +2,7 @@ export * from "../deps.ts"; export { assert, assertEquals, + assertNotEquals, assertThrows, assertThrowsAsync, } from "https://deno.land/std@0.85.0/testing/asserts.ts";