In preparation of an implementation of the bcrypt_pbkdf (a variant of PBKDF2 used by OpenSSH) algorithm, certain low-level operations of the Blowfish algorithm need to be generalized and exposed. The Blowfish.Primitive module has already been extended to account for the requirements imposed by the BCrypt algorithm, but the salt length was limited to 16 bytes and the BCrypt specific key schedule setup has been hard-coded into the Blowfish module. This commit makes a clear distintion between the expandKey and expandKeyWithSalt operation. Both take arbitrary sized salts and keys now. The specialized operation for 16 byte salts as used by BCrypt has been preserved and is selected automatically. Also, the BCrypt specific parts have been move to the BCrypt module with regard to separation of concern. A benchmark for generating BCrypt hashes with cost 10 shows a performance improvement from 158 to 141ms on average (Intel i5-6500) after this refactoring. Further experiments suggest that the specialized expandKeyWithSalt128 does not have any advantage over the generalized version and might be removed in favour of less branches and exceptional behaviour.
190 lines
8.5 KiB
Haskell
190 lines
8.5 KiB
Haskell
|
|
-- | Password encoding and validation using bcrypt.
|
|
--
|
|
-- Example usage:
|
|
--
|
|
-- >>> import Crypto.KDF.BCrypt (hashPassword, validatePassword)
|
|
-- >>> import qualified Data.ByteString.Char8 as B
|
|
-- >>>
|
|
-- >>> let bcryptHash = B.pack "$2a$10$MJJifxfaqQmbx1Mhsq3oq.YmMmfNhkyW4s/MS3K5rIMVfB7w0Q/OW"
|
|
-- >>> let password = B.pack "password"
|
|
-- >>> validatePassword password bcryptHash
|
|
-- >>> True
|
|
-- >>> let otherPassword = B.pack "otherpassword"
|
|
-- >>> otherHash <- hashPassword 12 otherPasssword :: IO B.ByteString
|
|
-- >>> validatePassword otherPassword otherHash
|
|
-- >>> True
|
|
--
|
|
-- See <https://www.usenix.org/conference/1999-usenix-annual-technical-conference/future-adaptable-password-scheme>
|
|
-- for details of the original algorithm.
|
|
--
|
|
-- The functions @hashPassword@ and @validatePassword@ should be all that
|
|
-- most users need.
|
|
--
|
|
-- Hashes are strings of the form
|
|
-- @$2a$10$MJJifxfaqQmbx1Mhsq3oq.YmMmfNhkyW4s/MS3K5rIMVfB7w0Q/OW@ which
|
|
-- encode a version number, an integer cost parameter and the concatenated
|
|
-- salt and hash bytes (each separately Base64 encoded. Incrementing the
|
|
-- cost parameter approximately doubles the time taken to calculate the hash.
|
|
--
|
|
-- The different version numbers evolved to account for bugs in the standard
|
|
-- C implementations. They don't represent different versions of the algorithm
|
|
-- itself and in most cases should produce identical results.
|
|
-- The most up to date version is @2b@ and this implementation uses the
|
|
-- @2b@ version prefix, but will also attempt to validate
|
|
-- against hashes with versions @2a@ and @2y@. Version @2@ or @2x@ will be
|
|
-- rejected. No attempt is made to differentiate between the different versions
|
|
-- when validating a password, but in practice this shouldn't cause any problems
|
|
-- if passwords are UTF-8 encoded (which they should be) and less than 256
|
|
-- characters long.
|
|
--
|
|
-- The cost parameter can be between 4 and 31 inclusive, but anything less than
|
|
-- 10 is probably not strong enough. High values may be prohibitively slow
|
|
-- depending on your hardware. Choose the highest value you can without having
|
|
-- an unacceptable impact on your users. The cost parameter can also be varied
|
|
-- depending on the account, since it is unique to an individual hash.
|
|
|
|
module Crypto.KDF.BCrypt
|
|
( hashPassword
|
|
, validatePassword
|
|
, validatePasswordEither
|
|
, bcrypt
|
|
)
|
|
where
|
|
|
|
import Control.Monad (forM_, unless, when)
|
|
import Crypto.Cipher.Blowfish.Primitive (Context, createKeySchedule,
|
|
encrypt, expandKey,
|
|
expandKeyWithSalt,
|
|
freezeKeySchedule)
|
|
import Crypto.Internal.Compat
|
|
import Crypto.Random (MonadRandom, getRandomBytes)
|
|
import Data.ByteArray (ByteArray, ByteArrayAccess,
|
|
Bytes)
|
|
import qualified Data.ByteArray as B
|
|
import Data.ByteArray.Encoding
|
|
import Data.Char
|
|
|
|
data BCryptHash = BCH Char Int Bytes Bytes
|
|
|
|
-- | Create a bcrypt hash for a password with a provided cost value.
|
|
-- Typically used to create a hash when a new user account is registered
|
|
-- or when a user changes their password.
|
|
--
|
|
-- Each increment of the cost approximately doubles the time taken.
|
|
-- The 16 bytes of random salt will be generated internally.
|
|
hashPassword :: (MonadRandom m, ByteArray password, ByteArray hash)
|
|
=> Int
|
|
-- ^ The cost parameter. Should be between 4 and 31 (inclusive).
|
|
-- Values which lie outside this range will be adjusted accordingly.
|
|
-> password
|
|
-- ^ The password. Should be the UTF-8 encoded bytes of the password text.
|
|
-> m hash
|
|
-- ^ The bcrypt hash in standard format.
|
|
hashPassword cost password = do
|
|
salt <- getRandomBytes 16
|
|
return $ bcrypt cost (salt :: Bytes) password
|
|
|
|
-- | Create a bcrypt hash for a password with a provided cost value and salt.
|
|
--
|
|
-- Cost value under 4 will be automatically adjusted back to 10 for safety reason.
|
|
bcrypt :: (ByteArray salt, ByteArray password, ByteArray output)
|
|
=> Int
|
|
-- ^ The cost parameter. Should be between 4 and 31 (inclusive).
|
|
-- Values which lie outside this range will be adjusted accordingly.
|
|
-> salt
|
|
-- ^ The salt. Must be 16 bytes in length or an error will be raised.
|
|
-> password
|
|
-- ^ The password. Should be the UTF-8 encoded bytes of the password text.
|
|
-> output
|
|
-- ^ The bcrypt hash in standard format.
|
|
bcrypt cost salt password = B.concat [header, B.snoc costBytes dollar, b64 salt, b64 hash]
|
|
where
|
|
hash = rawHash 'b' realCost salt password
|
|
header = B.pack [dollar, fromIntegral (ord '2'), fromIntegral (ord 'b'), dollar]
|
|
dollar = fromIntegral (ord '$')
|
|
zero = fromIntegral (ord '0')
|
|
costBytes = B.pack [zero + fromIntegral (realCost `div` 10), zero + fromIntegral (realCost `mod` 10)]
|
|
realCost
|
|
| cost < 4 = 10 -- 4 is virtually pointless so go for 10
|
|
| cost > 31 = 31
|
|
| otherwise = cost
|
|
|
|
b64 :: (ByteArray ba) => ba -> ba
|
|
b64 = convertToBase Base64OpenBSD
|
|
|
|
-- | Check a password against a stored bcrypt hash when authenticating a user.
|
|
--
|
|
-- Returns @False@ if the password doesn't match the hash, or if the hash is
|
|
-- invalid or an unsupported version.
|
|
validatePassword :: (ByteArray password, ByteArray hash) => password -> hash -> Bool
|
|
validatePassword password bcHash = either (const False) id (validatePasswordEither password bcHash)
|
|
|
|
-- | Check a password against a bcrypt hash
|
|
--
|
|
-- As for @validatePassword@ but will provide error information if the hash is invalid or
|
|
-- an unsupported version.
|
|
validatePasswordEither :: (ByteArray password, ByteArray hash) => password -> hash -> Either String Bool
|
|
validatePasswordEither password bcHash = do
|
|
BCH version cost salt hash <- parseBCryptHash bcHash
|
|
return $ (rawHash version cost salt password :: Bytes) `B.constEq` hash
|
|
|
|
rawHash :: (ByteArrayAccess salt, ByteArray password, ByteArray output) => Char -> Int -> salt -> password -> output
|
|
rawHash _ cost salt password = B.take 23 hash -- Another compatibility bug. Ignore last byte of hash
|
|
where
|
|
hash = loop (0 :: Int) orpheanBeholder
|
|
|
|
loop i input
|
|
| i < 64 = loop (i+1) (encrypt ctx input)
|
|
| otherwise = input
|
|
|
|
-- Truncate the password if necessary and append a null byte for C compatibility
|
|
key = B.snoc (B.take 72 password) 0
|
|
|
|
ctx = expensiveBlowfishContext key salt cost
|
|
|
|
-- The BCrypt plaintext: "OrpheanBeholderScryDoubt"
|
|
orpheanBeholder = B.pack [79,114,112,104,101,97,110,66,101,104,111,108,100,101,114,83,99,114,121,68,111,117,98,116]
|
|
|
|
-- "$2a$10$XajjQvNhvvRt5GSeFk1xFeyqRrsxkhBkUiQeg0dt.wU1qD4aFDcga"
|
|
parseBCryptHash :: (ByteArray ba) => ba -> Either String BCryptHash
|
|
parseBCryptHash bc = do
|
|
unless (B.length bc == 60 &&
|
|
B.index bc 0 == dollar &&
|
|
B.index bc 1 == fromIntegral (ord '2') &&
|
|
B.index bc 3 == dollar &&
|
|
B.index bc 6 == dollar) (Left "Invalid hash format")
|
|
unless (version == 'b' || version == 'a' || version == 'y') (Left ("Unsupported minor version: " ++ [version]))
|
|
when (costTens > 3 || cost > 31 || cost < 4) (Left "Invalid bcrypt cost")
|
|
(salt, hash) <- decodeSaltHash (B.drop 7 bc)
|
|
return (BCH version cost salt hash)
|
|
where
|
|
dollar = fromIntegral (ord '$')
|
|
zero = ord '0'
|
|
costTens = fromIntegral (B.index bc 4) - zero
|
|
costUnits = fromIntegral (B.index bc 5) - zero
|
|
version = chr (fromIntegral (B.index bc 2))
|
|
cost = costUnits + 10*costTens :: Int
|
|
|
|
decodeSaltHash saltHash = do
|
|
let (s, h) = B.splitAt 22 saltHash
|
|
salt <- convertFromBase Base64OpenBSD s
|
|
hash <- convertFromBase Base64OpenBSD h
|
|
return (salt, hash)
|
|
|
|
-- | Create a key schedule for the BCrypt "EKS" version.
|
|
--
|
|
-- Salt must be a 128-bit byte array.
|
|
-- Cost must be between 4 and 31 inclusive
|
|
-- See <https://www.usenix.org/conference/1999-usenix-annual-technical-conference/future-adaptable-password-scheme>
|
|
expensiveBlowfishContext :: (ByteArrayAccess key, ByteArrayAccess salt) => key-> salt -> Int -> Context
|
|
expensiveBlowfishContext keyBytes saltBytes cost
|
|
| B.length saltBytes /= 16 = error "bcrypt salt must be 16 bytes"
|
|
| otherwise = unsafeDoIO $ do
|
|
ks <- createKeySchedule
|
|
expandKeyWithSalt ks keyBytes saltBytes
|
|
forM_ [1..2^cost :: Int] $ \_ -> do
|
|
expandKey ks keyBytes
|
|
expandKey ks saltBytes
|
|
freezeKeySchedule ks
|