diff --git a/src/sql/mysql/protocol/Auth.rs b/src/sql/mysql/protocol/Auth.rs index 113debd11bc7..db899fb14b9c 100644 --- a/src/sql/mysql/protocol/Auth.rs +++ b/src/sql/mysql/protocol/Auth.rs @@ -82,10 +82,12 @@ pub mod caching_sha2_password { // SAFETY: engine is null (default). unsafe { SHA256::hash(&digest1, &mut digest2, core::ptr::null_mut()) }; - // SHA256(SHA256(SHA256(password)) + nonce) - let mut combined = vec![0u8; nonce.len() + digest2.len()]; - combined[0..nonce.len()].copy_from_slice(nonce); - combined[nonce.len()..].copy_from_slice(&digest2); + // SHA256(SHA256(SHA256(password)) + nonce): the double hash comes FIRST. + // mysql_native_password concatenates the other way around; the server's + // Generate_scramble (sha2_password_common.cc) updates digest_stage2 then m_rnd. + let mut combined = vec![0u8; digest2.len() + nonce.len()]; + combined[0..digest2.len()].copy_from_slice(&digest2); + combined[digest2.len()..].copy_from_slice(nonce); // SAFETY: engine is null (default). unsafe { SHA256::hash(&combined, &mut digest3, core::ptr::null_mut()) }; // `defer bun.default_allocator.free(combined)` → Vec drops at scope exit diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 8bba3068c9fd..c343c96a05c1 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -816,12 +816,10 @@ impl MySQLConnection { match response.status { Auth::caching_sha2_password::FastAuthStatus::SUCCESS => { - debug!("success auth"); - self.set_status(ConnectionState::Connected); - - self.flags.insert(ConnectionFlags::IS_READY_FOR_QUERY); - self.queue.mark_as_ready_for_query(); - self.advance(); + // fast_auth_success only acknowledges the cached scramble; the + // server always follows it with the OK/ERR packet that concludes + // auth, so stay in Authenticating and let the arms above consume it. + debug!("fast auth success, awaiting OK"); } Auth::caching_sha2_password::FastAuthStatus::CONTINUE_AUTH => { bun_core::scoped_log!(MySQLConnection, "continue auth"); diff --git a/test/js/sql/sql-mysql.auth.test.ts b/test/js/sql/sql-mysql.auth.test.ts index 0eed6f6e80f2..097e265a40b9 100644 --- a/test/js/sql/sql-mysql.auth.test.ts +++ b/test/js/sql/sql-mysql.auth.test.ts @@ -1,6 +1,19 @@ import { SQL } from "bun"; import { expect, test } from "bun:test"; import { describeWithContainer } from "harness"; +import { createHash } from "node:crypto"; +import { + listeningServer, + MYSQL_FAST_AUTH_SUCCESS, + MYSQL_MOCK_AUTH_DATA_PART_1, + MYSQL_MOCK_AUTH_DATA_PART_2, + mysqlAuthMoreData, + mysqlHandshakeV10, + mysqlOkPacket, + mysqlParseHandshakeResponse41, + mysqlReadPackets, + mysqlTextResultSet, +} from "./wire-frames"; describeWithContainer( "mysql", @@ -59,5 +72,149 @@ describeWithContainer( expect(result).toEqual([{ x: 1 }]); await sql.end(); }); + + // A passworded caching_sha2_password user's second and later connections take the + // fast path (AuthMoreData 0x03 then the concluding OK) once the server accepts the + // client's scramble; any prior successful full auth is what warms the server cache. + test("caching_sha2_password fast auth (warm server-side auth cache)", async () => { + { + await using admin = new SQL({ url: getUrl(), max: 1 }); + await admin`DROP USER IF EXISTS fastauth@'%';`.simple(); + await admin`CREATE USER fastauth@'%' IDENTIFIED WITH caching_sha2_password BY 'bunbun'; + GRANT ALL PRIVILEGES ON bun_sql_test.* TO fastauth@'%';`.simple(); + } + const userUrl = `mysql://fastauth:bunbun@${container.host}:${container.port}/bun_sql_test`; + + // Connection #1: cold cache -> full authentication (RSA public-key + // exchange). Its success is what warms the auth cache for `fastauth`. + { + await using cold = new SQL({ url: userUrl, max: 1, allowPublicKeyRetrieval: true }); + expect(await cold`select 1 as x`).toEqual([{ x: 1 }]); + } + + // Connection #2: warm cache -> the server should take the fast path. Until the + // 21-byte-nonce bug (#26195 / #28161) also lands, it degrades to full auth via + // allowPublicKeyRetrieval. The scripted tests below carry the fast-auth proof. + await using fast = new SQL({ url: userUrl, max: 1, allowPublicKeyRetrieval: true }); + expect(await fast`select 'REAL-ROW' as v`).toEqual([{ v: "REAL-ROW" }]); + }); }, ); + +// The caching_sha2_password "Fast path succeeds" exchange, byte-scripted so the scramble +// bytes can be read back off the wire and both TCP framings of AuthMoreData(0x03) + OK forced: +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html + +const COM_QUERY = 0x03; +const MYSQL_TYPE_VAR_STRING = 0xfd; + +test.each(["split", "coalesced"] as const)( + "caching_sha2_password fast-auth success: the trailing OK belongs to auth, not the first query (%s framing)", + async framing => { + const commands: number[] = []; + const { server, port } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let authed = false; + socket.write(mysqlHandshakeV10({ authPlugin: "caching_sha2_password" })); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!authed) { + // HandshakeResponse41 -> warm auth cache: fast_auth_success then OK. + authed = true; + const fastAuthSuccess = mysqlAuthMoreData(seq + 1, Buffer.from([MYSQL_FAST_AUTH_SUCCESS])); + const authOk = mysqlOkPacket(seq + 2); + if (framing === "coalesced") { + socket.write(Buffer.concat([fastAuthSuccess, authOk])); + } else { + socket.write(fastAuthSuccess); + setImmediate(() => socket.write(authOk)); + } + return; + } + commands.push(payload[0]); + if (payload[0] === COM_QUERY) { + socket.write(mysqlTextResultSet(1, [{ name: "v", type: MYSQL_TYPE_VAR_STRING }], [["REAL-ROW"]])); + } else { + // COM_QUIT from `await using sql` below: a real server just closes. + socket.end(); + } + }); + }); + socket.on("error", () => {}); + }); + + try { + await using sql = new SQL({ url: `mysql://root:pw@127.0.0.1:${port}/db`, max: 1 }); + // .simple() -> COM_QUERY / text protocol, which is exactly the result set + // the scripted server answers with. Settle to a value so a rejection shows + // up in the toEqual diff below instead of failing the test opaquely. + const result = await sql`SELECT 'REAL-ROW' AS v`.simple().then( + rows => ({ rows }), + (e: { code?: string }) => ({ code: e?.code ?? String(e) }), + ); + // `commands` proves the client only sends COM_QUERY once authentication + // has actually completed. + expect({ result, commands }).toEqual({ + result: { rows: [{ v: "REAL-ROW" }] }, + commands: [COM_QUERY], + }); + } finally { + server.close(); + } + }, +); + +// The scramble is XOR(SHA256(pw), SHA256(SHA256(SHA256(pw)) || nonce)) with the double +// hash hashed FIRST: MySQL's Generate_scramble, mysql2, go-sql-driver, and Connector/J all +// agree. mysql_native_password concatenates the other way around, which is NOT correct here. +test("caching_sha2_password scramble hashes the double-SHA256 before the nonce", async () => { + const password = "pw"; + const scrambleSent = Promise.withResolvers(); + const { server, port } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let authed = false; + socket.write(mysqlHandshakeV10({ authPlugin: "caching_sha2_password" })); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!authed) { + authed = true; + try { + scrambleSent.resolve(mysqlParseHandshakeResponse41(payload).authResponse); + } catch (e) { + scrambleSent.reject(e); + } + // Accept the auth so the query below completes and `await using sql` can + // tear down over the normal COM_QUIT path; the scramble is the subject. + socket.write(mysqlOkPacket(seq + 1)); + } else if (payload[0] === COM_QUERY) { + socket.write(mysqlTextResultSet(1, [{ name: "v", type: MYSQL_TYPE_VAR_STRING }], [["REAL-ROW"]])); + } else { + socket.end(); + } + }); + }); + socket.on("error", () => {}); + }); + + try { + await using sql = new SQL({ url: `mysql://root:${password}@127.0.0.1:${port}/db`, max: 1 }); + const [sent, rows] = await Promise.all([scrambleSent.promise, sql`SELECT 'REAL-ROW' AS v`.simple()]); + + const sha256 = (b: Buffer) => createHash("sha256").update(b).digest(); + const digest1 = sha256(Buffer.from(password)); + const digest2 = sha256(digest1); + const expected = (nonce: Buffer) => { + const digest3 = sha256(Buffer.concat([digest2, nonce])); + return Buffer.from(digest1.map((byte, i) => byte ^ digest3[i])).toString("hex"); + }; + // The spec nonce is 20 bytes (part1 + the first 12 bytes of part2). Bun currently + // also keeps part2's trailing filler byte (#26195, fixed separately in #28161), so + // accept either nonce length: this test pins only the concatenation order. + const nonce20 = Buffer.concat([MYSQL_MOCK_AUTH_DATA_PART_1, MYSQL_MOCK_AUTH_DATA_PART_2.subarray(0, 12)]); + const nonce21 = Buffer.concat([MYSQL_MOCK_AUTH_DATA_PART_1, MYSQL_MOCK_AUTH_DATA_PART_2]); + expect([expected(nonce20), expected(nonce21)]).toContain(sent.toString("hex")); + expect(rows).toEqual([{ v: "REAL-ROW" }]); + } finally { + server.close(); + } +}); diff --git a/test/js/sql/wire-frames.ts b/test/js/sql/wire-frames.ts index 221241102030..26184076b202 100644 --- a/test/js/sql/wire-frames.ts +++ b/test/js/sql/wire-frames.ts @@ -217,6 +217,12 @@ export function mysqlRawPacket(seq: number, payload: Buffer): Buffer { return Buffer.concat([header, payload]); } +// The auth-plugin-data (scramble seed) mysqlHandshakeV10 advertises. The 20-byte +// nonce every auth plugin scrambles against is PART_1 + the first 12 bytes of +// PART_2; PART_2's 13th byte is the protocol's trailing NUL filler, not nonce data. +export const MYSQL_MOCK_AUTH_DATA_PART_1: Buffer = Buffer.alloc(8, 0x61); +export const MYSQL_MOCK_AUTH_DATA_PART_2: Buffer = Buffer.concat([Buffer.alloc(12, 0x62), Buffer.from([0])]); + // MySQL Protocol::HandshakeV10 — page_protocol_connection_phase_packets_protocol_handshake_v10.html // Int<1>(10) NulString(server_version) Int<4>(thread_id) String<8>(auth1) Int<1>(0) Int<2>(cap_lo) // Int<1>(charset) Int<2>(status) Int<2>(cap_hi) Int<1>(auth_len) String<10>(reserved) String<13>(auth2) NulString(plugin) @@ -224,14 +230,11 @@ export function mysqlHandshakeV10( opts: { authPlugin?: string; capabilities?: number; serverVersion?: string } = {}, ): Buffer { const caps = opts.capabilities ?? MYSQL_DEFAULT_CAPABILITIES; - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; const payload = Buffer.concat([ Buffer.from([10]), Buffer.from(`${opts.serverVersion ?? "mock-5.7.0"}\0`), Buffer.from([1, 0, 0, 0]), // thread_id - authData1, + MYSQL_MOCK_AUTH_DATA_PART_1, Buffer.from([0]), // filler Buffer.from([caps & 0xff, (caps >> 8) & 0xff]), // capability_flags_1 Buffer.from([0x2d]), // character_set (utf8mb4_general_ci) @@ -239,17 +242,28 @@ export function mysqlHandshakeV10( Buffer.from([(caps >> 16) & 0xff, (caps >>> 24) & 0xff]), // capability_flags_2 Buffer.from([21]), // auth_plugin_data_len Buffer.alloc(10, 0), // reserved - authData2, + MYSQL_MOCK_AUTH_DATA_PART_2, Buffer.from(`${opts.authPlugin ?? "mysql_native_password"}\0`), ]); return mysqlRawPacket(0, payload); } -// MySQL Protocol::OK_Packet — page_protocol_basic_ok_packet.html: Int<1>(0x00) lenenc(affected_rows) lenenc(last_insert_id) Int<2>(status) Int<2>(warnings) -export function mysqlOkPacket(seq: number): Buffer { - return mysqlRawPacket(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); +// MySQL Protocol::OK_Packet — page_protocol_basic_ok_packet.html: Int<1>(header) lenenc(affected_rows) lenenc(last_insert_id) Int<2>(status) Int<2>(warnings) +// The header is 0x00, except for the CLIENT_DEPRECATE_EOF result-set terminator, which is an OK packet with a 0xFE header. +export function mysqlOkPacket(seq: number, header: 0x00 | 0xfe = 0x00): Buffer { + return mysqlRawPacket(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); +} + +// MySQL Protocol::AuthMoreData — page_protocol_connection_phase_packets_protocol_auth_more_data.html: +// Int<1>(0x01) String(plugin-specific payload) +export function mysqlAuthMoreData(seq: number, data: Buffer): Buffer { + return mysqlRawPacket(seq, Buffer.concat([Buffer.from([0x01]), data])); } +// caching_sha2_password fast_auth_success marker carried in an AuthMoreData payload — +// page_caching_sha2_authentication_exchanges.html (its sibling, 0x04, is perform_full_authentication). +export const MYSQL_FAST_AUTH_SUCCESS = 0x03; + // MySQL Protocol::AuthSwitchRequest — page_protocol_connection_phase_packets_protocol_auth_switch_request.html: // Int<1>(0xfe) NulString(plugin_name) String(plugin_provided_data) export function mysqlAuthSwitchRequest(seq: number, pluginName: string, pluginData: Buffer): Buffer { @@ -275,6 +289,32 @@ export function mysqlLenencStr(s: string | Buffer): Buffer { return Buffer.concat([mysqlLenencInt(buf.length), buf]); } +// Decode a length-encoded integer at `offset` (inverse of mysqlLenencInt); returns the value and the encoded width. +export function mysqlReadLenencInt(buf: Buffer, offset: number): { value: number; width: number } { + const first = buf[offset]; + if (first < 0xfb) return { value: first, width: 1 }; + if (first === 0xfc) return { value: buf.readUInt16LE(offset + 1), width: 3 }; + if (first === 0xfd) return { value: buf.readUIntLE(offset + 1, 3), width: 4 }; + if (first === 0xfe) return { value: Number(buf.readBigUInt64LE(offset + 1)), width: 9 }; + throw new Error(`0x${first.toString(16)} is not a lenenc-int prefix`); +} + +// MySQL Protocol::HandshakeResponse41 — page_protocol_connection_phase_packets_protocol_handshake_response.html: +// Int<4>(client_flag) Int<4>(max_packet) Int<1>(charset) String<23>(filler) NulString(username) +// then the auth_response as a string (CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) or Int<1>-prefixed string. +export function mysqlParseHandshakeResponse41(payload: Buffer): { username: string; authResponse: Buffer } { + let offset = 4 + 4 + 1 + 23; + const usernameEnd = payload.indexOf(0, offset); + if (usernameEnd < 0) throw new Error("HandshakeResponse41: unterminated username"); + const username = payload.subarray(offset, usernameEnd).toString("utf-8"); + offset = usernameEnd + 1; + // MYSQL_DEFAULT_CAPABILITIES always includes CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA, and for the + // <=250-byte responses every plugin produces, the lenenc and Int<1>-length encodings are identical. + const { value: authLen, width } = mysqlReadLenencInt(payload, offset); + offset += width; + return { username, authResponse: payload.subarray(offset, offset + authLen) }; +} + // MySQL Protocol::ColumnDefinition41 — page_protocol_com_query_response_text_resultset_column_definition.html: // lenenc("def") lenenc(schema) lenenc(table) lenenc(org_table) lenenc(name) lenenc(org_name) // lenenc(0x0c) Int<2>(charset) Int<4>(column_length) Int<1>(type) Int<2>(flags) Int<1>(decimals) Int<2>(0x0000) @@ -315,6 +355,28 @@ export function mysqlColumnDefinition( ); } +// MySQL text-protocol resultset row — page_protocol_com_query_response_text_resultset_row.html: +// one string per column. (SQL NULL is the single byte 0xfb; not needed by any mock yet.) +export function mysqlTextResultSetRow(seq: number, cols: (string | Buffer)[]): Buffer { + return mysqlRawPacket(seq, Buffer.concat(cols.map(c => mysqlLenencStr(c)))); +} + +// MySQL Textual Resultset — page_protocol_com_query_response_text_resultset.html, in the +// CLIENT_DEPRECATE_EOF framing: lenenc(column_count) packet, one ColumnDefinition41 per +// column, one row packet per row, then an OK packet with the 0xFE header as the terminator. +export function mysqlTextResultSet( + startSeq: number, + columns: { name: string; type: number }[], + rows: string[][], +): Buffer { + let seq = startSeq; + const parts: Buffer[] = [mysqlRawPacket(seq++, mysqlLenencInt(columns.length))]; + for (const column of columns) parts.push(mysqlColumnDefinition(seq++, column)); + for (const row of rows) parts.push(mysqlTextResultSetRow(seq++, row)); + parts.push(mysqlOkPacket(seq, 0xfe)); + return Buffer.concat(parts); +} + // MySQL COM_STMT_PREPARE_OK — page_protocol_com_stmt_prepare.html#sect_protocol_com_stmt_prepare_response_ok: // Int<1>(0x00) Int<4>(statement_id) Int<2>(num_columns) Int<2>(num_params) Int<1>(0x00) Int<2>(warning_count) export function mysqlStmtPrepareOk(seq: number, stmtId: number, numColumns: number, numParams: number): Buffer {