Skip to content

Commit d089c8c

Browse files
jappeace-slothclaude
andcommitted
Add caching_sha2_password authentication support (Issue #65)
MySQL 8.0+ defaults to caching_sha2_password instead of mysql_native_password. This adds support for: - SHA256 scramble for caching_sha2_password fast auth path - AuthMoreData (0x01) handling for fast auth success / full auth request - AuthSwitchRequest (0xFE) handling when server switches auth plugin - TLS full auth (cleartext password over encrypted connection) - CLIENT_PLUGIN_AUTH capability flag and plugin name in Auth packet Plain TCP connections throw an informative AuthException when full auth is required (RSA not yet implemented). Use TLS or ensure the password verifier is cached (fast auth path). Tested against both MariaDB 11.x and MySQL 8.0 in NixOS VM integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb022ff commit d089c8c

12 files changed

Lines changed: 320 additions & 52 deletions

File tree

mysql-haskell.cabal

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ test-suite test
128128
QC.Combinator
129129
QC.Common
130130
Orphans
131+
Sha256Scramble
131132
TCPStreams
132133
Word24
133134

@@ -168,6 +169,7 @@ test-suite integration
168169
BinaryRowNew
169170
BinLog
170171
BinLogNew
172+
CachingSha2
171173
ExecuteMany
172174
MysqlTests
173175
RoundtripBit

nix/ci.nix

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ in
1616
server.succeed("mysql -u root -e \"CREATE USER 'testMySQLHaskell'@'localhost';\"")
1717
server.succeed("mysql -u root -e \"CREATE DATABASE testMySQLHaskell;\"")
1818
server.succeed("mysql -u root -e \"GRANT ALL ON testMySQLHaskell.* TO 'testMySQLHaskell'@'localhost';\"")
19-
server.succeed("mysql -u root -e \"GRANT BINLOG MONITOR, REPLICATION SLAVE ON *.* TO 'testMySQLHaskell'@'localhost';\"")
19+
server.succeed("mysql -u root -e \"GRANT BINLOG MONITOR, REPLICATION SLAVE, CREATE USER ON *.* TO 'testMySQLHaskell'@'localhost';\"")
2020
print(server.succeed("${package}/bin/integration/integration"))
2121
'';
2222
nodes.server = {
@@ -34,4 +34,48 @@ in
3434
};
3535
};
3636
};
37+
integrated-checks-mysql80 = pkgs.testers.nixosTest {
38+
name = "mysql-haskell-mysql80-test";
39+
testScript = ''
40+
server.start()
41+
server.wait_for_unit("mysql.service")
42+
server.wait_until_succeeds("mysql -u root -e 'SELECT 1'")
43+
44+
server.succeed("mysql -u root -e \"CREATE DATABASE testMySQLHaskell;\"")
45+
46+
# Main test user (mysql_native_password so existing tests including password change work over plain TCP)
47+
server.succeed("mysql -u root -e \"CREATE USER 'testMySQLHaskell'@'localhost' IDENTIFIED WITH mysql_native_password;\"")
48+
server.succeed("mysql -u root -e \"GRANT ALL ON testMySQLHaskell.* TO 'testMySQLHaskell'@'localhost';\"")
49+
server.succeed("mysql -u root -e \"GRANT REPLICATION SLAVE, REPLICATION CLIENT, CREATE USER ON *.* TO 'testMySQLHaskell'@'localhost';\"")
50+
51+
# User with caching_sha2_password (MySQL 8.0 default) for SHA256 fast auth test
52+
server.succeed("mysql -u root -e \"CREATE USER 'testMySQLHaskellSha2'@'localhost' IDENTIFIED BY 'testPassword123';\"")
53+
server.succeed("mysql -u root -e \"GRANT ALL ON testMySQLHaskell.* TO 'testMySQLHaskellSha2'@'localhost';\"")
54+
55+
# User with mysql_native_password for AuthSwitchRequest test
56+
server.succeed("mysql -u root -e \"CREATE USER 'testMySQLHaskellNative'@'localhost' IDENTIFIED WITH mysql_native_password BY 'nativePass123';\"")
57+
server.succeed("mysql -u root -e \"GRANT ALL ON testMySQLHaskell.* TO 'testMySQLHaskellNative'@'localhost';\"")
58+
59+
# Pre-cache the caching_sha2_password verifier by logging in via unix socket
60+
server.succeed("mysql -u testMySQLHaskellSha2 -ptestPassword123 -e 'SELECT 1'")
61+
62+
# Run the full integration test suite (sha2 tests are conditionally included for MySQL 8.0+)
63+
print(server.succeed("${package}/bin/integration/integration"))
64+
'';
65+
nodes.server = {
66+
virtualisation.memorySize = 2048;
67+
virtualisation.diskSize = 1024;
68+
services.mysql = {
69+
enable = true;
70+
package = pkgs.mysql80;
71+
settings.mysqld = {
72+
max_allowed_packet = "256M";
73+
log_bin = "mysql-bin";
74+
server_id = 1;
75+
binlog_format = "ROW";
76+
default_authentication_plugin = "caching_sha2_password";
77+
};
78+
};
79+
};
80+
};
3781
}

src/Database/MySQL/Connection.hs

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ This is an internal module, the 'MySQLConn' type should not directly acessed to
1414
1515
-}
1616

17-
module Database.MySQL.Connection where
17+
module Database.MySQL.Connection
18+
( module Database.MySQL.Connection
19+
) where
1820

1921
import Control.Exception (Exception, bracketOnError,
2022
throwIO, catch, SomeException)
@@ -124,37 +126,107 @@ connectDetail (ConnectInfo host port db user pass charset)
124126
let auth = mkAuth db user pass charset greet
125127
write c $ encodeToPacket 1 auth
126128
q <- readPacket is'
127-
if isOK q
128-
then do
129-
consumed <- newIORef True
130-
let waitNotMandatoryOK = catch
131-
(void (waitCommandReply is')) -- server will either reply an OK packet
132-
((\ _ -> return ()) :: SomeException -> IO ()) -- or directy close the connection
133-
conn = MySQLConn is'
134-
(write c)
135-
(writeCommand COM_QUIT (write c) >> waitNotMandatoryOK >> TCP.close c)
136-
consumed
137-
return (greet, conn)
138-
else TCP.close c >> decodeFromPacket q >>= throwIO . ERRException
129+
completeAuth is' (write c) pass q plainFullAuth
130+
consumed <- newIORef True
131+
let waitNotMandatoryOK = catch
132+
(void (waitCommandReply is')) -- server will either reply an OK packet
133+
((\ _ -> return ()) :: SomeException -> IO ()) -- or directy close the connection
134+
conn = MySQLConn is'
135+
(write c)
136+
(writeCommand COM_QUIT (write c) >> waitNotMandatoryOK >> TCP.close c)
137+
consumed
138+
return (greet, conn)
139139

140140
connectWithBufferSize h p bs = TCP.connectSocket h p >>= TCP.socketToConnection bs
141141
write c a = TCP.send c $ Binary.runPut . Binary.put $ a
142142

143143
mkAuth :: ByteString -> ByteString -> ByteString -> Word8 -> Greeting -> Auth
144144
mkAuth db user pass charset greet =
145145
let salt = greetingSalt1 greet `B.append` greetingSalt2 greet
146-
scambleBuf = scramble salt pass
147-
in Auth clientCap clientMaxPacketSize charset user scambleBuf db
148-
where
149-
scramble :: ByteString -> ByteString -> ByteString
150-
scramble salt pass'
151-
| B.null pass' = B.empty
152-
| otherwise = B.pack (B.zipWith xor sha1pass withSalt)
153-
where sha1pass = sha1 pass'
154-
withSalt = sha1 (salt `B.append` sha1 sha1pass)
155-
156-
sha1 :: ByteString -> ByteString
157-
sha1 = BA.convert . (Crypto.hash :: ByteString -> Crypto.Digest Crypto.SHA1)
146+
plugin = greetingAuthPlugin greet
147+
scambleBuf = scrambleForPlugin plugin salt pass
148+
in Auth clientCap clientMaxPacketSize charset user scambleBuf db plugin
149+
150+
-- | Dispatch scramble based on the authentication plugin name.
151+
scrambleForPlugin :: ByteString -> ByteString -> ByteString -> ByteString
152+
scrambleForPlugin plugin salt pass
153+
| plugin == "caching_sha2_password" = scrambleSHA256 salt pass
154+
| otherwise = scrambleSHA1 salt pass
155+
156+
-- | SHA1-based scramble for @mysql_native_password@.
157+
scrambleSHA1 :: ByteString -> ByteString -> ByteString
158+
scrambleSHA1 salt pass
159+
| B.null pass = B.empty
160+
| otherwise = B.pack (B.zipWith xor sha1pass withSalt)
161+
where sha1pass = sha1 pass
162+
withSalt = sha1 (salt `B.append` sha1 sha1pass)
163+
sha1 :: ByteString -> ByteString
164+
sha1 = BA.convert . (Crypto.hash :: ByteString -> Crypto.Digest Crypto.SHA1)
165+
166+
-- | SHA256-based scramble for @caching_sha2_password@.
167+
-- XOR(SHA256(password), SHA256(SHA256(SHA256(password)) + nonce))
168+
scrambleSHA256 :: ByteString -> ByteString -> ByteString
169+
scrambleSHA256 salt pass
170+
| B.null pass = B.empty
171+
| otherwise = B.pack (B.zipWith xor sha256pass withSalt)
172+
where sha256pass = sha256 pass
173+
withSalt = sha256 (sha256 sha256pass `B.append` salt)
174+
sha256 :: ByteString -> ByteString
175+
sha256 = BA.convert . (Crypto.hash :: ByteString -> Crypto.Digest Crypto.SHA256)
176+
177+
-- | Handle multi-step authentication after sending the initial auth response.
178+
--
179+
-- This handles OK, ERR, AuthMoreData (0x01), and AuthSwitchRequest (0xFE).
180+
-- The @fullAuth@ callback is invoked when the server requests full authentication
181+
-- (e.g., cleartext password over TLS).
182+
completeAuth :: InputStream Packet -- ^ packet input stream
183+
-> (Packet -> IO ()) -- ^ packet writer
184+
-> ByteString -- ^ password
185+
-> Packet -- ^ the first response packet from server
186+
-> (Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ())
187+
-- ^ full auth callback (seqN, password, writer, input)
188+
-> IO ()
189+
completeAuth is writePacket pass p fullAuth
190+
| isOK p = return ()
191+
| isERR p = decodeFromPacket p >>= throwIO . ERRException
192+
| isAuthMoreData p = do
193+
let body = L.toStrict (pBody p)
194+
case B.index body 1 of
195+
0x03 -> do -- fast auth success, read the final OK
196+
ok <- readPacket is
197+
if isOK ok
198+
then return ()
199+
else decodeFromPacket ok >>= throwIO . ERRException
200+
0x04 -> do -- full auth required
201+
fullAuth (pSeqN p + 1) pass writePacket is
202+
_ -> throwIO (UnexpectedPacket p)
203+
| isAuthSwitch p = do
204+
-- Parse AuthSwitchRequest: 0xFE, plugin name (NUL), salt
205+
let body = L.toStrict (pBody p)
206+
rest = B.drop 1 body -- skip 0xFE
207+
(newPlugin, rest') = B.break (== 0) rest
208+
newSalt = B.drop 1 rest' -- skip NUL; trailing NUL may or may not be present
209+
-- Remove trailing NUL from salt if present
210+
newSalt' = if not (B.null newSalt) && B.last newSalt == 0
211+
then B.init newSalt
212+
else newSalt
213+
scrambled = scrambleForPlugin newPlugin newSalt' pass
214+
seqN = pSeqN p + 1
215+
responseBody = L.fromStrict scrambled
216+
responsePacket = Packet (fromIntegral (B.length scrambled)) seqN responseBody
217+
writePacket responsePacket
218+
q <- readPacket is
219+
completeAuth is writePacket pass q fullAuth
220+
| otherwise = throwIO (UnexpectedPacket p)
221+
222+
-- | Full auth handler for plain TCP connections: throws an error because
223+
-- caching_sha2_password full authentication requires a secure connection.
224+
plainFullAuth :: Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ()
225+
plainFullAuth _ _ _ _ =
226+
throwIO $ AuthException "caching_sha2_password full authentication requires a TLS connection. Use Database.MySQL.TLS to connect, or ensure the password verifier is cached (fast auth path)."
227+
228+
data AuthException = AuthException String deriving (Typeable, Show)
229+
instance Exception AuthException
158230

159231
-- | A specialized 'decodeInputStream' here for speed
160232
decodeInputStream :: InputStream ByteString -> IO (InputStream Packet)

src/Database/MySQL/Protocol/Auth.hs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ data Auth = Auth
122122
, authName :: !ByteString
123123
, authPassword :: !ByteString
124124
, authSchema :: !ByteString
125+
, authPlugin :: !ByteString
125126
} deriving (Show, Eq)
126127

127128
getAuth :: Get Auth
@@ -131,10 +132,10 @@ getAuth = do
131132
c <- getWord8
132133
skipN 23
133134
n <- getByteStringNul
134-
return $ Auth a m c n B.empty B.empty
135+
return $ Auth a m c n B.empty B.empty B.empty
135136

136137
putAuth :: Auth -> Put
137-
putAuth (Auth cap m c n p s) = do
138+
putAuth (Auth cap m c n p s plugin) = do
138139
putWord32le cap
139140
putWord32le m
140141
putWord8 c
@@ -144,6 +145,8 @@ putAuth (Auth cap m c n p s) = do
144145
putByteString p
145146
putByteString s
146147
putWord8 0x00
148+
putByteString plugin
149+
putWord8 0x00
147150

148151
instance Binary Auth where
149152
get = getAuth
@@ -182,6 +185,7 @@ clientCap = CLIENT_LONG_PASSWORD
182185
.|. CLIENT_MULTI_STATEMENTS
183186
.|. CLIENT_MULTI_RESULTS
184187
.|. CLIENT_SECURE_CONNECTION
188+
.|. CLIENT_PLUGIN_AUTH
185189

186190
clientMaxPacketSize :: Word32
187191
clientMaxPacketSize = 0x00ffffff :: Word32

src/Database/MySQL/Protocol/Packet.hs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ isEOF :: Packet -> Bool
7171
isEOF p = L.index (pBody p) 0 == 0xFE
7272
{-# INLINE isEOF #-}
7373

74+
-- | Is this an AuthMoreData packet? (first byte 0x01)
75+
-- Used during authentication handshake for caching_sha2_password.
76+
isAuthMoreData :: Packet -> Bool
77+
isAuthMoreData p = L.index (pBody p) 0 == 0x01
78+
{-# INLINE isAuthMoreData #-}
79+
80+
-- | Is this an AuthSwitchRequest packet? (first byte 0xFE)
81+
-- Same marker as EOF but used in authentication context.
82+
isAuthSwitch :: Packet -> Bool
83+
isAuthSwitch p = L.index (pBody p) 0 == 0xFE
84+
{-# INLINE isAuthSwitch #-}
85+
7486
-- | Is there more packet to be read?
7587
--
7688
-- https://dev.mysql.com/doc/internals/en/status-flags.html

src/Database/MySQL/TLS.hs

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,22 @@ module Database.MySQL.TLS (
1717
, module Data.TLSSetting
1818
) where
1919

20-
import Control.Exception (bracketOnError, throwIO)
20+
import Control.Exception (bracketOnError, throwIO, catch, SomeException)
21+
import Control.Monad (void)
2122
import qualified Data.Binary as Binary
2223
import qualified Data.Binary.Put as Binary
24+
import Data.ByteString (ByteString)
25+
import qualified Data.ByteString as B
26+
import qualified Data.ByteString.Lazy as L
27+
import Data.Word (Word8)
2328
import qualified Data.Connection as Conn
2429
import Data.IORef (newIORef)
2530
import Data.TLSSetting
2631
import Database.MySQL.Connection hiding (connect, connectDetail)
2732
import Database.MySQL.Protocol.Auth
33+
import Database.MySQL.Protocol.Command
2834
import Database.MySQL.Protocol.Packet
35+
import System.IO.Streams (InputStream)
2936
import qualified Network.TLS as TLS
3037
import qualified System.IO.Streams.TCP as TCP
3138
import qualified Data.Connection as TCP
@@ -63,13 +70,29 @@ connectDetail (ConnectInfo host port db user pass charset) (cparams, subName) =
6370
let auth = mkAuth db user pass charset greet
6471
write tc (encodeToPacket 2 auth)
6572
q <- readPacket tlsIs'
66-
if isOK q
67-
then do
68-
consumed <- newIORef True
69-
let conn = MySQLConn tlsIs' (write tc) (TCP.close tc) consumed
70-
return (greet, conn)
71-
else TCP.close c >> decodeFromPacket q >>= throwIO . ERRException
73+
completeAuth tlsIs' (write tc) pass q tlsFullAuth
74+
consumed <- newIORef True
75+
let waitNotMandatoryOK = catch
76+
(void (waitCommandReply tlsIs'))
77+
((\ _ -> return ()) :: SomeException -> IO ())
78+
conn = MySQLConn tlsIs' (write tc)
79+
(writeCommand COM_QUIT (write tc) >> waitNotMandatoryOK >> TCP.close tc)
80+
consumed
81+
return (greet, conn)
7282
else error "Database.MySQL.TLS: server doesn't support TLS connection"
7383
where
7484
connectWithBufferSize h p bs = TCP.connectSocket h p >>= TCP.socketToConnection bs
7585
write c a = TCP.send c $ Binary.runPut . Binary.put $ a
86+
87+
-- | Full auth handler for TLS connections: sends the cleartext password
88+
-- as a NUL-terminated packet, which MySQL accepts over encrypted connections.
89+
tlsFullAuth :: Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ()
90+
tlsFullAuth seqN pass writePacket is = do
91+
let payload = pass `B.append` "\0"
92+
body = L.fromStrict payload
93+
pkt = Packet (fromIntegral (B.length payload)) seqN body
94+
writePacket pkt
95+
q <- readPacket is
96+
if isOK q
97+
then return ()
98+
else decodeFromPacket q >>= throwIO . ERRException

test/CachingSha2.hs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{-# LANGUAGE ScopedTypeVariables #-}
2+
3+
module CachingSha2 (tests) where
4+
5+
import Database.MySQL.Base
6+
import qualified System.IO.Streams as Stream
7+
import Test.Tasty
8+
import Test.Tasty.HUnit
9+
10+
-- | These tests exercise two different authentication paths in 'completeAuth'.
11+
-- Both tests connect and run @SELECT 1@, but the server-side auth protocol
12+
-- differs based on which MySQL user is used. The users are created in
13+
-- @nix/ci.nix@ (integrated-checks-mysql80) with different auth plugins:
14+
--
15+
-- * @testMySQLHaskellSha2@ — created with @caching_sha2_password@ (the MySQL 8.0
16+
-- default). The client sends a SHA256 scramble, the server responds with
17+
-- AuthMoreData (0x01, byte 2 = 0x03) indicating fast auth success.
18+
-- The CI script pre-caches the verifier via a unix socket login so the
19+
-- fast path is guaranteed.
20+
--
21+
-- * @testMySQLHaskellNative@ — created with @mysql_native_password@. The server
22+
-- advertises @caching_sha2_password@ in its Greeting, so the client initially
23+
-- sends a SHA256 scramble. The server then responds with AuthSwitchRequest
24+
-- (0xFE) telling the client to re-authenticate using @mysql_native_password@
25+
-- with a new salt. The client re-scrambles with SHA1 and sends the response.
26+
tests :: TestTree
27+
tests = testGroup "caching_sha2_password"
28+
[ testCaseSteps "SHA256 fast auth" $ \step -> do
29+
step "connecting as testMySQLHaskellSha2 (caching_sha2_password)..."
30+
(_, c) <- connectDetail defaultConnectInfo
31+
{ ciUser = "testMySQLHaskellSha2"
32+
, ciPassword = "testPassword123"
33+
, ciDatabase = "testMySQLHaskell"
34+
}
35+
36+
step "executing SELECT 1..."
37+
(_, is) <- query_ c "SELECT 1"
38+
Just row <- Stream.read is
39+
assertBool "SELECT 1 returns 1" (row == [MySQLInt32 1] || row == [MySQLInt64 1])
40+
Stream.skipToEof is
41+
42+
close c
43+
44+
, testCaseSteps "AuthSwitchRequest handling (mysql_native_password on sha2 server)" $ \step -> do
45+
step "connecting as testMySQLHaskellNative (mysql_native_password)..."
46+
(_, c) <- connectDetail defaultConnectInfo
47+
{ ciUser = "testMySQLHaskellNative"
48+
, ciPassword = "nativePass123"
49+
, ciDatabase = "testMySQLHaskell"
50+
}
51+
52+
step "executing SELECT 1..."
53+
(_, is) <- query_ c "SELECT 1"
54+
Just row <- Stream.read is
55+
assertBool "SELECT 1 returns 1" (row == [MySQLInt32 1] || row == [MySQLInt64 1])
56+
Stream.skipToEof is
57+
58+
close c
59+
]

0 commit comments

Comments
 (0)