June 21, 2016

Another Password Generator with Spec

A while ago, I tinkered with using data.generators and test.check for generating random data. Here's a link with more info , and here's an example in clojurescript.

A few months ago it was announced that clojure 1.9 will have a new core library called clojure.spec.

I'm just wrapping my brain around it, but clojure.spec seems really useful.

This post is a description of how I created a spec for a random password generator.

For this example, here are the requirements for the passwords we'll be spec'ing:

  • Must contain at least 2 lowercase letters
  • Must contain at least 2 uppercase letters
  • Must contain at least 2 digits
  • Must contain at least 2 of the following symbols: !, $, ^, &
  • Must be at least 10 chars long but no longer than 15

Spec Functions

A spec is simply a function that takes one argument and returns true or false, a.k.a, a predicate function.

So, let's try to create a spec for the first lowercase letter rule, shall we?

(s/def ::two-lowers #(re-matches #".*[a-z]+.*[a-z]+.*" %))
(s/valid? ::two-lowers "1234")   ;;=> false
(s/valid? ::two-lowers "12b34a") ;;=> true

What's really cool is that these spec functions can be used to create generator functions using s/gen. So in theory, once you've written a spec function, you'll get a generator function for free!

(gen/generate (s/gen ::two-lowers))
;;=> clojure.lang.ExceptionInfo: Unable to construct gen at: [] for: :boot.user/two-lowers

Oops, hmm, that didn't work. So, as you can see, s/gen can't always figure out how to create a generator function from a spec. It really only understands how to randomly generate primitive data. So, in this case, by hinting that the password should always be a string, s/gen is then able to start cranking out random strings and eventually can find one that satisfies the spec:

(s/def ::two-lowers
  (s/and string? #(re-matches #".*[a-z]+.*[a-z]+.*" %)))
(gen/generate (s/gen ::two-lowers))
;;=> "lErTQG3Jb26d4575S46VV8"

Nice! A string with at least 2 lower case letters. Pretty cool, right?

Here's another way I came up with to write the same spec. I like this version better because it has the added bonus of ensuring that the two lower case letters chosen are unique.

(def char-lower? (into #{} (map char (range 97 122))))

(s/def ::two-lowers
  (s/and string? #(<= 2 (count (filter char-lower? (into #{} %))))))
  
(gen/generate (s/gen ::two-lowers))
;;=> "9ob4Kej25Rz6259CD"

Ok, that looks nice. Next we need to write specs for the rest of the rules:

(def char-upper? (into #{} (map char (range 65 91))))

(s/def ::two-uppers
  (s/and string? #(<= 2 (count (filter char-upper? (into #{} %))))))
  
(s/valid? ::two-uppers "AB")        ;;=> true

(gen/generate (s/gen ::two-uppers)) ;;=> "zjxjglIn282U3HNjZg"


(def char-digit? (into #{} (map char (range 48 58))))

(s/def ::two-digits
  (s/and string? #(<= 2 (count (filter char-digit? (into #{} %))))))
  
(s/valid? ::two-digits "12")        ;;=> true

(gen/generate (s/gen ::two-digits)) ;;=> "fWC10nU0C80IvJ0a3fjR6Z6LVV4f"


(def char-symbol? #{\! \$ \^ \&})

(s/def ::two-symbols
  (s/and string? #(<= 2 (count (filter char-symbol? (into #{} %))))))
  
(s/valid? ::two-symbols "$!")        ;;=> true

But, oh no! Look what happens when I try to generate examples data from the ::two-symbols spec:

(gen/generate (s/gen ::two-symbols))
;;=> clojure.lang.ExceptionInfo: Couldn't satisfy such-that predicate after 100 tries.

The generator function created from s/gen tried 100 times to generate a string that matches this spec, but couldn't do it.

When this happens, it means the basic, primitive generators that s/gen has access too just won't cut it. We need to write our own custom generator.

Personally, I've found writing generators is a little bit of a challenge. You have to think a little differently than when writing "normal" functions. The best way to get better at it (for me, anyway) is just to try writing a bunch of them.

Here's what I came up with:

(defn gen-two-symbols []
  (gen/fmap (fn [[a b]] (apply str (shuffle (concat a b))))
   (gen/tuple (gen/vector-distinct (gen/elements char-symbol?) 
                                   {:min-elements 2}) 
              (gen/vector (gen/char-alphanumeric)))))
              
(gen/generate (gen-two-symbols)) ;;=> "w$IhRo493&DNp!Sd40^8"

Next, this custom generator can be registered with ::two-symbols via the with-gen function:

(s/def ::two-symbols
  (s/with-gen
    (s/and string? #(<= 2 (count (filter char-symbol? (into #{} %)))))
    gen-two-symbols))
    
(gen/generate (s/gen ::two-symbols)) ;;=> "J8I^H&n$Tk0k!i"

Sweet!

We're heading down the home stretch.

Next, I used the s/int-in-range function to create a spec which ensures that the length of the password is between 10 and 15 characters long.

(s/def ::10-to-15-chars (s/and string? #(s/int-in-range? 10 16 (count %))))

(s/valid? ::10-to-15-chars "012345678")  ;;=> false
(s/valid? ::10-to-15-chars "0123456789") ;;=> true
(gen/generate (s/gen ::10-to-15-chars))  ;;=> "LyJ5BU9r57Vr3H"

I love how those specs read almost like they would be spoken out loud.

It's time to combine all these into one spec for the password and hold our breath!

(s/def ::password
  (s/and ::two-lowers
         ::two-uppers
         ::two-digits
         ::two-symbols
         ::10-to-15-chars))
(s/valid? ::password "abCD12$!34") ;;=> true

Woohoo!

But wait. Look a this:

(gen/generate (s/gen ::password))
clojure.lang.ExceptionInfo: Couldn't satisfy such-that predicate after 100 tries.

That's a bummer. But we know how to fix that, right? Yep, another custom generator.

(defn gen-chars [& args]
  (apply gen/vector
         (concat [(gen/one-of [(gen/elements char-lower?)
                               (gen/elements char-upper?)
                               (gen/elements char-digit?)
                               (gen/elements char-symbol?)])] args)))
                               
(defn gen-password []
  (gen/fmap (fn [[a b c d e]] 
              (let [;; make sure we satisfy the rules
                    rules (concat a b c d) 
                    ;; replace first chars in random string with rules
                    end   (concat rules (subvec e (- (count rules) 1)))]
                (apply str (shuffle end))))
            (gen/tuple (gen/vector-distinct (gen/elements char-lower?) 
                                            {:num-elements 2})
                       (gen/vector-distinct (gen/elements char-upper?) 
                                            {:num-elements 2})
                       (gen/vector-distinct (gen/elements char-digit?) 
                                            {:num-elements 2})
                       (gen/vector-distinct (gen/elements char-symbol?) 
                                            {:num-elements 2})
                       ;; generate between 10 and 15 chars
                       (gen-chars 10 15))))

That generator is pretty big and intimidating, so here's some explanation:

The gen-chars is a helper generator to generate lists of valid characters.

Then, in gen-password, I first generate 2 of each of the required types of characters (which results in 8 characters that I bind to a variable rules), then I generate a random string of 10-15 chars using gen-chars which I bind to a variable end. The final step is to replace the first 8 chars in end with the 8 chars from rules string and shuffle them all up.

Now we can associate this custom generator with ::password for the grand finale.

(s/def ::password
  (s/with-gen 
    (s/and ::two-lowers
           ::two-uppers
           ::two-digits
           ::two-symbols
           ::10-to-15-chars)
    gen-password))

(gen/generate (s/gen ::password))
;;=> "^22T$^cCM!Yv$5^"

(s/valid? ::password (gen/generate (s/gen ::password)))
;;=> true

How cool is that?!

Next up: I want to implement this as a clojurescript widget and also learn more about how to use clojure.spec.test to run tests against specs such as this one.

Tags: clojure clojurescript