Skip to content
Browse files

Initial commit.

Adds the basic password functionalities, tests, documentation
and type specs.
  • Loading branch information...
0 parents commit 8cf4a5e8f6719fc7aa8c92a976b4584c4bdcab8c @ferd committed Sep 9, 2011
Showing with 211 additions and 0 deletions.
  1. +9 −0 .gitignore
  2. +15 −0 doc/overview.edoc
  3. +7 −0 ebin/erlpass.app
  4. +4 −0 rebar.config
  5. +78 −0 src/erlpass.erl
  6. +98 −0 test/erlpass_tests.erl
9 .gitignore
@@ -0,0 +1,9 @@
+*.swp
+*.beam
+*.dump
+deps/*
+doc/*.html
+doc/*.css
+doc/*.png
+doc/edoc-info
+.eunit/*
15 doc/overview.edoc
@@ -0,0 +1,15 @@
+ ** this is the overview.doc file for the application 'erlpass' **
+@author Fred Hebert <mononcqc@gmail.com> [http://ferd.ca]
+@version 0.1
+@title Safe password management (without backend)
+@doc
+The idea behind the library is to handle password hashing and changing in a
+safe manner, independent from any kind of storage whatsoever.
+
+The library is a thin wrapper around the erlang-bcrypt library from smarkets,
+handling special cases such as unicode passwords, and forcing hashes in binary.
+Moreover, the library takes care of providing common operations such as
+matching passwords, changing the work factor of a hash, or changing a password
+as a whole.
+@end
+
7 ebin/erlpass.app
@@ -0,0 +1,7 @@
+{application, erlpass,
+ [{description, "Safely handle passwords with bcrypt and Erlang"},
+ {vsn, "0.0.1"},
+ {modules, [erlpass]},
+ {applications, [bcrypt]},
+ {agner, [{requires, ["bcrypt","proper"]}]}
+]}.
4 rebar.config
@@ -0,0 +1,4 @@
+{lib_dirs, ["deps"]}.
+
+{deps, [{bcrypt, "0.4.0", {git, "git://github.com/smarkets/erlang-bcrypt.git", {branch, "master"}}},
+ {proper, "1.0", {git, "git://github.com/manopapad/proper.git", {branch, "master"}}}]}.
78 src/erlpass.erl
@@ -0,0 +1,78 @@
+%% @author Fred Hebert <mononcqc@gmail.com> [http://ferd.ca/]
+%% @doc Erlpass is a simple wrapper library trying to abstract away common
+%% password operations using safe algorithms, in this case, bcrypt.
+-module(erlpass).
+-export([hash/1, hash/2, match/2, change/3, change/4]).
+-define(DEFAULT_WORK_FACTOR, 12).
+
+%% @type password() = binary() | list() | iolist(). A password, supports valid unicode.
+-type password() :: binary() | list() | iolist().
+%% @type work_factor() = 4..31. Work factor of the bcrypt algorithm
+-type work_factor() :: 4..31.
+%% @type hash() = binary(). The hashed password with a given work factor.
+-type hash() :: binary().
+
+-export_type([password/0, work_factor/0, hash/0]).
+
+%% @doc Similar to {@link hash/2. <code>hash(Password, 12)</code>}.
+-spec hash(password()) -> hash().
+hash(S) when is_binary(S); is_list(S) -> hash(S, ?DEFAULT_WORK_FACTOR).
+
+
+%% @doc Hashes a given {@link password(). <code>password</code>} with a given
+%% {@link work_factor(). work factor}. Bcrypt will be used to create
+%% a {@link hash(). hash} of the password to be stored by the application.
+%% Compare the password to the hash by using {@link match/2. <code>match/2</code>}.
+%% Bcrypt takes care of salting the hashes for you so this does not need to be
+%% done. The higher the work factor, the longer the password will take to be
+%% hashed and checked.
+-spec hash(password(), work_factor()) -> hash().
+hash(Str, Factor) ->
+ {ok, Hash} = bcrypt:hashpw(format_pass(Str), element(2, bcrypt:gen_salt(Factor))),
+ list_to_binary(Hash).
+
+%% @doc Compares a given password to a hash. Returns <code>true</code> if
+%% the password matches, and <code>false</code> otherwise.
+-spec match(password(), hash()) -> boolean().
+match(Pass, Hash) ->
+ LHash = binary_to_list(Hash),
+ case bcrypt:hashpw(format_pass(Pass), LHash) of
+ {ok, LHash} -> true;
+ {ok, _} -> false
+ end.
+
+%% @doc If a given {@link password(). password} matches a given
+%% {@link hash(). hash}, the password is re-hashed again using
+%% the new {@link work_factor(). work factor}. This allows to update a
+%% given work factor to something stronger.
+%% Equivalent to {@link change/4. <code>change(Pass, Hash, Pass, Factor)</code>}.
+-spec change(password(), hash(), work_factor()) -> hash() | {error, bad_password}.
+change(Pass, Hash, Factor) ->
+ change(Pass, Hash, Pass, Factor).
+
+%% @doc If a given old {@link password(). password} matches a given old
+%% {@link hash(). hash}, a new {@link password(). password} is hashed using the
+%% {@link work_factor(). work factor} passed in as an argument.
+%% Allows to safely change a password, only if the previous one was given
+%% with it.
+-spec change(password(), hash(), password(), work_factor()) -> hash() | {error, bad_password}.
+change(OldPass, Hash, NewPass, Factor) ->
+ case match(OldPass, Hash) of
+ true -> hash(NewPass, Factor);
+ false -> {error, bad_password}
+ end.
+
+%%% PRIVATE
+%% This 'list_to_binary' stuff is risky -- no idea what the implementation
+%% is like.
+%% We have to support unicode
+%% @doc transforms a given {@link password(). password} in a safe binary format
+%% that can be understood by the bcrypt library.
+-spec format_pass(iolist()) -> binary().
+format_pass(Str) when is_list(Str) ->
+ case unicode:characters_to_binary(Str) of
+ {error, _Good, _Bad} -> list_to_binary(Str);
+ {incomplete, _Good, _Bad} -> list_to_binary(Str);
+ Bin -> Bin
+ end;
+format_pass(Bin) when is_binary(Bin) -> Bin.
98 test/erlpass_tests.erl
@@ -0,0 +1,98 @@
+-module(erlpass_tests).
+-include_lib("proper/include/proper.hrl").
+-include_lib("eunit/include/eunit.hrl").
+-define(PROPMOD, proper).
+-define(PROP(A), {timeout, 45, ?_assert(?PROPMOD:quickcheck(A(), [{max_shrinks,15}]))}).
+
+-define(LIGHT_WORK_FACTOR, 4).
+-define(MEDIUM_WORK_FACTOR, 9).
+-define(NORMAL_WORK_FACTOR, 12).
+-define(HEAVY_WORK_FACTOR, 31).
+
+%% EUNIT TESTS
+setup() ->
+ application:start(crypto),
+ application:start(bcrypt).
+
+good_default_work_factor_test_() ->
+ {"The default work factor should be high enough to be safe (usually ~12)",
+ {setup, fun setup/0,
+ [?_assert(element(1, timer:tc(erlpass, hash, ["abc", ?LIGHT_WORK_FACTOR])) <
+ element(1, timer:tc(erlpass, hash, ["abc"])))]}}.
+
+proper_test_() ->
+ {"Run all property-based tests",
+ {setup, fun setup/0,
+ [?PROP(prop_support_str_formats), ?PROP(prop_hashed), ?PROP(prop_match),
+ ?PROP(prop_change_work_factor), ?PROP(prop_change_password),
+ ?PROP(prop_change_error)]}}.
+
+%%% PROPERTY-BASED TESTS
+prop_support_str_formats() ->
+ ?FORALL(S, password(),
+ begin process_flag(trap_exit, true),
+ try erlpass:hash(S, ?LIGHT_WORK_FACTOR) of
+ _ -> true
+ catch
+ error:function_clause -> false;
+ A:B -> io:format(user, "failure: ~p~n",[{S,A,B}]), false
+ end end).
+
+prop_hashed() ->
+ ?FORALL(S, password(),
+ %% check for bcrypt encoding. Contains versions and whatnot. Defaults to list
+ begin process_flag(trap_exit, true),
+ case catch erlpass:hash(S, ?LIGHT_WORK_FACTOR) of
+ <<"$2$", Rest/binary>> when Rest =/= <<>> -> true;
+ <<"$2a$", Rest/binary>> when Rest =/= <<>> -> true;
+ Hash -> io:format("~p~n",[{err, S, Hash}]), false
+ end end).
+
+prop_match() ->
+ ?FORALL(S, password(),
+ begin
+ Hash = erlpass:hash(S, ?LIGHT_WORK_FACTOR),
+ erlpass:match(S, Hash) andalso
+ not erlpass:match(S, <<Hash/binary, $a>>)
+ end).
+
+prop_change_work_factor() ->
+ ?FORALL(S, password(),
+ begin
+ Hash1 = erlpass:hash(S, ?LIGHT_WORK_FACTOR),
+ Hash2 = erlpass:change(S, Hash1, ?MEDIUM_WORK_FACTOR),
+ erlpass:match(S, Hash1) andalso erlpass:match(S, Hash2)
+ end).
+
+prop_change_password() ->
+ ?FORALL(S1, password(),
+ begin
+ S2 = ["new"|S1],
+ Hash1 = erlpass:hash(S1, ?LIGHT_WORK_FACTOR),
+ Hash2 = erlpass:change(S1, Hash1, S2, ?LIGHT_WORK_FACTOR),
+ erlpass:match(S1, Hash1) andalso erlpass:match(S2, Hash2) andalso
+ not erlpass:match(S2, Hash1) andalso not erlpass:match(S1, Hash2)
+ end).
+
+prop_change_error() ->
+ ?FORALL(S, password(),
+ begin
+ FailPass = ["fail"|S],
+ Hash = erlpass:hash(S, ?LIGHT_WORK_FACTOR),
+ erlpass:match(S, Hash) andalso
+ not erlpass:match(FailPass, Hash) andalso
+ {error, bad_password} =:= erlpass:change(FailPass, Hash, ?LIGHT_WORK_FACTOR)
+ end).
+
+%%% GENERATORS
+password() ->
+ ?LAZY(oneof([unicode_string(), binary(), [password1()]])).
+
+password1() ->
+ ?LAZY(oneof([binary(),
+ byte(),
+ password1()])).
+
+unicode_string() ->
+ ?SUCHTHAT(X, list(char()),
+ lists:all(fun(Y) -> Y < 16#D800 orelse Y > 16#DFFF end, X)).

0 comments on commit 8cf4a5e

Please sign in to comment.
Something went wrong with that request. Please try again.