From b72e22363265aefc16265ec0fa4f4670cbb3b176 Mon Sep 17 00:00:00 2001 From: Sean D Gillespie Date: Sat, 10 Feb 2024 22:06:12 -0500 Subject: [PATCH] feature(lib): Add a transformation pass * to lower case * capitalization * numerization --- src/Data/Gibberish/GenPass.hs | 114 ++++++++++++++++++++++++++++- test/Data/Gibberish/GenPassSpec.hs | 66 ++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/Data/Gibberish/GenPass.hs b/src/Data/Gibberish/GenPass.hs index ef53eec..09fc909 100644 --- a/src/Data/Gibberish/GenPass.hs +++ b/src/Data/Gibberish/GenPass.hs @@ -1,16 +1,24 @@ {-# LANGUAGE OverloadedLists #-} +{-# LANGUAGE ScopedTypeVariables #-} module Data.Gibberish.GenPass ( genPassword, + numeralConversions, ) where import Data.Gibberish.MonadPass (MonadRandom ()) import Data.Gibberish.Types -import Control.Monad.Random (fromList, fromListMay, uniform) +import Control.Arrow ((>>>)) +import Control.Monad ((>=>)) +import Control.Monad.Random (MonadRandom (..), fromList, fromListMay, uniform) import Control.Monad.Trans.Maybe (MaybeT (..), hoistMaybe) import Data.Bifunctor (bimap, second) +import Data.Char (toLower, toUpper) +import Data.Map (Map ()) import Data.Map qualified as Map +import Data.Maybe (fromMaybe) +import Data.Ratio import Data.Text (Text ()) import Data.Text qualified as Text import Prelude hiding (Word) @@ -24,12 +32,20 @@ genPassword opts@GenPassOptions {..} -- | Generates a password with the given options. Assumes optsLength is at least 3. genPassword' :: MonadRandom m => GenPassOptions -> m Word genPassword' opts@(GenPassOptions {..}) = do + -- Select the first two characters f2 <- first2 opts + -- Select the rest of the characters rest <- lastN opts (optsLength - 2) f2 - + -- Construct the full password from f2 and rest let pass = digramToText f2 `Text.append` Text.reverse rest - pure (Word pass) + -- Apply transformations in order + let transform = + Text.map toLower + >>> capitalize opts + >=> digitize opts + + Word <$> transform pass digramToText :: Digram -> Text digramToText (Digram a b) = [a, b] @@ -65,3 +81,95 @@ next GenPassOptions {..} digram = do nextDefault :: MonadRandom m => m Char nextDefault = uniform (['a' .. 'z'] :: [Char]) + +-- | Randomly capitalize at least 1 character. Additional characters capitalize +-- at a probability of 1/12 +capitalize :: MonadRandom m => GenPassOptions -> Text -> m Text +capitalize opts@GenPassOptions {..} t + | optsCapitals = capitalizeR =<< capitalize1 opts t + | otherwise = pure t + +-- | Randomly capitalize 1 character +capitalize1 :: MonadRandom m => GenPassOptions -> Text -> m Text +capitalize1 GenPassOptions {..} t = + update1 (pure . toUpper) t =<< getRandomR (0, optsLength - 1) + +capitalizeR :: MonadRandom m => Text -> m Text +capitalizeR = updateR (pure . toUpper) (1 % 12) + +digitize :: MonadRandom m => GenPassOptions -> Text -> m Text +digitize opts t + | optsDigits opts = digitizeR =<< digitize1 opts t + | otherwise = pure t + +digitize1 :: MonadRandom m => GenPassOptions -> Text -> m Text +digitize1 _ t + | null candidates = pure t + | otherwise = digitize1' =<< uniform candidates + where + candidates :: [Int] + candidates = findIndices (`elem` Map.keys numeralConversions) t + digitize1' :: MonadRandom m => Int -> m Text + digitize1' pos = update1 (uniform . toDigit) t pos + +digitizeR :: MonadRandom m => Text -> m Text +digitizeR = updateR (uniform . toDigit) (1 % 6) + +-- | A mapping from letters to numbers that look like them +numeralConversions :: Map Char [Char] +numeralConversions = + Map.fromList + [ ('o', ['0']), + ('l', ['1']), + ('z', ['2']), + ('e', ['3']), + ('a', ['4']), + ('s', ['5']), + ('g', ['6', '9']), + ('t', ['7']), + ('b', ['8']) + ] + +-- | Map a letter to one or more digits, if possible +toDigit :: Char -> [Char] +toDigit c = fromMaybe [c] (numeralConversions Map.!? c) + +-- | /O(n)/ The 'findIndices' function extends 'findIndex', by returning the +-- indices of all elements satisfying the predicate, in ascending order. +findIndices :: (Char -> Bool) -> Text -> [Int] +findIndices p = loop 0 + where + loop !n !qs = case Text.findIndex p qs of + Just !i -> + let !j = n + i + in j : loop (j + 1) (Text.drop (i + 1) qs) + Nothing -> [] +{-# INLINE [1] findIndices #-} + +update1 :: Monad m => (Char -> m Char) -> Text -> Int -> m Text +update1 f t pos = + case Text.splitAt pos t of + (prefix, suffix) -> + case Text.uncons suffix of + Nothing -> pure t + Just (ch, suffix') -> do + ch' <- f ch + pure $ prefix `Text.append` (ch' `Text.cons` suffix') + +updateR :: MonadRandom m => (Char -> m Char) -> Rational -> Text -> m Text +updateR f prob t = textTraverse updateR' t + where + updateR' ch = do + ch' <- f ch + fromList + [ (ch, toRational $ denominator prob), + (ch', toRational $ numerator prob) + ] + +textTraverse :: Monad m => (Char -> m Char) -> Text -> m Text +textTraverse f = Text.foldr folder (pure Text.empty) + where + folder c accum = do + accum' <- accum + c' <- f c + pure $ Text.cons c' accum' diff --git a/test/Data/Gibberish/GenPassSpec.hs b/test/Data/Gibberish/GenPassSpec.hs index c0345ef..f878a1c 100644 --- a/test/Data/Gibberish/GenPassSpec.hs +++ b/test/Data/Gibberish/GenPassSpec.hs @@ -1,14 +1,19 @@ module Data.Gibberish.GenPassSpec (spec) where -import Data.Gibberish.GenPass (genPassword) +import Data.Gibberish.GenPass (genPassword, numeralConversions) import Data.Gibberish.MonadPass (usingPass) import Data.Gibberish.Trigraph (Language (..), loadTrigraph) import Data.Gibberish.Types (GenPassOptions (..), Word (..)) import Test.Gibberish.Gen qualified as Gen import Control.Monad.IO.Class (liftIO) +import Data.Char +import Data.Map (Map ()) +import Data.Map qualified as Map import Data.Text qualified as Text import Hedgehog +import Hedgehog.Gen qualified as Gen +import Hedgehog.Range qualified as Range import Test.Hspec import Test.Hspec.Hedgehog import Prelude hiding (Word) @@ -27,3 +32,62 @@ spec = do annotateShow pass Text.length pass === optsLength opts + + it "has only lowercase when capitals is false" $ hedgehog $ do + trigraph <- liftIO $ loadTrigraph English + opts <- forAll Gen.genPassOptions + randomGen <- forAll Gen.stdGen + -- Only consider passwords of sufficient (>=3) length + len <- forAll (Gen.int $ Range.linear 3 15) + + let opts' = + opts + { optsTrigraph = trigraph, + optsCapitals = False, + optsLength = len + } + + let (Word pass, _) = usingPass randomGen (genPassword opts') + annotateShow pass + + assert $ Text.all (not . isUpperCase) pass + + it "has at least one capital when enabled" $ hedgehog $ do + trigraph <- liftIO $ loadTrigraph English + opts <- forAll Gen.genPassOptions + randomGen <- forAll Gen.stdGen + -- Only consider passwords of sufficient (>=3) length + len <- forAll (Gen.int $ Range.linear 3 15) + + let opts' = + opts + { optsTrigraph = trigraph, + optsCapitals = True, + optsLength = len + } + + let (Word pass, _) = usingPass randomGen (genPassword opts') + annotateShow pass + + assert $ Text.any (\c -> isUpperCase c || isPunctuation c) pass + + it "has at least one digit when enabled" $ hedgehog $ do + trigraph <- liftIO $ loadTrigraph English + opts <- forAll Gen.genPassOptions + randomGen <- forAll Gen.stdGen + -- Only consider passwords of sufficient (>=3) length + len <- forAll (Gen.int $ Range.linear 3 15) + + let opts' = + opts + { optsTrigraph = trigraph, + optsDigits = True, + optsLength = len + } + + let (Word pass, _) = usingPass randomGen (genPassword opts') + annotateShow pass + + assert $ + Text.any (\c -> isNumber c) pass + || Text.all (`Map.notMember` numeralConversions) pass