Skip to content

Commit f39470a

Browse files
authored
Connect to MySQL 8 binlog users without requiring TLS (#127)
1 parent b41e146 commit f39470a

5 files changed

Lines changed: 149 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/binlog-protocol/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ byteorder = "1.5"
1717
sha1 = "0.10"
1818
sha2 = "0.10"
1919
rsa = { version = "0.9", features = ["sha2"] }
20+
rand_core = { version = "0.6", features = ["getrandom"] }
2021

2122
hex = "0.4"
2223
zstd = "0.13.3"

crates/binlog-protocol/src/shared/wire.rs

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ use tokio_rustls::TlsConnector;
99

1010
use crate::error::Error;
1111
use crate::options::{SslMode, SslOptions};
12+
use rsa::pkcs8::DecodePublicKey;
1213

1314
const UTF8_MB4_GENERAL_CI: u16 = 0x002d;
15+
/// MySQL auth scramble / nonce length (trailing NUL from the handshake is trimmed).
16+
const SCRAMBLE_LENGTH: usize = 20;
1417
const CLIENT_PROTOCOL_41: u32 = 512;
1518
const CLIENT_SSL: u32 = 2048;
1619
const CLIENT_SECURE_CONNECTION: u32 = 32768;
@@ -191,6 +194,7 @@ pub async fn authenticate(
191194
) -> Result<(), Error> {
192195
negotiate_tls(channel, handshake, host, ssl).await?;
193196

197+
let mut scramble = handshake.scramble.clone();
194198
let auth = build_auth_packet(handshake, username, password, channel.tls_active())?;
195199
channel.write_packet(&auth).await?;
196200
let mut packet = channel.read_packet().await?;
@@ -216,15 +220,16 @@ pub async fn authenticate(
216220
return Ok(());
217221
}
218222
Some(0x04) => {
219-
if !channel.tls_active() {
220-
return Err(Error::Auth(
221-
"caching_sha2_password full authentication requires TLS; use --tls-mode preferred or required"
222-
.into(),
223-
));
223+
if channel.tls_active() {
224+
let mut response = password.as_bytes().to_vec();
225+
response.push(0);
226+
channel.write_packet(&response).await?;
227+
} else {
228+
// Non-TLS full auth: RSA-encrypt the password with the
229+
// server's public key (caching_sha2_password).
230+
perform_caching_sha2_rsa_full_auth(channel, password, &scramble)
231+
.await?;
224232
}
225-
let mut response = password.as_bytes().to_vec();
226-
response.push(0);
227-
channel.write_packet(&response).await?;
228233
packet = channel.read_packet().await?;
229234
}
230235
other => {
@@ -243,7 +248,8 @@ pub async fn authenticate(
243248
.find_map(|(i, &b)| if b == 0 { Some(i) } else { None })
244249
.unwrap_or(0);
245250
let plugin_data = &packet[1 + plugin.len() + 1..1 + plugin.len() + 1 + data_end];
246-
let response = auth_switch_response(&plugin, password, plugin_data)?;
251+
scramble = normalize_scramble(plugin_data);
252+
let response = auth_switch_response(&plugin, password, &scramble)?;
247253
channel.write_packet(&response).await?;
248254
packet = channel.read_packet().await?;
249255
}
@@ -256,6 +262,50 @@ pub async fn authenticate(
256262
}
257263
}
258264

265+
/// Request the server RSA public key and send an RSA-OAEP encrypted password
266+
/// for `caching_sha2_password` full authentication over a plaintext connection.
267+
async fn perform_caching_sha2_rsa_full_auth(
268+
channel: &mut PacketChannel,
269+
password: &str,
270+
scramble: &[u8],
271+
) -> Result<(), Error> {
272+
// 0x02 = PublicKeyRequest
273+
channel.write_packet(&[0x02]).await?;
274+
let key_pkt = channel.read_packet().await?;
275+
check_error_packet(&key_pkt, "failed to get RSA public key")?;
276+
if key_pkt.first() != Some(&0x01) {
277+
return Err(Error::Auth(format!(
278+
"unexpected RSA key response prefix: {:02x}",
279+
key_pkt.first().copied().unwrap_or(0)
280+
)));
281+
}
282+
// AuthMoreData: [0x01, PEM..., optional 0x00]
283+
let pem_bytes = key_pkt[1..].strip_suffix(&[0x00]).unwrap_or(&key_pkt[1..]);
284+
let pub_key_pem = std::str::from_utf8(pem_bytes)
285+
.map_err(|e| Error::Auth(format!("invalid UTF-8 in RSA public key: {e}")))?;
286+
let pub_key = rsa::RsaPublicKey::from_public_key_pem(pub_key_pem)
287+
.map_err(|e| Error::Auth(format!("failed to parse RSA public key: {e}")))?;
288+
289+
// XOR null-terminated password with the 20-byte scramble (repeating).
290+
let mut plain = password.as_bytes().to_vec();
291+
plain.push(0);
292+
if scramble.is_empty() {
293+
return Err(Error::Auth(
294+
"caching_sha2_password RSA full auth requires a non-empty scramble".into(),
295+
));
296+
}
297+
for (i, byte) in plain.iter_mut().enumerate() {
298+
*byte ^= scramble[i % scramble.len()];
299+
}
300+
301+
// MySQL uses RSAES-OAEP with SHA-1 (MySQL 8.0.5+).
302+
let padding = rsa::Oaep::new::<sha1::Sha1>();
303+
let encrypted = pub_key
304+
.encrypt(&mut rand_core::OsRng, padding, &plain)
305+
.map_err(|e| Error::Auth(format!("RSA encryption failed: {e}")))?;
306+
channel.write_packet(&encrypted).await
307+
}
308+
259309
async fn negotiate_tls(
260310
channel: &mut PacketChannel,
261311
handshake: &Handshake,
@@ -509,6 +559,7 @@ fn parse_handshake(packet: &[u8]) -> Result<Handshake, Error> {
509559

510560
let mut scramble = scramble_part1.to_vec();
511561
scramble.extend_from_slice(scramble_part2);
562+
scramble = normalize_scramble(&scramble);
512563

513564
let auth_plugin = if capabilities & CLIENT_PLUGIN_AUTH != 0 {
514565
read_null_string(&packet[pos..])
@@ -524,6 +575,14 @@ fn parse_handshake(packet: &[u8]) -> Result<Handshake, Error> {
524575
})
525576
}
526577

578+
/// Pad or truncate to the canonical 20-byte MySQL scramble (trailing handshake NUL
579+
/// is dropped when the concatenated length is 21, matching mysql_async).
580+
fn normalize_scramble(raw: &[u8]) -> Vec<u8> {
581+
let mut scramble = raw.to_vec();
582+
scramble.resize(SCRAMBLE_LENGTH, 0);
583+
scramble
584+
}
585+
527586
fn read_null_string(data: &[u8]) -> String {
528587
let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
529588
String::from_utf8_lossy(&data[..end]).into_owned()

crates/mysql-binlog-source/tests/suite/integration.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,69 @@ async fn repl_user_can_stream_changes() -> Result<()> {
657657
Ok(())
658658
}
659659

660+
/// MySQL 8 `caching_sha2_password` full auth over plaintext (RSA) must succeed
661+
/// without TLS. A freshly created user forces a cache miss (AuthMoreData 0x04).
662+
#[tokio::test]
663+
async fn caching_sha2_rsa_full_auth_without_tls() -> Result<()> {
664+
crate::shared::init_logging();
665+
let container = crate::shared::shared_mysql_binlog().await;
666+
let db_name = "integ_sha2_rsa";
667+
let admin_conn = crate::shared::create_test_db(container, db_name).await?;
668+
669+
let admin_pool = mysql_async::Pool::from_url(&admin_conn)?;
670+
let mut admin = admin_pool.get_conn().await?;
671+
672+
// Unique user so the server has no cached password hash yet.
673+
let user = format!("sha2_rsa_{}", std::process::id());
674+
let pass = "sha2_rsa_pass";
675+
admin
676+
.query_drop(format!("DROP USER IF EXISTS '{user}'@'%'"))
677+
.await?;
678+
admin
679+
.query_drop(format!(
680+
"CREATE USER '{user}'@'%' IDENTIFIED WITH caching_sha2_password BY '{pass}'"
681+
))
682+
.await?;
683+
admin
684+
.query_drop(format!(
685+
"GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO '{user}'@'%'"
686+
))
687+
.await?;
688+
admin
689+
.query_drop(format!("GRANT SELECT ON `{db_name}`.* TO '{user}'@'%'"))
690+
.await?;
691+
admin.query_drop("FLUSH PRIVILEGES").await?;
692+
693+
let sha2_conn = admin_conn.replacen("root:testpass@", &format!("{user}:{pass}@"), 1);
694+
let (host, port, username, password, _) = crate::shared::parse_mysql_uri(&sha2_conn)?;
695+
696+
// Plaintext connection: must use RSA full auth, not TLS cleartext.
697+
let client = BinlogClient::connect(ReplicaOptions {
698+
host,
699+
port,
700+
username,
701+
password,
702+
server_id: 9_003_020,
703+
ssl: SslMode::Disabled,
704+
blocking_poll: Duration::from_millis(200),
705+
flavor: None,
706+
mariadb_flags: binlog_protocol::MariaDbDumpFlags {
707+
send_annotate_rows: true,
708+
},
709+
mariadb_gtid_strict_mode: binlog_protocol::MariaDbGtidStrictMode::ServerDefault,
710+
})
711+
.await
712+
.map_err(|e| anyhow::anyhow!("caching_sha2 RSA full auth failed: {e}"))?;
713+
714+
drop(client);
715+
admin
716+
.query_drop(format!("DROP USER IF EXISTS '{user}'@'%'"))
717+
.await?;
718+
drop(admin);
719+
admin_pool.disconnect().await?;
720+
Ok(())
721+
}
722+
660723
#[tokio::test]
661724
async fn mysql_runtime_checkpoint_is_gtid() -> Result<()> {
662725
crate::shared::init_logging();

docs/mysql-binlog.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,22 @@ Provide `--tls-mode` to encrypt the replication connection when the server is re
219219
- `--tls-mode required` — require TLS; fail if unavailable.
220220
- `--tls-ca <PATH>` / `--tls-cert <PATH>` / `--tls-key <PATH>` — verify the server and/or present a client certificate.
221221

222+
### Authentication plugins
223+
224+
MySQL 8 defaults to `caching_sha2_password`. surreal-sync supports the stages of that plugin (and the older `mysql_native_password` plugin) as follows:
225+
226+
| Path | Meaning | surreal-sync |
227+
|------|---------|--------------|
228+
| Fast auth (`0x03`) | Server already has the password hash in cache | Supported |
229+
| Full auth + TLS (`0x04`) | Cache miss; send password in clear over TLS | Supported |
230+
| Full auth + RSA (`0x04`, no TLS) | Cache miss; encrypt password with the server RSA key | Supported |
231+
| `mysql_native_password` | Older SHA1 scramble plugin | Supported |
232+
| `sha256_password` | Different legacy plugin | Not supported |
233+
234+
With `--tls-mode disabled` (the default), a cache miss uses RSA public-key encryption so the password is not sent in cleartext. Prefer `--tls-mode required` (with a CA) on untrusted networks so the whole replication session is encrypted, not only the password exchange.
235+
236+
You may still create a replication user with `IDENTIFIED WITH mysql_native_password` for compatibility with older clients; it is optional for surreal-sync.
237+
222238
### Binlog compression
223239

224240
surreal-sync transparently decodes MySQL 8's compressed transaction payloads (`TRANSACTION_PAYLOAD`, zstd). If you use per-column compression or a compression scheme surreal-sync does not yet decode, it fails with a clear error rather than importing corrupt data; disable that feature or open an issue:

0 commit comments

Comments
 (0)