The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
# -*- CPerl -*-
# $Id: passphrase_rules,v 1.6 2007/08/14 15:45:51 ajk Exp $

use strict;
use warnings;

use Data::Passphrase;
use Data::Passphrase::Graph::Roman;
use Data::Passphrase::Graph::Qwerty;
use Data::Passphrase::Phrasebook::Bloom;
use Carp;
use List::MoreUtils qw/any false/;
use Readonly;

# constants to be used in the rules
Readonly my $FAILING_SCORE             => 0.5;
Readonly my $LDAP_SERVER               => 'ldap.example.com';
Readonly my $MAXIMUM_TOTAL_CHARACTERS  => 127;
Readonly my $MINIMUM_TOTAL_CHARACTERS  => 15;
Readonly my $MINIMUM_UNIQUE_CHARACTERS => 4;
Readonly my $MINIMUM_UNIQUE_WORDS      => 4;
Readonly my $PHRASE_DICTIONARY         => 'examples/passphrase_deny';
Readonly my $STRONG_CHARACTERS         => 25;
Readonly my $STRONG_WORDS	       => 6;

# predictable pattern graphs
Readonly my $ALPHA_GRAPH  => Data::Passphrase::Graph::Roman->new();
Readonly my $QWERTY_GRAPH => Data::Passphrase::Graph::Qwerty->new();

# dictionary of common phrases
Readonly my $COMMON_PHRASEBOOK
    => Data::Passphrase::Phrasebook::Bloom->new({
        file => $PHRASE_DICTIONARY,
    });

# NOTE: This ruleset is NOT meant to be a complete passphrase policy.
# It's just here as an example.  I recommend developing your own
# passphrase policy and then codifying it here.

return [

    # Return "450 Passphrase is too short" for any passphrase shorter
    # than 15 characters.  The "validate" subroutine can use $_[0] as
    # a comparator because in numeric context it evaluates to the
    # length of the passphrase even though it's an Data::Passphrase
    # object.  The test data is just a string of 14 Xs -- the
    # passphrase-test script will check to make sure this string
    # results in a 450.
    #
    # The "validate" subroutine can return 0 or 1 to represent fail or
    # pass, as in
    #
    #   validate => sub { $_[0] >= $MINIMUM_TOTAL_CHARACTERS }
    #
    # or it can return any value in between to represent a score.  The
    # example below normalizes based on $STRONG_CHARACTERS such that a
    # barely passing score of 60% is given for 15 characters, and
    # anything above and including 25 characters is considered 100%.

    {
        code     => 450,
        message  => 'is too short',
        test     => {
            'X' x ($MINIMUM_TOTAL_CHARACTERS - 1) => { score => 0  },
            'to be is to do'                      => { score => 56 },
        },
        validate => sub { $_[0] / $STRONG_CHARACTERS },
    },

    # Same as above, but reject passphrases greater than 127
    # characters (the limit for Windows).

    {
        code     => 451,
        message  => 'is too long',
        test     => 'X' x ($MAXIMUM_TOTAL_CHARACTERS + 1),
        validate => sub { $_[0] <= $MAXIMUM_TOTAL_CHARACTERS },
    },

    # Some Unix systems default to using # for ERASE and @ for KILL,
    # even when passwords are being entered.  This rule has two test
    # passphrases (in an anonymous list) to test for both characters.

    {
        code     => 452,
        message  => 'may not contain # or @',
        test     => ['this passphrase contains #', '@ appears in this one'],
        validate => sub { $_[0] !~ /([#@])/ },
    },

    # Here's an example of a rule that doesn't test anything -- it
    # just performs some actions needed by later rules.  The
    # subsequent rules are word-based, so this rule splits the
    # passphrase into normalized words (for our own definition of
    # "word": something delimited by /[^a-z]+/) and stows them in
    # the object as both an array and a hash.  This rule should never
    # fail, so there are no tests and we return 1.

    {
        validate => sub {
            my ($self) = @_;

            # split into words
            my @word_list = split /[^a-z]+/i, $self->get_passphrase();

            # build unqiue list of words
            my %unique_word_hash = map { lc $_ => 1 } @word_list;
            $self->set_data(word_hash =>      \%unique_word_hash );
            $self->set_data(words     => [keys %unique_word_hash]);

            return 1;
        },
    },

    # The hash of user data is passed as the second argument.  Here we
    # use it to extract the list of unique words and compare its
    # length.

    {
        code     => 460,
        message  => 'contains too few unique words',
        test     => {
            'antidisestablishmentarianism' => { score => 16 },
            'two words two words'          => { score => 33 },
        },
        validate => sub { @{ $_[1]->{words} } / $STRONG_WORDS },
    },

    # A word must have at least 2 distinct characters.

    {
        code     => 461,
        message  => 'contains too few valid words',
        test     => { 'aaa bbb ccc ddd' => { score => 0 } },
        validate => sub {
            return ( false { /^(.)\1*$/ } @{ $_[1]->{words} } )
                   / $STRONG_WORDS;
        },
    },

    # Here's an example of checking for a predictable pattern.

    {
        code     => 463,
        message  => 'may not be based on the keyboard layout',
        test     => {
            'qaz xsw edc vfr'      => { score => 50 },
            'qwerty asdf jkl vcxz' => { score => 50 },
            'okm juh bgt rdx'      => { score => 50 },
        },
        validate => sub {
            return (any { !$QWERTY_GRAPH->has($_) } @{ $_[1]->{words} })
                   || $FAILING_SCORE;
        },
    },

    # This rule queries an LDAP server (using get_display_name(), not
    # provided) for a user's full name and disallows a passphrase
    # based on the name.  A subroutine is used to generate test
    # passphrases.

    {
        disabled => 1,
        code     => 464,
        message  => 'may not be based on your name',
        test     => sub {
            my ($self) = @_;

            my $username = $self->get_username() or return;

            my @full_name = split /,? /, get_display_name({
                ldap_server => $LDAP_SERVER,
                username    => $username,
            });

            return {
                "my username is $username"        => { score => 50 },
                "my last name is $full_name[0]"   => { score => 50 },
                "my first name is $full_name[1]"  => { score => 50 },
                "my middle name is $full_name[2]" => { score => 50 },
            };
        },
        validate => sub {
            my ($self, $data_hash) = @_;

            # unpack arguments
            my $debug      = $self->get_debug     ();
            my $username   = $self->get_username  () or return 1;
            my $passphrase = $self->get_passphrase();
            my $word_hash  = $data_hash->{word_hash};

            # quickly fail if username is a word
            if (exists $word_hash->{$username}) {
                $debug and warn "contains $username (username)";
                return $FAILING_SCORE;
            }

            # get display name
            my @full_name = map { lc } split /,? /, get_display_name({
                ldap_server => $LDAP_SERVER,
                username    => $username,
            });
            $debug and warn "full name: @full_name";

            # fail if any part of the full name is a word
            return any { exists $word_hash->{$_} } @full_name
                   ? $FAILING_SCORE
                   : 1
                   ;
        },
    },

    # Look up the passphrase in our phrasebook.

    {
        code     => 454,
        message  => 'is too common',
        test     => {
            'what a piece of work is man'          => { score => 50 },
            'a fool and his money are soon parted' => { score => 50 },
        },
        validate => sub {
            my ($input) = @_;

            # normalize for hash lookup
            my $comparison_key = lc $input;
            $comparison_key =~ s/[^a-z ]//gi;

            return $COMMON_PHRASEBOOK->has($comparison_key)
                   ? $FAILING_SCORE
                   : 1
                   ;
        },
    },
];