packages feed

mysql-haskell 1.2.2 → 1.2.3

raw patch · 8 files changed

+244/−10 lines, 8 filesdep +asn1-encodingdep +asn1-typesdep +base16-bytestringPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: asn1-encoding, asn1-types, base16-bytestring

API changes (from Hackage documentation)

+ Database.MySQL.Connection: doRSAAuth :: (Packet -> IO ()) -> Word8 -> ByteString -> ByteString -> ByteString -> IO ()
+ Database.MySQL.Connection: parseRSAPublicKey :: ByteString -> Either String PublicKey
+ Database.MySQL.Connection: xorPasswordNonce :: ByteString -> ByteString -> ByteString
- Database.MySQL.Connection: plainFullAuth :: Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ()
+ Database.MySQL.Connection: plainFullAuth :: ByteString -> Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ()

Files

ChangeLog.md view
@@ -1,5 +1,8 @@ # Revision history for mysql-haskell +## 1.2.3 -- 2026.04.12 ++ Support caching_sha2_password full auth via RSA on non-TLS connections#81 thanks @ikaro1192+ ## 1.2.2 -- 2026.03.25 + Widen tls upper bound to allow tls 2.4.x + Fix unsafe ByteString operations (`unsafeDrop`, `unsafeTail`, `unsafeIndex`)
mysql-haskell.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.4 name:               mysql-haskell-version:            1.2.2+version:            1.2.3 synopsis:           pure haskell MySQL driver description:        pure haskell MySQL driver. license:            BSD-3-Clause@@ -90,6 +90,8 @@   hs-source-dirs:     src   other-modules:      Database.MySQL.Query   build-depends:+    asn1-encoding >=0.9 && <0.10,+    asn1-types >=0.3 && <0.4,     binary >=0.8.3 && <0.9,     blaze-textual >=0.2 && <0.3,     bytestring >=0.10.2.0 && <0.12 || ^>=0.12.0,@@ -139,6 +141,7 @@     QC.Combinator     QC.Common     Orphans+    Sha1Scramble     Sha256Scramble     TCPStreams     Word24@@ -146,6 +149,7 @@   hs-source-dirs:     test   build-depends:     attoparsec < 1.0,+    base16-bytestring >=1.0 && <2.0,     binary >=0.8,     bytestring >=0.10,     deepseq,@@ -187,6 +191,7 @@     RoundtripYear     SelectOne     TextRow+    TLSConnection     TextRowNew     UnixSocket 
src/Database/MySQL/Connection.hs view
@@ -22,6 +22,14 @@                                                   throwIO, catch, SomeException) import           Control.Monad import qualified Crypto.Hash                     as Crypto+import           Crypto.Hash.Algorithms          (SHA1(..))+import qualified Crypto.Number.Serialize         as Serialize+import qualified Crypto.PubKey.RSA               as RSA+import qualified Crypto.PubKey.RSA.OAEP          as OAEP+import qualified Data.ASN1.BinaryEncoding        as ASN1B+import qualified Data.ASN1.BitArray              as ASN1BA+import qualified Data.ASN1.Encoding              as ASN1E+import qualified Data.ASN1.Types                 as ASN1 import qualified Data.Binary                     as Binary import qualified Data.Binary.Put                 as Binary import           Data.Bits@@ -36,6 +44,7 @@ import qualified Data.ByteString.Unsafe          as B import           Data.IORef                      (IORef, newIORef, readIORef,                                                   writeIORef)+import qualified Data.PEM                        as PEM import           Data.Typeable import           Data.Word import           Database.MySQL.Protocol.Auth@@ -124,10 +133,11 @@         is' <- decodeInputStream is         p <- readPacket is'         greet <- decodeFromPacket p-        let auth = mkAuth db user pass charset greet+        let salt = greetingSalt1 greet `B.append` greetingSalt2 greet+            auth = mkAuth db user pass charset greet         write c $ encodeToPacket 1 auth         q <- readPacket is'-        completeAuth is' (write c) pass q plainFullAuth+        completeAuth is' (write c) pass q (plainFullAuth salt)         consumed <- newIORef True         let waitNotMandatoryOK = catch                 (void (waitCommandReply is'))           -- server will either reply an OK packet@@ -165,10 +175,11 @@         is' <- decodeInputStream is         p <- readPacket is'         greet <- decodeFromPacket p-        let auth = mkAuth db user pass charset greet+        let salt = greetingSalt1 greet `B.append` greetingSalt2 greet+            auth = mkAuth db user pass charset greet         write c $ encodeToPacket 1 auth         q <- readPacket is'-        completeAuth is' (write c) pass q plainFullAuth+        completeAuth is' (write c) pass q (plainFullAuth salt)         consumed <- newIORef True         let waitNotMandatoryOK = catch                 (void (waitCommandReply is'))           -- server will either reply an OK packet@@ -260,11 +271,77 @@         completeAuth is writePacket pass q fullAuth     | otherwise = throwIO (UnexpectedPacket p) --- | Full auth handler for plain TCP connections: throws an error because--- caching_sha2_password full authentication requires a secure connection.-plainFullAuth :: Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ()-plainFullAuth _ _ _ _ =-    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)."+-- | Full auth handler for plain TCP connections using RSA encryption.+--+-- The nonce is captured via partial application at the call site:+-- @completeAuth is writePacket pass q (plainFullAuth salt)@+plainFullAuth :: ByteString -> Word8 -> ByteString -> (Packet -> IO ()) -> InputStream Packet -> IO ()+plainFullAuth nonce seqN pass writePacket is = do+    -- Request server's RSA public key+    writePacket (putToPacket seqN (Binary.putWord8 0x02))+    keyPkt <- readPacket is+    -- Key packet body: 0x01 <PEM bytes>+    let pemKey = L.toStrict (L.drop 1 (pBody keyPkt))+    doRSAAuth writePacket (pSeqN keyPkt + 1) pass nonce pemKey+    q <- readPacket is+    if isOK q then pure ()+              else decodeFromPacket q >>= throwIO . ERRException++-- | Parse a PEM-encoded SubjectPublicKeyInfo RSA public key (PKCS#8 / RFC 5480).+parseRSAPublicKey :: ByteString -> Either String RSA.PublicKey+parseRSAPublicKey pemBytes =+    either (Left . show) Right (PEM.pemParseBS pemBytes)+    >>= firstPEM+    >>= decodeDER+    >>= spkiBitString+    >>= decodeDER+    >>= rsaKey+  where+    firstPEM []    = Left "empty PEM"+    firstPEM (x:_) = Right (PEM.pemContent x)++    decodeDER bs = either (Left . show) Right (ASN1E.decodeASN1' ASN1B.DER bs)++    spkiBitString (ASN1.Start ASN1.Sequence+                   : ASN1.Start ASN1.Sequence+                   : ASN1.OID _+                   : ASN1.Null+                   : ASN1.End ASN1.Sequence+                   : ASN1.BitString bs+                   : ASN1.End ASN1.Sequence+                   : [])+        = Right (ASN1BA.bitArrayGetData bs)+    spkiBitString xs = Left ("unexpected SPKI ASN1: " ++ show xs)++    rsaKey (ASN1.Start ASN1.Sequence+            : ASN1.IntVal n+            : ASN1.IntVal e+            : ASN1.End ASN1.Sequence+            : [])+        = Right RSA.PublicKey+            { RSA.public_size = B.length (Serialize.i2osp n :: ByteString)+            , RSA.public_n    = n+            , RSA.public_e    = e+            }+    rsaKey xs = Left ("unexpected RSA key ASN1: " ++ show xs)++-- | XOR @(password ++ NUL)@ with a cyclically-extended nonce.+xorPasswordNonce :: ByteString -> ByteString -> ByteString+xorPasswordNonce pass nonce =+    let msg      = pass `B.snoc` 0x00+        nonceLen = B.length nonce+        cycled   = B.pack [nonce `B.index` (i `mod` nonceLen) | i <- [0 .. B.length msg - 1]]+    in B.pack (B.zipWith xor msg cycled)++-- | Encrypt password with the server's RSA public key and send it.+doRSAAuth :: (Packet -> IO ()) -> Word8 -> ByteString -> ByteString -> ByteString -> IO ()+doRSAAuth writePacket seqN pass nonce pemKey = do+    pubKey <- either (\e -> throwIO (userError ("RSA key parse: " ++ e))) pure+                     (parseRSAPublicKey pemKey)+    result <- OAEP.encrypt (OAEP.defaultOAEPParams SHA1) pubKey+                           (xorPasswordNonce pass nonce)+    ct <- either (\e -> throwIO (userError ("RSA encrypt: " ++ show e))) pure result+    writePacket (putToPacket seqN (Binary.putByteString ct))  data AuthException = AuthException String deriving (Typeable, Show) instance Exception AuthException
test/Integration.hs view
@@ -2,12 +2,14 @@  import qualified Data.ByteString as B import           Database.MySQL.Base+import           System.Environment (lookupEnv) import           Test.Tasty (defaultMain, testGroup) import qualified CachingSha2 import qualified MysqlTests import qualified RoundtripBit import qualified RoundtripYear import qualified SelectOne+import qualified TLSConnection import qualified UnixSocket  main :: IO ()@@ -22,6 +24,9 @@     -- Probe for a Unix domain socket (MYSQL_UNIX_SOCKET env var, then common paths).     mSockPath <- UnixSocket.findSocketPath +    -- Probe for TLS CA certificate path (set in NixOS VM tests).+    mTlsCaPath <- lookupEnv "MYSQL_TLS_CA_PATH"+     defaultMain $ testGroup "mysql-integration" $         [ SelectOne.tests         , RoundtripBit.tests@@ -33,3 +38,5 @@         ++ [ CachingSha2.tests | isMySql80 ]         -- Unix socket tests are included only when a socket file is found.         ++ [ UnixSocket.tests p | Just p <- [mSockPath] ]+        -- TLS tests are included only when a CA certificate path is provided.+        ++ [ TLSConnection.tests p | Just p <- [mTlsCaPath] ]
test/Main.hs view
@@ -4,6 +4,7 @@ import qualified QC.Combinator as Combinator import Test.Tasty (defaultMain, testGroup) import qualified JSON+import qualified Sha1Scramble import qualified Sha256Scramble import qualified Word24 import qualified TCPStreams@@ -24,6 +25,7 @@          [            TCPStreams.tests          ]+      , Sha1Scramble.tests       , Sha256Scramble.tests       , BoundsCheck.tests       ]
+ test/Sha1Scramble.hs view
@@ -0,0 +1,49 @@+module Sha1Scramble (tests) where++import qualified Data.ByteString as B+import qualified Data.ByteString.Base16 as Base16+import           Data.ByteString.Char8 (pack)+import           Database.MySQL.Connection (scrambleSHA1)+import           Test.Tasty+import           Test.Tasty.HUnit++hexToBytes :: String -> B.ByteString+hexToBytes = Base16.decodeLenient . pack++tests :: TestTree+tests = testGroup "SHA1 Scramble"+    [ testCase "empty password returns empty" $ do+        let result = scrambleSHA1 "some_salt" ""+        assertEqual "empty password" B.empty result++    , testCase "non-empty password returns 20 bytes" $ do+        let result = scrambleSHA1 "12345678901234567890" "password"+        assertEqual "scramble length" 20 (B.length result)++    , testCase "different salts produce different scrambles" $ do+        let r1 = scrambleSHA1 "salt1_______________" "password"+            r2 = scrambleSHA1 "salt2_______________" "password"+        assertBool "different salts should produce different results" (r1 /= r2)++    , testCase "different passwords produce different scrambles" $ do+        let salt = "12345678901234567890"+            r1 = scrambleSHA1 salt "password1"+            r2 = scrambleSHA1 salt "password2"+        assertBool "different passwords should produce different results" (r1 /= r2)++    , testCase "scramble is deterministic" $ do+        let salt = "12345678901234567890"+            r1 = scrambleSHA1 salt "testPassword123"+            r2 = scrambleSHA1 salt "testPassword123"+        assertEqual "same inputs produce same output" r1 r2++    , testCase "golden: salt=12345678901234567890 pass=password" $ do+        let result = scrambleSHA1 "12345678901234567890" "password"+            expected = hexToBytes "1957dce2724282e018f40d905824cb6361f88d41"+        assertEqual "golden vector 1" expected result++    , testCase "golden: salt=ABCDEFGHIJKLMNOPQRST pass=secret" $ do+        let result = scrambleSHA1 "ABCDEFGHIJKLMNOPQRST" "secret"+            expected = hexToBytes "28441590674285e7d03cae7af237504797f70e91"+        assertEqual "golden vector 2" expected result+    ]
test/Sha256Scramble.hs view
@@ -1,10 +1,15 @@ module Sha256Scramble (tests) where  import qualified Data.ByteString as B+import qualified Data.ByteString.Base16 as Base16+import           Data.ByteString.Char8 (pack) import           Database.MySQL.Connection (scrambleSHA256) import           Test.Tasty import           Test.Tasty.HUnit +hexToBytes :: String -> B.ByteString+hexToBytes = Base16.decodeLenient . pack+ tests :: TestTree tests = testGroup "SHA256 Scramble"     [ testCase "empty password returns empty" $ do@@ -31,4 +36,14 @@             r1 = scrambleSHA256 salt "testPassword123"             r2 = scrambleSHA256 salt "testPassword123"         assertEqual "same inputs produce same output" r1 r2++    , testCase "golden: salt=12345678901234567890 pass=password" $ do+        let result = scrambleSHA256 "12345678901234567890" "password"+            expected = hexToBytes "718d80fb92b693f7ee1fcad95bfe7a2be1f332ac2c3ec6cdc85893b65b240650"+        assertEqual "golden vector 1" expected result++    , testCase "golden: salt=ABCDEFGHIJKLMNOPQRST pass=secret" $ do+        let result = scrambleSHA256 "ABCDEFGHIJKLMNOPQRST" "secret"+            expected = hexToBytes "d721e183c1f036a196c1389201b8d53f38064a16f1d0923683ab886763152d75"+        assertEqual "golden vector 2" expected result     ]
+ test/TLSConnection.hs view
@@ -0,0 +1,76 @@+{-# LANGUAGE ScopedTypeVariables #-}++module TLSConnection (tests) where++import qualified Data.ByteString       as B+import qualified Data.Text             as T+import           Database.MySQL.Base+import qualified Database.MySQL.TLS    as TLS+import           Database.MySQL.TLS    (makeClientParams, TrustedCAStore(..))+import qualified System.IO.Streams     as Stream+import           Test.Tasty+import           Test.Tasty.HUnit++tests :: FilePath -> TestTree+tests caPath = testGroup "tls-connection"+    [ testCaseSteps "TLS connectDetail: SELECT 1" $ \step -> do+        step "creating TLS client params..."+        cparams <- makeClientParams (CustomCAStore caPath)++        step "connecting via TLS..."+        (greet, conn) <- TLS.connectDetail+            defaultConnectInfo { ciUser = "testMySQLHaskell", ciDatabase = "testMySQLHaskell" }+            (cparams, "Winter")++        step "checking greeting version..."+        let ver = greetingVersion greet+        assertBool "greeting version is not empty" (not $ B.null ver)++        step "executing SELECT 1..."+        (_, is) <- query_ conn "SELECT 1"+        Just row <- Stream.read is+        assertBool "SELECT 1 returns 1"+            (row == [MySQLInt32 1] || row == [MySQLInt64 1])+        Stream.skipToEof is++        close conn++    , testCaseSteps "TLS connection: verify encryption active" $ \step -> do+        step "creating TLS client params..."+        cparams <- makeClientParams (CustomCAStore caPath)++        step "connecting via TLS..."+        conn <- TLS.connect+            defaultConnectInfo { ciUser = "testMySQLHaskell", ciDatabase = "testMySQLHaskell" }+            (cparams, "Winter")++        step "checking SSL cipher..."+        (_, is) <- query_ conn "SHOW STATUS LIKE 'Ssl_cipher'"+        Just row <- Stream.read is+        case row of+            [_name, MySQLText cipher] ->+                assertBool "SSL cipher is not empty" (not $ T.null cipher)+            _ -> assertFailure ("unexpected row shape: " ++ show row)+        Stream.skipToEof is++        close conn++    , testCaseSteps "TLS connection: prepared statement roundtrip" $ \step -> do+        step "creating TLS client params..."+        cparams <- makeClientParams (CustomCAStore caPath)++        step "connecting via TLS..."+        conn <- TLS.connect+            defaultConnectInfo { ciUser = "testMySQLHaskell", ciDatabase = "testMySQLHaskell" }+            (cparams, "Winter")++        step "executing prepared statement..."+        stmt <- prepareStmt conn "SELECT ? + 1"+        (_, is) <- queryStmt conn stmt [MySQLInt32 41]+        Just row <- Stream.read is+        assertBool "41 + 1 = 42"+            (row == [MySQLInt32 42] || row == [MySQLInt64 42])+        Stream.skipToEof is++        close conn+    ]