packages feed

mysql-haskell-1.5.0: test/Ed25519Scramble.hs

-- | MariaDB's ed25519 authentication, checked against MariaDB's own answers:
-- the keys its server stores and the signatures its client library sends.
module Ed25519Scramble (tests) where

import           Control.Exception              (try)
import qualified Crypto.PubKey.Ed25519          as Ed25519
import           Crypto.Error                   (eitherCryptoError)
import           Data.Binary.Put                (putByteString, putWord8)
import qualified Data.ByteArray.Encoding        as Encoding
import qualified Data.ByteString                as B
import qualified Data.ByteString.Base16         as Base16
import qualified Data.ByteString.Char8          as BC
import qualified Data.ByteString.Lazy           as L
import           Data.IORef                     (modifyIORef', newIORef, readIORef)
import           Database.MySQL.Connection      (UnexpectedPacket (..), completeAuth,
                                                 ed25519PublicKey, scrambleEd25519)
import           Database.MySQL.Protocol.Packet (Packet (..), putToPacket)
import qualified System.IO.Streams              as Stream
import           Test.QuickCheck.Instances      ()
import           Test.Tasty
import           Test.Tasty.HUnit
import           Test.Tasty.QuickCheck

hex :: String -> IO B.ByteString
hex = either (assertFailure . ("not hex: " ++)) pure . Base16.decode . BC.pack

-- | The key as MariaDB shows it: base64 without its one padding character.
mariadbKey :: B.ByteString -> B.ByteString
mariadbKey = B.dropWhileEnd (== 0x3D) . Encoding.convertToBase Encoding.Base64

-- | What the server sends when an account needs client_ed25519.
authSwitch :: B.ByteString -> Packet
authSwitch nonce = putToPacket 2 $ do
    putWord8 0xFE
    putByteString "client_ed25519"
    putWord8 0
    putByteString nonce

okPacket :: Packet
okPacket = putToPacket 4 (putByteString "\x00\x00\x00\x02\x00\x00\x00")

-- | The packets 'completeAuth' writes while answering an AuthSwitchRequest for
-- this nonce, and whether it refused the request.
answerSwitch :: B.ByteString -> B.ByteString -> IO ([Packet], Either UnexpectedPacket ())
answerSwitch nonce pass = do
    written <- newIORef []
    replies <- Stream.fromList [okPacket]
    outcome <- try $ completeAuth replies (\p -> modifyIORef' written (p :)) pass (authSwitch nonce)
        (\_ _ _ _ -> assertFailure "ed25519 asked for full authentication")
    sent <- readIORef written
    pure (reverse sent, outcome)

newtype Nonce = Nonce B.ByteString deriving Show

instance Arbitrary Nonce where
    arbitrary = Nonce . B.pack <$> vectorOf 32 arbitrary

-- | MariaDB's server checks a reply with standard Ed25519 verification, so
-- every reply must pass it for the key MariaDB stores.
verifiesAsEd25519 :: B.ByteString -> Nonce -> Property
verifiesAsEd25519 pass (Nonce nonce) =
    either (\failure -> counterexample (show failure) False) property $ do
        key <- ed25519PublicKey pass >>= eitherCryptoError . Ed25519.publicKey
        signature <- scrambleEd25519 nonce pass >>= eitherCryptoError . Ed25519.signature
        pure (Ed25519.verify key nonce signature)

tests :: TestTree
tests = testGroup "ed25519 scramble"
    [ testCase "public keys are the ones MariaDB stores" $ do
        -- From MariaDB 11.4.9's mysql-test/suite/plugins/r/auth_ed25519.result
        -- and its ed25519_password(); the last is the account nix/ci.nix makes.
        mariadbKey <$> ed25519PublicKey "foo" @?= Right "vubFBzIrapbfHct1/J72dnUryz5VS7lA6XHH8sIx4TI"
        mariadbKey <$> ed25519PublicKey "foobar" @?= Right "qv2mG6HWCuy32Slb5xhV4THStewNz2VINVPbgk+XAJ8"
        mariadbKey <$> ed25519PublicKey "foo bar" @?= Right "Y5fV74JAVRMOK2cdnUsYS+WW9sXaaL/o+6WGKOgqnzc"
        mariadbKey <$> ed25519PublicKey "secret" @?= Right "ZIgUREUg5PVgQ6LskhXmO+eZLS0nC8be6HPjYWR4YJY"
        mariadbKey <$> ed25519PublicKey "ed25519Pass123" @?= Right "j2VKy3t+tdtEQXXes18uKRvK1XL9crguh3AregMR6kk"
      -- Signatures from ma_crypto_sign of mariadb-connector-c 3.3.5's ref10.
    , testCase "an AuthSwitchRequest is answered with libmariadb's signature, a nonce ending in 0 included" $ do
        nonce <- hex "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00"
        signature <- hex "0c9e13ccd52e9dbb7957836f1c48ae216bf28da17c4b027f93534b0e5a348917\
                         \0096c63db819a35af8a62c976faa5bc436cb047911dcb3d28301cd07d5cd8303"
        (sent, outcome) <- answerSwitch nonce "ed25519Pass123"
        sent @?= [Packet 64 3 (L.fromStrict signature)]
        either (\(UnexpectedPacket p) -> assertFailure ("refused " ++ show p)) pure outcome
    , testCase "signature of another password and nonce" $ do
        nonce <- hex "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
        signature <- hex "c0999e2c61f2fe2d679307b2b236930e1efc3680e43d4bb2b46f4250d6e11d55\
                         \047e75a777f4db9d60aa3ab64eebb3197a827df920f5c852bdd7be70ba0d6e0c"
        scrambleEd25519 nonce "secret" @?= Right signature
    , testCase "a nonce that is not 32 bytes is refused unsigned, as libmariadb does" $ do
        let request = authSwitch (B.replicate 20 7)
        (sent, outcome) <- answerSwitch (B.replicate 20 7) "ed25519Pass123"
        sent @?= []
        either (\(UnexpectedPacket p) -> p @?= request) (\() -> assertFailure "the nonce was accepted") outcome
    , testProperty "every reply verifies as Ed25519 under the stored key" verifiesAsEd25519
    ]