Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/sql/mysql/protocol/Auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions src/sql_jsc/mysql/MySQLConnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
157 changes: 157 additions & 0 deletions test/js/sql/sql-mysql.auth.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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<Buffer>();
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();
}
});
78 changes: 70 additions & 8 deletions test/js/sql/wire-frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,39 +217,53 @@ 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)
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)
Buffer.from([0x02, 0x00]), // status_flags (SERVER_STATUS_AUTOCOMMIT)
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<EOF>(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<EOF>(plugin_provided_data)
export function mysqlAuthSwitchRequest(seq: number, pluginName: string, pluginData: Buffer): Buffer {
Expand All @@ -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<lenenc> (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)
Expand Down Expand Up @@ -315,6 +355,28 @@ export function mysqlColumnDefinition(
);
}

// MySQL text-protocol resultset row — page_protocol_com_query_response_text_resultset_row.html:
// one string<lenenc> 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 {
Expand Down
Loading