passman/src/Password.hs

381 lines
9.5 KiB
Haskell
Raw Normal View History

2018-12-06 22:03:38 -05:00
{-|
Module: Password
Description: a simple password manager
2021-01-05 21:08:41 -05:00
Copyright: (C) 2018-2021 Jonathan Lamothe
2018-12-06 22:03:38 -05:00
License: LGPLv3 (or later)
2020-12-14 22:41:24 -05:00
Maintainer: jonathan@jlamothe.net
2018-12-06 22:03:38 -05:00
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this program. If not, see
<https://www.gnu.org/licenses/>.
-}
2018-12-12 17:04:23 -05:00
{-# LANGUAGE TemplateHaskell #-}
2019-01-01 22:25:25 -05:00
{-# LANGUAGE OverloadedStrings #-}
2018-12-12 17:04:23 -05:00
2018-12-09 15:34:29 -05:00
module Password (
-- * Data Types
PWDatabase, PWData(..), PWPolicy (..), PWSalt (..),
2018-12-12 17:04:23 -05:00
-- ** Lenses
-- $lenses
2018-12-17 15:18:01 -05:00
-- *** PWData
pwPolicy, pwSalt,
-- *** PWPolicy
2018-12-12 17:38:10 -05:00
pwLength, pwUpper, pwLower, pwDigits, pwSpecial,
-- ** Default Instances
2018-12-18 14:41:29 -05:00
newPWDatabase, newPWData, newPWPolicy, newPWSalt,
2018-12-18 17:56:10 -05:00
-- ** Validations
2018-12-19 16:45:16 -05:00
validatePWDatabase, validatePWData, validatePWPolicy,
-- * Functions
-- ** Password Generator
pwGenerate,
-- ** Password Checkers
2018-12-25 16:09:32 -05:00
pwCountUpper, pwCountLower, pwCountDigits, pwCountSpecial, pwCount,
-- ** Database Functions
2018-12-31 14:09:13 -05:00
pwHasService, pwSetService, pwGetService, pwRemoveService, pwSearch
2018-12-09 15:34:29 -05:00
) where
2018-12-29 12:21:25 -05:00
import Data.Aeson
( FromJSON (parseJSON)
, ToJSON (toJSON)
, object
, withObject
, withText
, (.:)
, (.=)
)
2018-12-20 22:21:36 -05:00
import qualified Data.ByteString.Lazy as B
2019-01-02 13:38:29 -05:00
import qualified Data.ByteString.Lazy.Char8 as B8
import Data.ByteString.Builder (toLazyByteString, stringUtf8)
2019-01-01 22:45:03 -05:00
import qualified Data.ByteString.Base16.Lazy as B16
2018-12-21 17:32:06 -05:00
import qualified Data.ByteString.Base64.Lazy as B64
2018-12-25 15:47:39 -05:00
import Data.Char (isUpper, isLower, isDigit, isAlphaNum, toLower)
2018-12-21 17:04:37 -05:00
import Data.Digest.Pure.SHA
2018-12-17 15:18:01 -05:00
import qualified Data.Map as M
2018-12-12 18:41:34 -05:00
import Data.Maybe (fromMaybe)
2019-01-02 13:38:29 -05:00
import qualified Data.Text as T
import Lens.Micro (over, set, to, (^.))
import Lens.Micro.TH (makeLenses)
2018-12-17 16:09:40 -05:00
import System.Random (RandomGen, randoms, split)
2018-12-12 17:04:23 -05:00
2018-12-17 15:18:01 -05:00
-- | a mapping of service names to password data
type PWDatabase = M.Map String PWData
-- | data necessary to construct a password
data PWData = PWData
{ _pwPolicy :: PWPolicy
-- ^ the password policy
2018-12-18 17:56:10 -05:00
, _pwSalt :: PWSalt
2018-12-17 15:18:01 -05:00
-- ^ random data used to generate the password
} deriving (Eq, Show)
2018-12-09 15:34:29 -05:00
-- | defines a password policy
data PWPolicy = PWPolicy
{ _pwLength :: Int
-- ^ password length
, _pwUpper :: Int
-- ^ the minimum number of upper case characters
, _pwLower :: Int
-- ^ the minimum number of lower case characters
, _pwDigits :: Int
-- ^ the minimum number of digits
, _pwSpecial :: Maybe Int
-- ^ the minimum number of non-alphanumeric characters (not allowed
-- if @"Nothing"@)
} deriving (Eq, Show)
-- | the "salt" used to generate a password
newtype PWSalt = PWSalt { runPWSalt :: B.ByteString }
deriving (Eq, Show)
2018-12-12 17:04:23 -05:00
-- $lenses The following functions are automatically generated by
-- @makeLenses@. See the
-- [lens](http://hackage.haskell.org/package/lens) package for further
-- details.
makeLenses ''PWPolicy
2018-12-17 15:18:01 -05:00
makeLenses ''PWData
2018-12-12 17:04:23 -05:00
2018-12-29 12:21:25 -05:00
instance FromJSON PWData where
parseJSON = withObject "PWData" $ \v -> PWData
<$> v .: "policy"
<*> v .: "salt"
instance FromJSON PWPolicy where
parseJSON = withObject "PWPolicy" $ \v -> PWPolicy
<$> v .: "length"
<*> v .: "min_upper"
<*> v .: "min_lower"
<*> v .: "min_digits"
<*> v .: "min_special"
instance FromJSON PWSalt where
parseJSON = withText "PWSalt" $ \v ->
2019-01-02 13:38:29 -05:00
case B64.decode $ toUTF8 $ T.unpack v of
2018-12-29 12:21:25 -05:00
Left x -> fail x
Right x -> return $ PWSalt x
2018-12-29 12:21:25 -05:00
instance ToJSON PWData where
toJSON d = object
[ "policy" .= (d^.pwPolicy)
, "salt" .= (d^.pwSalt)
]
instance ToJSON PWPolicy where
toJSON p = object
[ "length" .= (p^.pwLength)
, "min_upper" .= (p^.pwUpper)
, "min_lower" .= (p^.pwLower)
, "min_digits" .= (p^.pwDigits)
, "min_special" .= (p^.pwSpecial)
]
instance ToJSON PWSalt where
toJSON = toJSON . toB64 . runPWSalt
2018-12-29 12:21:25 -05:00
2018-12-18 14:41:29 -05:00
-- | default (empty) password database
newPWDatabase :: PWDatabase
newPWDatabase = M.empty
2018-12-18 14:04:45 -05:00
-- | builds a new @'PWData'@
newPWData
:: RandomGen g
=> g
-- ^ the random generator to use
-> (PWData, g)
-- ^ the result and new random generator
newPWData g = (result, g') where
result = PWData newPWPolicy salt
(salt, g') = newPWSalt g
-- | default password policy
newPWPolicy :: PWPolicy
newPWPolicy = PWPolicy 16 0 0 0 (Just 0)
2018-12-18 14:04:45 -05:00
2018-12-17 16:09:40 -05:00
-- | builds a new salt
newPWSalt
2018-12-17 16:09:40 -05:00
:: RandomGen g
=> g
-- ^ the random generator to use
-> (PWSalt, g)
-- ^ the result and new random generator
newPWSalt g = (result, g2) where
result = PWSalt $ B.pack $ take 32 $ randoms g1
2018-12-17 16:09:40 -05:00
(g1, g2) = split g
2018-12-19 10:36:28 -05:00
-- | validates a password database
validatePWDatabase
:: PWDatabase
-- ^ the database to be validated
-> Bool
-- ^ @"True"@ if valid; @"False"@ otherwise
validatePWDatabase = all validatePWData
2018-12-18 17:56:10 -05:00
-- | validates password data
validatePWData
:: PWData
-- ^ the data to be validated
-> Bool
-- ^ @"True"@ if valid; @"False"@ otherwise
validatePWData x =
validatePWPolicy (x^.pwPolicy) &&
B.length (x^.pwSalt.to runPWSalt) > 0
2018-12-18 17:56:10 -05:00
2018-12-12 18:41:34 -05:00
-- | validates a password policy
validatePWPolicy
:: PWPolicy
-- ^ the policy being validated
2018-12-13 11:15:26 -05:00
-> Bool
-- ^ indicates whether or not the policy is valid
2018-12-17 00:04:43 -05:00
validatePWPolicy x = and
2018-12-13 11:48:37 -05:00
[ needed <= x^.pwLength
, x^.pwLength >= 0
, x^.pwUpper >= 0
, x^.pwLower >= 0
, x^.pwDigits >= 0
, fromMaybe 0 (x^.pwSpecial) >= 0
] where
needed = x^.pwUpper + x^.pwLower + x^.pwDigits + special
special = fromMaybe 0 $ x^.pwSpecial
2018-12-12 18:41:34 -05:00
2018-12-19 16:45:16 -05:00
-- | generates a password
pwGenerate
:: String
-- ^ the master password
-> PWData
-- ^ the password parameters
-> Maybe String
-- ^ the resulting password, if possible; @"Nothing"@ if the data is
-- invalid
2018-12-19 16:47:02 -05:00
pwGenerate pw d = if validatePWData d
then Just $ mkPass (mkPool seed) (d^.pwPolicy)
else Nothing
where seed = mkSeed pw d
2018-12-19 16:45:16 -05:00
2018-12-20 22:15:43 -05:00
-- | counts upper case characters in a password
pwCountUpper
:: String
-- ^ the password
-> Int
-- ^ the count
2018-12-25 16:09:32 -05:00
pwCountUpper = pwCount isUpper
2018-12-20 22:15:43 -05:00
2018-12-19 16:45:16 -05:00
-- | counts lower case characters in a password
pwCountLower
:: String
-- ^ the password
-> Int
-- ^ the count
2018-12-25 16:09:32 -05:00
pwCountLower = pwCount isLower
2018-12-19 16:45:16 -05:00
-- | counts digits in a password
pwCountDigits
:: String
-- ^ the password
-> Int
-- ^ the count
2018-12-25 16:09:32 -05:00
pwCountDigits = pwCount isDigit
2018-12-19 16:45:16 -05:00
-- | counts special characters in a password
pwCountSpecial
:: String
-- ^ the password
-> Int
-- ^ the count
2018-12-25 16:09:32 -05:00
pwCountSpecial = pwCount isSpecial
-- | counts characters matching a specific constraint
pwCount
:: (Char -> Bool)
-- ^ the constraint
-> String
-- ^ the string being checked
-> Int
-- ^ the count
pwCount f = length . filter f
2018-12-19 16:45:16 -05:00
-- | checks to see if a service is in the database
pwHasService
:: String
-- ^ the service name
-> PWDatabase
-- ^ the database to check
-> Bool
-- ^ returns @"True"@ if found; @"False"@ otherwise
2018-12-24 10:53:52 -05:00
pwHasService x db = elem x $ M.keys db
-- | adds a service to the database, or overwrites an existing one
pwSetService
:: String
-- ^ the service name
-> PWData
-- ^ the password data for the service
-> PWDatabase
-- ^ the database to add to
-> PWDatabase
-- ^ the resulting database
2018-12-24 11:55:49 -05:00
pwSetService = M.insert
-- | attempts to get a service from the database
pwGetService
:: String
-- ^ the service name
-> PWDatabase
-- ^ the database to check
-> Maybe PWData
-- ^ the service's password data, or @"Nothing"@ if the service is
-- not found
2018-12-24 12:22:54 -05:00
pwGetService = M.lookup
2018-12-31 14:09:13 -05:00
-- | removes a service from the database
pwRemoveService
:: String
-- ^ the service being removed
-> PWDatabase
-- ^ the database the service is being removed from
-> PWDatabase
-- ^ the resulting database
pwRemoveService = M.delete
-- | searches for a service
pwSearch
:: String
-- ^ the search string
-> PWDatabase
-- ^ the database to search
-> [String]
-- ^ the matching service names
2018-12-25 15:47:39 -05:00
pwSearch x db = filter (\y -> l y `contains` l x) $ M.keys db where
l = map toLower
2018-12-20 22:15:43 -05:00
isSpecial :: Char -> Bool
2018-12-25 16:10:13 -05:00
isSpecial = not . isAlphaNum
2018-12-19 16:45:16 -05:00
2018-12-19 16:47:02 -05:00
mkPass :: String -> PWPolicy -> String
2019-01-01 22:50:16 -05:00
mkPass [] _ = "" -- this should never happen
mkPass (x:xs) p = if p^.pwLength <= 0
2018-12-19 16:47:02 -05:00
then ""
2019-01-01 22:50:16 -05:00
else let p' = nextPolicy x p in
if validatePWPolicy p'
then x : mkPass xs p'
else mkPass xs p
2018-12-19 16:47:02 -05:00
mkPool :: B.ByteString -> String
2018-12-20 21:48:02 -05:00
mkPool = toB64 . raw where
raw x = let x' = mkHash x in
2019-01-01 04:48:36 -05:00
x' `B.append` raw x'
2018-12-19 16:47:02 -05:00
mkSeed :: String -> PWData ->B.ByteString
mkSeed pw d = toUTF8 pw `B.append` (d^.pwSalt.to runPWSalt)
2018-12-19 16:47:02 -05:00
mkHash :: B.ByteString -> B.ByteString
2019-01-02 13:38:29 -05:00
mkHash = fst . B16.decode . toUTF8 . show . sha256
2018-12-19 16:47:02 -05:00
nextPolicy :: Char -> PWPolicy -> PWPolicy
nextPolicy x p = over pwLength pred $
2018-12-20 22:02:24 -05:00
if isUpper x
then dec pwUpper
else if isLower x
then dec pwLower
else if isDigit x
then dec pwDigits
else case p^.pwSpecial of
Nothing -> set pwSpecial (Just (-1)) p
Just _ -> dec $ pwSpecial . traverse
2018-12-20 22:02:24 -05:00
where
dec l = over l (max 0 . pred) p
2018-12-19 16:47:02 -05:00
toUTF8 :: String -> B.ByteString
2019-01-02 13:38:29 -05:00
toUTF8 = toLazyByteString . stringUtf8
2018-12-19 16:47:02 -05:00
toB64 :: B.ByteString -> String
2019-01-02 13:38:29 -05:00
toB64 = B8.unpack . B64.encode
2018-12-19 16:47:02 -05:00
2018-12-25 15:47:39 -05:00
contains :: String -> String -> Bool
_ `contains` "" = True
"" `contains` _ = False
xs@(_:xs') `contains` ys
2018-12-25 15:47:39 -05:00
| xs `startsWith` ys = True
| otherwise = xs' `contains` ys
startsWith :: String -> String -> Bool
_ `startsWith` "" = True
"" `startsWith` _ = False
(x:xs) `startsWith` (y:ys)
| x == y = xs `startsWith` ys
| otherwise = False
2018-12-09 15:34:29 -05:00
--jl