diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b74a30..877a860 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v1 - name: Install Deno 1.x - uses: denolib/setup-deno@master + uses: denoland/setup-deno@v1 with: deno-version: v1.x - name: Check fmt @@ -37,7 +37,7 @@ jobs: steps: - uses: actions/checkout@v1 - name: Install Deno ${{ matrix.DENO_VERSION }} - uses: denolib/setup-deno@master + uses: denoland/setup-deno@v1 with: deno-version: ${{ matrix.DENO_VERSION }} - name: Show Deno version @@ -46,10 +46,10 @@ jobs: run: | sudo mkdir -p /var/run/mysqld/tmp sudo chmod -R 777 /var/run/mysqld - docker container run --rm -d -p 3306:3306 \ + docker container run --name mysql --rm -d -p 3306:3306 \ -v /var/run/mysqld:/var/run/mysqld \ -v /var/run/mysqld/tmp:/tmp \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=true \ + -e MYSQL_ROOT_PASSWORD=root \ ${{ matrix.DB_VERSION }} ./.github/workflows/wait-for-mysql.sh - name: Run tests (TCP) @@ -61,7 +61,8 @@ jobs: if [[ "${{ matrix.DB_VERSION }}" == "mysql:5.5" ]]; then SOCKPATH=/var/run/mysqld/tmp/mysql.sock fi + echo "DROP USER 'root'@'localhost';" | docker exec -i mysql mysql -proot DB_SOCKPATH=$SOCKPATH TEST_METHODS=unix \ - deno test --unstable --allow-env --allow-net=127.0.0.1:3306 \ + deno test --unstable --allow-env \ --allow-read=/var/run/mysqld/ --allow-write=/var/run/mysqld/ \ ./test.ts diff --git a/.github/workflows/wait-for-mysql.sh b/.github/workflows/wait-for-mysql.sh index dc4a5da..13302dd 100755 --- a/.github/workflows/wait-for-mysql.sh +++ b/.github/workflows/wait-for-mysql.sh @@ -1,15 +1,11 @@ #!/bin/sh echo "Waiting for MySQL" -for i in `seq 1 10`; +for i in `seq 1 30`; do - result="$(echo '\q' | mysql -h 127.0.0.1 -uroot -P 3306 2>&1 > /dev/null)" - if [ "$result" = "" ]; then - echo "Success waiting for MySQL" - exit 0 - fi + echo '\q' | mysql -h 127.0.0.1 -uroot --password=root -P 3306 && exit 0 >&2 echo "MySQL is waking up" - sleep 10 + sleep 1 done echo "Failed waiting for MySQL" && exit 1 diff --git a/README.md b/README.md index 8b2b8f6..38f9802 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ await client.useConnection(async (conn) => { const { iterator: users } = await conn.execute( `select * from users`, /* params: */ [], - /* iterator: */ false, + /* iterator: */ true, ); for await (const user of users) { console.log(user); diff --git a/deps.ts b/deps.ts index 745928d..93a948b 100644 --- a/deps.ts +++ b/deps.ts @@ -2,6 +2,7 @@ export type { Deferred } from "https://deno.land/std@0.104.0/async/mod.ts"; export { deferred, delay } from "https://deno.land/std@0.104.0/async/mod.ts"; export { format as byteFormat } from "https://deno.land/x/bytes_formater@v1.4.0/mod.ts"; export { createHash } from "https://deno.land/std@0.104.0/hash/mod.ts"; +export { decode as base64Decode } from "https://deno.land/std@0.104.0/encoding/base64.ts"; export type { SupportedAlgorithm, } from "https://deno.land/std@0.104.0/hash/mod.ts"; diff --git a/src/auth.ts b/src/auth.ts index 0342e1d..deafa1d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -32,7 +32,8 @@ export default function auth( ) { switch (authPluginName) { case "mysql_native_password": - return mysqlNativePassword(password, seed); + // Native password authentication only need and will need 20-byte challenge. + return mysqlNativePassword(password, seed.slice(0, 20)); case "caching_sha2_password": return cachingSha2Password(password, seed); diff --git a/src/auth_plugin/caching_sha2_password.ts b/src/auth_plugin/caching_sha2_password.ts index c3ed0df..1e8cbbe 100644 --- a/src/auth_plugin/caching_sha2_password.ts +++ b/src/auth_plugin/caching_sha2_password.ts @@ -10,12 +10,17 @@ interface handler { } let scramble: Uint8Array, password: string; -function start(scramble_: Uint8Array, password_: string): handler { + +async function start( + scramble_: Uint8Array, + password_: string, +): Promise { scramble = scramble_; password = password_; return { done: false, next: authMoreResponse }; } -function authMoreResponse(packet: ReceivePacket): handler { + +async function authMoreResponse(packet: ReceivePacket): Promise { const enum AuthStatusFlags { FullAuth = 0x04, FastPath = 0x03, @@ -36,30 +41,34 @@ function authMoreResponse(packet: ReceivePacket): handler { return { done, next, quickRead, data: authMoreData }; } -function encryptWithKey(packet: ReceivePacket): handler { +async function encryptWithKey(packet: ReceivePacket): Promise { const publicKey = parsePublicKey(packet); const len = password.length; - let passwordBuffer: Uint8Array = new Uint8Array(len + 1); + const passwordBuffer: Uint8Array = new Uint8Array(len + 1); for (let n = 0; n < len; n++) { passwordBuffer[n] = password.charCodeAt(n); } passwordBuffer[len] = 0x00; - const encryptedPassword = encrypt(passwordBuffer, scramble, publicKey); - return { done: false, next: terminate, data: encryptedPassword }; + const encryptedPassword = await encrypt(passwordBuffer, scramble, publicKey); + return { + done: false, + next: terminate, + data: new Uint8Array(encryptedPassword), + }; } function parsePublicKey(packet: ReceivePacket): string { return packet.body.skip(1).readNullTerminatedString(); } -function encrypt( + +async function encrypt( password: Uint8Array, scramble: Uint8Array, key: string, -): Uint8Array { +): Promise { const stage1 = xor(password, scramble); - const encrypted = encryptWithPublicKey(key, stage1); - return encrypted; + return await encryptWithPublicKey(key, stage1); } function terminate() { diff --git a/src/auth_plugin/crypt.ts b/src/auth_plugin/crypt.ts index 6e12394..8eb2339 100644 --- a/src/auth_plugin/crypt.ts +++ b/src/auth_plugin/crypt.ts @@ -1,7 +1,28 @@ -import { RSA } from "https://deno.land/x/god_crypto@v0.2.0/mod.ts"; -function encryptWithPublicKey(key: string, data: Uint8Array): Uint8Array { - const publicKey = RSA.parseKey(key); - return RSA.encrypt(data, publicKey); +import { base64Decode } from "../../deps.ts"; + +async function encryptWithPublicKey( + key: string, + data: Uint8Array, +): Promise { + const pemHeader = "-----BEGIN PUBLIC KEY-----\n"; + const pemFooter = "\n-----END PUBLIC KEY-----"; + key = key.trim(); + key = key.substring(pemHeader.length, key.length - pemFooter.length); + const importedKey = await crypto.subtle.importKey( + "spki", + base64Decode(key), + { name: "RSA-OAEP", hash: "SHA-256" }, + false, + ["encrypt"], + ); + + return await crypto.subtle.encrypt( + { + name: "RSA-OAEP", + }, + importedKey, + data, + ); } export { encryptWithPublicKey }; diff --git a/src/buffer.ts b/src/buffer.ts index f978155..5c3e48b 100644 --- a/src/buffer.ts +++ b/src/buffer.ts @@ -63,6 +63,10 @@ export class BufferReader { return decode(buf); } + readRestOfPacketString(): Uint8Array { + return this.buffer.slice(this.pos); + } + readString(len: number): string { const str = decode(this.buffer.slice(this.pos, this.pos + len)); this.pos += len; diff --git a/src/connection.ts b/src/connection.ts index 7a229a4..d266dc6 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -19,6 +19,8 @@ import { import { FieldInfo, parseField, parseRow } from "./packets/parsers/result.ts"; import { PacketType } from "./constant/packet.ts"; import authPlugin from "./auth_plugin/index.ts"; +import { parseAuthSwitch } from "./packets/parsers/authswitch.ts"; +import auth from "./auth.ts"; import ServerCapabilities from "./constant/capabilities.ts"; /** @@ -117,13 +119,41 @@ export class Connection { handler = adaptedPlugin; break; case AuthResult.MethodMismatch: - // TODO: Negotiate - throw new Error("Currently cannot support auth method mismatch!"); + const authSwitch = parseAuthSwitch(receive.body); + // If CLIENT_PLUGIN_AUTH capability is not supported, no new cipher is + // sent and we have to keep using the cipher sent in the init packet. + if ( + authSwitch.authPluginData === undefined || + authSwitch.authPluginData.length === 0 + ) { + authSwitch.authPluginData = handshakePacket.seed; + } + + let authData; + if (password) { + authData = auth( + authSwitch.authPluginName, + password, + authSwitch.authPluginData, + ); + } else { + authData = Uint8Array.from([]); + } + + await new SendPacket(authData, receive.header.no + 1).send(this.conn); + + receive = await this.nextPacket(); + const authSwitch2 = parseAuthSwitch(receive.body); + if (authSwitch2.authPluginName !== "") { + throw new Error( + "Do not allow to change the auth plugin more than once!", + ); + } } let result; if (handler) { - result = handler.start(handshakePacket.seed, password!); + result = await handler.start(handshakePacket.seed, password!); while (!result.done) { if (result.data) { const sequenceNumber = receive.header.no + 1; @@ -134,7 +164,7 @@ export class Connection { await this.nextPacket(); } if (result.next) { - result = result.next(receive); + result = await result.next(receive); } } } @@ -210,34 +240,6 @@ export class Connection { this.close(); }; - /** - * Check if database server version is less than 5.7.0 - * - * MySQL version is "x.y.z" - * eg "5.5.62" - * - * MariaDB version is "5.5.5-x.y.z-MariaDB[-build-infos]" for versions after 5 (10.0 etc) - * eg "5.5.5-10.4.10-MariaDB-1:10.4.10+maria~bionic" - * and "x.y.z-MariaDB-[build-infos]" for 5.x versions - * eg "5.5.64-MariaDB-1~trusty" - */ - private lessThan5_7(): Boolean { - const version = this.serverVersion; - if (!version.includes("MariaDB")) return version < "5.7.0"; - const segments = version.split("-"); - // MariaDB v5.x - if (segments[1] === "MariaDB") return segments[0] < "5.7.0"; - // MariaDB v10+ - return false; - } - - /** Check if the MariaDB version is 10.0 or 10.1 */ - private isMariaDBAndVersion10_0Or10_1(): Boolean { - const version = this.serverVersion; - if (!version.includes("MariaDB")) return false; - return version.includes("5.5.5-10.1") || version.includes("5.5.5-10.0"); - } - /** Close database connection */ close(): void { if (this.state != ConnectionState.CLOSED) { @@ -303,8 +305,8 @@ export class Connection { } const rows = []; - if (this.lessThan5_7() || this.isMariaDBAndVersion10_0Or10_1()) { - // EOF(less than 5.7 or mariadb version is 10.0 or 10.1) + if (!(this.capabilities & ServerCapabilities.CLIENT_DEPRECATE_EOF)) { + // EOF(mysql < 5.7 or mariadb < 10.2) receive = await this.nextPacket(); if (receive.type !== PacketType.EOF_Packet) { throw new ProtocolError(); @@ -314,7 +316,11 @@ export class Connection { if (!iterator) { while (true) { receive = await this.nextPacket(); - if (receive.type === PacketType.EOF_Packet) { + // OK_Packet when CLIENT_DEPRECATE_EOF is set. OK_Packet can be 0xfe or 0x00 + if ( + receive.type === PacketType.EOF_Packet || + receive.type === PacketType.OK_Packet + ) { break; } else { const row = parseRow(receive.body, fields); diff --git a/src/packets/parsers/authswitch.ts b/src/packets/parsers/authswitch.ts new file mode 100644 index 0000000..ac8b728 --- /dev/null +++ b/src/packets/parsers/authswitch.ts @@ -0,0 +1,21 @@ +import { BufferReader } from "../../buffer.ts"; + +/** @ignore */ +export interface authSwitchBody { + status: number; + authPluginName: string; + authPluginData: Uint8Array; +} + +/** @ignore */ +export function parseAuthSwitch(reader: BufferReader): authSwitchBody { + const status = reader.readUint8(); + const authPluginName = reader.readNullTerminatedString(); + const authPluginData = reader.readRestOfPacketString(); + + return { + status, + authPluginName, + authPluginData, + }; +} diff --git a/src/pool.ts b/src/pool.ts index 8a0bdcf..f1de757 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -24,6 +24,12 @@ export class PoolConnection extends Connection { log.warning(`error closing idle connection`, error); } }, this.config.idleTimeout); + try { + // Don't block the event loop from finishing + Deno.unrefTimer(this._idleTimer); + } catch (_error) { + // unrefTimer() is unstable API in older version of Deno + } } } diff --git a/test.ts b/test.ts index 71a7189..a41ccb6 100644 --- a/test.ts +++ b/test.ts @@ -306,6 +306,37 @@ testWithClient(async function testExecuteIterator(client) { }); }); +// For MySQL 8, the default auth plugin is `caching_sha2_password`. Create user +// using `mysql_native_password` to test Authentication Method Mismatch. +testWithClient(async function testCreateUserWithMysqlNativePassword(client) { + const { version } = (await client.query(`SELECT VERSION() as version`))[0]; + if (version.startsWith("8.")) { + // MySQL 8 does not have `PASSWORD()` function + await client.execute( + `CREATE USER 'testuser'@'%' IDENTIFIED WITH mysql_native_password BY 'testpassword'`, + ); + } else { + await client.execute( + `CREATE USER 'testuser'@'%' IDENTIFIED WITH mysql_native_password`, + ); + await client.execute( + `SET PASSWORD FOR 'testuser'@'%' = PASSWORD('testpassword')`, + ); + } + await client.execute(`GRANT ALL ON test.* TO 'testuser'@'%'`); +}); + +testWithClient(async function testConnectWithMysqlNativePassword(client) { + assertEquals( + await client.query(`SELECT CURRENT_USER() AS user`), + [{ user: "testuser@%" }], + ); +}, { username: "testuser", password: "testpassword" }); + +testWithClient(async function testDropUserWithMysqlNativePassword(client) { + await client.execute(`DROP USER 'testuser'@'%'`); +}); + registerTests(); Deno.test("configLogger()", async () => { diff --git a/test.util.ts b/test.util.ts index ef737c6..985fb7f 100644 --- a/test.util.ts +++ b/test.util.ts @@ -5,10 +5,13 @@ const { DB_PORT, DB_NAME, DB_PASSWORD, DB_USER, DB_HOST, DB_SOCKPATH } = Deno .env.toObject(); const port = DB_PORT ? parseInt(DB_PORT) : 3306; const db = DB_NAME || "test"; -const password = DB_PASSWORD; +const password = DB_PASSWORD || "root"; const username = DB_USER || "root"; const hostname = DB_HOST || "127.0.0.1"; const sockPath = DB_SOCKPATH || "/var/run/mysqld/mysqld.sock"; +const testMethods = + Deno.env.get("TEST_METHODS")?.split(",") as ("tcp" | "unix")[] || ["tcp"]; +const unixSocketOnly = testMethods.length === 1 && testMethods[0] === "unix"; const config: ClientConfig = { timeout: 10000, @@ -31,10 +34,7 @@ export function testWithClient( tests.push([fn, overrideConfig]); } -export function registerTests(methods?: ("tcp" | "unix")[]) { - if (!methods) { - methods = Deno.env.get("TEST_METHODS")?.split(",") as any || ["tcp"]; - } +export function registerTests(methods: ("tcp" | "unix")[] = testMethods) { if (methods!.includes("tcp")) { tests.forEach(([fn, overrideConfig]) => { Deno.test({ @@ -83,6 +83,7 @@ export async function createTestDB() { ...config, poolSize: 1, db: undefined, + socketPath: unixSocketOnly ? sockPath : undefined, }); await client.execute(`CREATE DATABASE IF NOT EXISTS ${db}`); await client.close();