├── .gitignore ├── rebar.lock ├── rebar.config ├── CHANGELOG.md ├── .github └── workflows │ └── ubuntu.yaml ├── LICENSE.txt ├── src ├── hotp.app.src ├── otpauth.erl ├── hotp.erl └── totp.erl ├── GNUmakefile ├── README.md ├── test ├── hotp_tests.erl └── totp_tests.erl └── doc └── handbook.md /.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | [{<<"base32">>, 2 | {git,"https://github.com/gearnode/erl-base32.git", 3 | {ref,"80e1270b909911ff4ab9f20a6c04adbae693b3f3"}}, 4 | 0}, 5 | {<<"uri">>, 6 | {git,"https://github.com/gearnode/erl-uri.git", 7 | {ref,"2a028d1c3c19001289f6cd04ab723582fa2c049d"}}, 8 | 0}]. 9 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {cover_enabled, true}. 2 | {erl_opts, [debug_info]}. 3 | {dialyzer, [{plt_extra_apps, []}, 4 | {warnings, [unknown]}]}. 5 | {deps, [{base32, 6 | {git, "https://github.com/gearnode/erl-base32.git", 7 | {tag, "v1.0.1"}}}, 8 | {uri, 9 | {git, "https://github.com/gearnode/erl-uri.git", 10 | {tag, "v1.4.0"}}}]}. 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a 6 | Changelog](https://keepachangelog.com/en/1.0.0/), and this project 7 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [Unreleased] 10 | 11 | ## [1.0.0] - 2022-07-26 12 | 13 | No changes. 14 | 15 | ## [0.1.0] - 2021-03-26 16 | 17 | ### Added 18 | 19 | - Generate `HOTP` password. 20 | - Validate `HOTP` password. 21 | - Generate `TOTP` password. 22 | - Validate `TOTP` password. 23 | - Generate `otpauth://` URI from validator state. 24 | -------------------------------------------------------------------------------- /.github/workflows/ubuntu.yaml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | on: 3 | push: 4 | branches: 5 | - "master" 6 | pull_request: 7 | branches: 8 | - "master" 9 | jobs: 10 | linux: 11 | name: "Test on OTP ${{ matrix.otp_version }} and ${{ matrix.os }}" 12 | runs-on: "${{ matrix.os }}" 13 | strategy: 14 | matrix: 15 | otp_version: [23, 24, 25] 16 | os: ["ubuntu-latest"] 17 | container: 18 | image: "erlang:${{ matrix.otp_version }}" 19 | steps: 20 | - uses: "actions/checkout@v2" 21 | - name: "make dialyzer" 22 | run: | 23 | make dialyzer 24 | - name: "make test" 25 | run: | 26 | make test 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Bryan Frimin . 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /src/hotp.app.src: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2022 Bryan Frimin . 2 | %% Copyright (c) 2021 Exograd SAS. 3 | %% 4 | %% Permission to use, copy, modify, and/or distribute this software for any 5 | %% purpose with or without fee is hereby granted, provided that the above 6 | %% copyright notice and this permission notice appear in all copies. 7 | %% 8 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | %% SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 14 | %% IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | {application, hotp, 17 | [{description, "HOTP and TOTP algorithms in Erlang"}, 18 | {vsn, "git"}, 19 | {registered, []}, 20 | {applications, 21 | [kernel, 22 | stdlib, 23 | crypto, 24 | base32, 25 | uri 26 | ]}, 27 | {env,[]}, 28 | {modules, []}, 29 | 30 | {licenses, ["ISC"]}, 31 | {links, []} 32 | ]}. 33 | -------------------------------------------------------------------------------- /GNUmakefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Bryan Frimin . 2 | # Copyright (c) 2021 Exograd SAS. 3 | # 4 | # Permission to use, copy, modify, and/or distribute this software for 5 | # any purpose with or without fee is hereby granted, provided that the 6 | # above copyright notice and this permission notice appear in all 7 | # copies. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 10 | # WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 11 | # WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 12 | # AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL 13 | # DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR 14 | # PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 15 | # TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 16 | # PERFORMANCE OF THIS SOFTWARE. 17 | 18 | all: dialyzer test 19 | 20 | dialyzer: 21 | QUIET=1 rebar3 dialyzer 22 | 23 | build: 24 | QUIET=1 rebar3 compile 25 | 26 | shell: 27 | QUIET=1 rebar3 shell --config config/local.config 28 | 29 | test: 30 | QUIET=1 rebar3 eunit 31 | 32 | cover: 33 | QUIET=1 rebar3 eunit --cover 34 | QUIET=1 rebar3 cover 35 | 36 | clean: 37 | $(RM) -r _build 38 | 39 | .PHONY: all dialyzer build shell test cover clean 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | This repository contains Erlang implementation of the [HMAC-Based 3 | One-Time Password](https://tools.ietf.org/html/rfc4226) and the 4 | [Time-Based One-Time Password](https://tools.ietf.org/html/rfc6238) 5 | algorithms. 6 | 7 | 8 | # Build 9 | You can build the library with: 10 | 11 | make build 12 | 13 | # Test 14 | You can execute the test suite with: 15 | 16 | make dialyzer test 17 | 18 | You can generate the test coverage with: 19 | 20 | make cover 21 | 22 | # Documentation 23 | A handbook is available [in the `doc` directory](doc/handbook.md). 24 | 25 | # Contact 26 | If you find a bug or have any question, feel free to open a Github 27 | issue. 28 | 29 | Please not that we do not currently review or accept any contribution. 30 | 31 | # Licence 32 | Released under the ISC license. 33 | 34 | Copyright (c) 2022 Bryan Frimin . 35 | 36 | Permission to use, copy, modify, and/or distribute this software for any 37 | purpose with or without fee is hereby granted, provided that the above 38 | copyright notice and this permission notice appear in all copies. 39 | 40 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 41 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 42 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 43 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 44 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 45 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 46 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 47 | -------------------------------------------------------------------------------- /src/otpauth.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2022 Bryan Frimin . 2 | %% Copyright (c) 2021 Exograd SAS. 3 | %% 4 | %% Permission to use, copy, modify, and/or distribute this software for any 5 | %% purpose with or without fee is hereby granted, provided that the above 6 | %% copyright notice and this permission notice appear in all copies. 7 | %% 8 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | %% SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 14 | %% IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | -module(otpauth). 17 | 18 | -export([generate/7]). 19 | 20 | -export_type([type/0, key/0, password_size/0, algorithm/0, issuer/0, 21 | account/0]). 22 | 23 | -type type() :: hotp | totp. 24 | -type key() :: hotp:key() | totp:key(). 25 | -type password_size() :: hotp:password_size() | totp:password_size(). 26 | -type algorithm() :: hotp:hmac_algorithms(). 27 | -type issuer() :: binary(). 28 | -type account() :: binary(). 29 | 30 | -spec generate(type(), key(), password_size(), algorithm(), issuer(), 31 | account(), uri:query()) -> binary(). 32 | generate(Type, Key, Size, Algorithm, Issuer, Account, Parameters) -> 33 | Query0 = [{<<"secret">>, base32:encode(Key, [nopad])}, 34 | {<<"issuer">>, Issuer}, 35 | {<<"algorithm">>, algorithm_to_binary(Algorithm)}, 36 | {<<"digits">>, integer_to_binary(Size)}], 37 | Query = Query0 ++ Parameters, 38 | Label = io_lib:format("/~s:~s", [Issuer, Account]), 39 | URI = #{scheme => <<"otpauth">>, host => atom_to_binary(Type), 40 | path => iolist_to_binary(Label), query => Query}, 41 | uri:serialize(URI). 42 | 43 | -spec algorithm_to_binary(algorithm()) -> binary(). 44 | algorithm_to_binary(sha) -> 45 | <<"SHA1">>; 46 | algorithm_to_binary(sha256) -> 47 | <<"SHA256">>; 48 | algorithm_to_binary(sha512) -> 49 | <<"SHA512">>. 50 | -------------------------------------------------------------------------------- /test/hotp_tests.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2022 Bryan Frimin . 2 | %% Copyright (c) 2021 Exograd SAS. 3 | %% 4 | %% Permission to use, copy, modify, and/or distribute this software for any 5 | %% purpose with or without fee is hereby granted, provided that the above 6 | %% copyright notice and this permission notice appear in all copies. 7 | %% 8 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | %% SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 14 | %% IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | -module(hotp_tests). 17 | 18 | -include_lib("eunit/include/eunit.hrl"). 19 | 20 | generate_test_() -> 21 | %% Vector test imported from the RFC 4226 appendix D. 22 | Key = <<"12345678901234567890">>, 23 | Options = #{size => 6}, 24 | [?_assertEqual(755224, 25 | hotp:generate(Key, 0, Options)), 26 | ?_assertEqual(287082, 27 | hotp:generate(Key, 1, Options)), 28 | ?_assertEqual(359152, 29 | hotp:generate(Key, 2, Options)), 30 | ?_assertEqual(969429, 31 | hotp:generate(Key, 3, Options)), 32 | ?_assertEqual(338314, 33 | hotp:generate(Key, 4, Options)), 34 | ?_assertEqual(254676, 35 | hotp:generate(Key, 5, Options)), 36 | ?_assertEqual(287922, 37 | hotp:generate(Key, 6, Options)), 38 | ?_assertEqual(162583, 39 | hotp:generate(Key, 7, Options)), 40 | ?_assertEqual(399871, 41 | hotp:generate(Key, 8, Options)), 42 | ?_assertEqual(520489, 43 | hotp:generate(Key, 9, Options))]. 44 | 45 | validate_test() -> 46 | Key = <<"12345678901234567890">>, 47 | State1 = hotp:new_validator(Key, #{look_ahead => 2}), 48 | 49 | ?assertEqual(invalid, hotp:validate(State1, 123456)), 50 | 51 | {Valid2, State2} = hotp:validate(State1, 287082), 52 | ?assertEqual(valid, Valid2), 53 | ?assertMatch(#{counter := 1}, State2), 54 | 55 | %% As the previous code has be valid it must not be valid anymore. 56 | ?assertEqual(invalid, hotp:validate(State2, 287082)), 57 | ?assertMatch(#{counter := 1}, State2), 58 | 59 | {Valid3, State3} = hotp:validate(State2, 359152), 60 | ?assertEqual(valid, Valid3), 61 | ?assertMatch(#{counter := 2}, State3), 62 | 63 | %% As look ahead is set to 2, the code must be valid and the counter must be 64 | %% set to 5. 65 | {Valid4, State4} = hotp:validate(State3, 254676), 66 | ?assertEqual(valid, Valid4), 67 | ?assertMatch(#{counter := 5}, State4), 68 | 69 | ?assertEqual(invalid, hotp:validate(State4, 338314)), 70 | 71 | ?assertEqual(invalid, hotp:validate(State4, 254676)), 72 | 73 | ?assertEqual(invalid, hotp:validate(State4, 520489)). 74 | 75 | otpauth_uri_test() -> 76 | Key = <<"12345">>, 77 | State = hotp:new_validator(Key, #{size => 8}), 78 | Issuer = <<"Exograd">>, 79 | Account = <<"bryan@frimin.fr">>, 80 | URI = hotp:otpauth_uri(State, Issuer, Account), 81 | ?assertEqual(<<"otpauth://hotp/Exograd:bryan@frimin.fr?secret=GEZDGNBV&issuer=Exograd&algorithm=SHA1&digits=8&counter=0">>, URI). 82 | -------------------------------------------------------------------------------- /src/hotp.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2022 Bryan Frimin . 2 | %% Copyright (c) 2021 Exograd SAS. 3 | %% 4 | %% Permission to use, copy, modify, and/or distribute this software for any 5 | %% purpose with or without fee is hereby granted, provided that the above 6 | %% copyright notice and this permission notice appear in all copies. 7 | %% 8 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | %% SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 14 | %% IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | -module(hotp). 17 | 18 | -export([generate/2, generate/3, 19 | new_validator/1, new_validator/2, 20 | validate/2, 21 | otpauth_uri/3]). 22 | 23 | -export_type([key/0, counter/0, password/0, password_size/0, 24 | hmac_algorithms/0, 25 | validator_state/0]). 26 | 27 | -type key() :: binary(). 28 | -type counter() :: non_neg_integer(). 29 | -type password() :: non_neg_integer(). 30 | -type password_size() :: pos_integer(). 31 | -type hmac_algorithms() :: sha | sha256 | sha512. 32 | 33 | -opaque validator_state() :: #{key := key(), 34 | counter := counter(), 35 | algorithm := hmac_algorithms(), 36 | size := pos_integer(), 37 | look_ahead := non_neg_integer()}. 38 | 39 | -spec generate(key(), counter()) -> 40 | password(). 41 | generate(Key, Counter) -> 42 | generate(Key, Counter, #{}). 43 | 44 | -spec generate(key(), counter(), Options) -> 45 | password() 46 | when Options :: #{size => password_size(), 47 | algorithm => hmac_algorithms()}. 48 | generate(Key, Counter, Options) -> 49 | Size = maps:get(size, Options, 6), 50 | Algorithm = maps:get(algorithm, Options, sha), 51 | truncate(crypto:mac(hmac, Algorithm, Key, <>), Size). 52 | 53 | -spec truncate(binary(), password_size()) -> 54 | password(). 55 | truncate(HMACResult, Size) -> 56 | Offset = binary:at(HMACResult, byte_size(HMACResult) - 1) band 16#0f, 57 | S0 = (binary:at(HMACResult, Offset) band 16#7f) bsl 24, 58 | S1 = (binary:at(HMACResult, Offset + 1) band 16#ff) bsl 16, 59 | S2 = (binary:at(HMACResult, Offset + 2) band 16#ff) bsl 8, 60 | S3 = (binary:at(HMACResult, Offset + 3) band 16#ff), 61 | (S0 bor S1 bor S2 bor S3) rem pow10(Size). 62 | 63 | -spec pow10(non_neg_integer()) -> 64 | pos_integer(). 65 | pow10(N) when N > 0 -> 66 | pow10(N, 1). 67 | 68 | -spec pow10(non_neg_integer(), non_neg_integer()) -> 69 | pos_integer(). 70 | pow10(0, Acc) -> 71 | Acc; 72 | pow10(N, Acc) -> 73 | pow10(N - 1, Acc * 10). 74 | 75 | -spec new_validator(key()) -> 76 | validator_state(). 77 | new_validator(Key) -> 78 | new_validator(Key, #{}). 79 | 80 | -spec new_validator(key(), Options) -> 81 | validator_state() 82 | when Options :: #{counter => counter(), 83 | size => password_size(), 84 | look_ahead => non_neg_integer()}. 85 | new_validator(Key, Options) -> 86 | #{key => Key, 87 | counter => maps:get(counter, Options, 0), 88 | size => maps:get(size, Options, 6), 89 | algorithm => maps:get(algorithm, Options, sha), 90 | look_ahead => maps:get(look_ahead, Options, 5)}. 91 | 92 | -spec validate(validator_state(), password()) -> 93 | {valid, validator_state()} | invalid. 94 | validate(#{key := Key, size := Size, counter := Counter0, 95 | algorithm := Algorithm, look_ahead := LookAhead} = State, 96 | Password) -> 97 | IsValidPassword = 98 | fun(C) -> 99 | generate(Key, C, #{size => Size, algorithm => Algorithm}) =:= Password 100 | end, 101 | Counter = Counter0 + 1, 102 | PossibleCounters = lists:seq(Counter, Counter + LookAhead), 103 | case lists:search(IsValidPassword, PossibleCounters) of 104 | false -> 105 | invalid; 106 | {value, NewCounter} -> 107 | State1 = State#{counter => NewCounter}, 108 | {valid, State1} 109 | end. 110 | 111 | -spec otpauth_uri(validator_state(), binary(), binary()) -> 112 | binary(). 113 | otpauth_uri(#{key := Key, size := Size, counter := Counter, 114 | algorithm := Algorithm}, 115 | Issuer, Account) -> 116 | Parameters = [{<<"counter">>, integer_to_binary(Counter)}], 117 | otpauth:generate(hotp, Key, Size, Algorithm, Issuer, Account, Parameters). 118 | -------------------------------------------------------------------------------- /test/totp_tests.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2022 Bryan Frimin . 2 | %% Copyright (c) 2021 Exograd SAS. 3 | %% 4 | %% Permission to use, copy, modify, and/or distribute this software for any 5 | %% purpose with or without fee is hereby granted, provided that the above 6 | %% copyright notice and this permission notice appear in all copies. 7 | %% 8 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | %% SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 14 | %% IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | -module(totp_tests). 17 | 18 | -include_lib("eunit/include/eunit.hrl"). 19 | 20 | generate_test_() -> 21 | T = fun(Bin) -> calendar:rfc3339_to_system_time(Bin) end, 22 | Key = <<"12345678901234567890">>, 23 | Key2 = <<"12345678901234567890123456789012">>, 24 | Key3 = 25 | <<"1234567890123456789012345678901234567890123456789012345678901234">>, 26 | [?_assertEqual(94287082, 27 | totp:generate(Key, T("1970-01-01T00:00:59Z"), #{size => 8})), 28 | ?_assertEqual(46119246, 29 | totp:generate(Key2, T("1970-01-01T00:00:59Z"), 30 | #{size => 8, algorithm => sha256})), 31 | ?_assertEqual(90693936, 32 | totp:generate(Key3, T("1970-01-01T00:00:59Z"), 33 | #{size => 8, algorithm => sha512})), 34 | ?_assertEqual(07081804, 35 | totp:generate(Key, T("2005-03-18T01:58:29Z"), #{size => 8})), 36 | ?_assertEqual(68084774, 37 | totp:generate(Key2, T("2005-03-18T01:58:29Z"), 38 | #{size => 8, algorithm => sha256})), 39 | ?_assertEqual(25091201, 40 | totp:generate(Key3, T("2005-03-18T01:58:29Z"), 41 | #{size => 8, algorithm => sha512})), 42 | ?_assertEqual(14050471, 43 | totp:generate(Key, T("2005-03-18T01:58:31Z"), #{size => 8})), 44 | 45 | ?_assertEqual(67062674, 46 | totp:generate(Key2, T("2005-03-18T01:58:31Z"), 47 | #{size => 8, algorithm => sha256})), 48 | ?_assertEqual(99943326, 49 | totp:generate(Key3, T("2005-03-18T01:58:31Z"), 50 | #{size => 8, algorithm => sha512})), 51 | ?_assertEqual(89005924, 52 | totp:generate(Key, T("2009-02-13T23:31:30Z"), #{size => 8})), 53 | ?_assertEqual(91819424, 54 | totp:generate(Key2, T("2009-02-13T23:31:30Z"), 55 | #{size => 8, algorithm => sha256})), 56 | ?_assertEqual(93441116, 57 | totp:generate(Key3, T("2009-02-13T23:31:30Z"), 58 | #{size => 8, algorithm => sha512})), 59 | ?_assertEqual(69279037, 60 | totp:generate(Key, T("2033-05-18T03:33:20Z"), #{size => 8})), 61 | ?_assertEqual(90698825, 62 | totp:generate(Key2, T("2033-05-18T03:33:20Z"), 63 | #{size => 8, algorithm => sha256})), 64 | ?_assertEqual(38618901, 65 | totp:generate(Key3, T("2033-05-18T03:33:20Z"), 66 | #{size => 8, algorithm => sha512})), 67 | ?_assertEqual(65353130, 68 | totp:generate(Key, T("2603-10-11T11:33:20Z"), #{size => 8})), 69 | ?_assertEqual(77737706, 70 | totp:generate(Key2, T("2603-10-11T11:33:20Z"), 71 | #{size => 8, algorithm => sha256})), 72 | ?_assertEqual(47863826, 73 | totp:generate(Key3, T("2603-10-11T11:33:20Z"), 74 | #{size => 8, algorithm => sha512}))]. 75 | 76 | validate_test() -> 77 | Key = <<"12345678901234567890">>, 78 | State1 = totp:new_validator(Key, #{step => 10}), 79 | 80 | ?assertMatch({valid, _}, totp:validate(State1, 254676, 50)), 81 | ?assertMatch({valid, _}, totp:validate(State1, 254676, 59)), 82 | ?assertMatch({valid, _}, totp:validate(State1, 254676, 40)), 83 | ?assertMatch({valid, _}, totp:validate(State1, 254676, 69)), 84 | 85 | ?assertEqual(invalid, totp:validate(State1, 254676, 39)), 86 | ?assertEqual(invalid, totp:validate(State1, 254676, 70)), 87 | 88 | %% Cannot reuse the last code 89 | {valid, State2} = totp:validate(State1, 254676, 50), 90 | ?assertEqual(invalid, totp:validate(State2, 254676, 55)). 91 | 92 | otpauth_uri_test() -> 93 | Key = <<"12345">>, 94 | State = totp:new_validator(Key, #{size => 8, step => 60}), 95 | Issuer = <<"Exograd">>, 96 | Account = <<"bryan@frimin.fr">>, 97 | URI = totp:otpauth_uri(State, Issuer, Account), 98 | ?assertEqual(<<"otpauth://totp/Exograd:bryan@frimin.fr?secret=GEZDGNBV&issuer=Exograd&algorithm=SHA1&digits=8&period=60">>, URI). 99 | -------------------------------------------------------------------------------- /doc/handbook.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | This document contains development notes about the `hotp` library. 3 | 4 | # Versioning 5 | The following `hotp` versions are available: 6 | - `0.y.z` unstable versions. 7 | - `x.y.z` stable versions: `hotp` will maintain reasonable backward 8 | compatibility, deprecating features before removing them. 9 | - Experimental untagged versions. 10 | 11 | Developers who use unstable or experimental versions are responsible for 12 | updating their application when `hotp` is modified. Note that 13 | unstable versions can be modified without backward compatibility at any 14 | time. 15 | 16 | # Modules 17 | ## `hotp` 18 | The HOTP implementation is based on the [RFC 19 | 4226](https://tools.ietf.org/html/rfc4226). 20 | 21 | ### `generate/2` 22 | Generate an HOTP password. 23 | 24 | Same as `generate(<<"secret">>, 0, #{})`. 25 | 26 | ### `generate/3` 27 | Generate an HOTP password. 28 | 29 | The following options are supported: 30 | 31 | | Name | Type | Description | Default | 32 | |-----------|---------|---------------------------------------------------|---------| 33 | | size | integer | The number of digits in a password. | 6 | 34 | | algorithm | atom | The crypto algorithm use to generate the password | sha | 35 | 36 | 37 | Example: 38 | ```erlang 39 | hotp:generate(<<"secret">>, 1, #{size => 8}). 40 | ``` 41 | 42 | ### `new_validator/1` 43 | Returns a validator state that can be used by `validate/2` to validate 44 | the HOTP password. 45 | 46 | Same as `new_validator(<<"secret">>, #{})`. 47 | 48 | ### `new_validator/2` 49 | Returns a validator state that can be used by `validate/2` to validate 50 | the HOTP password. 51 | 52 | The following options are supported: 53 | 54 | | Name | Type | Description | Default | 55 | |------------|---------|---------------------------------------------------|---------| 56 | | counter | integer | The initial counter value. | 0 | 57 | | size | integer | The number of digits in a password. | 6 | 58 | | look_ahead | integer | The number of next counters to check validity | 5 | 59 | | algorithm | atom | The crypto algorithm use to generate the password | sha | 60 | 61 | Example: 62 | ```erlang 63 | ValidatorState = hotp:new_validator(<<"secret">>, #{size => 8}). 64 | ``` 65 | 66 | ### `validate/2` 67 | Validates a HOTP password given a validator state. 68 | 69 | Example: 70 | ```erlang 71 | ValidatorState = hotp:new_validator(<<"secret">>), 72 | {valid, NewValidatorState} = hotp:validate(ValidatorState, 533881). 73 | ``` 74 | 75 | ## `totp` 76 | The TOTP implementation is based on the [RFC 77 | 6238](https://tools.ietf.org/html/rfc6238). 78 | 79 | ### `generate/1` 80 | Generate an TOTP password. 81 | 82 | Same as `generate(<<"secret">>, os:system_time(second), #{})`. 83 | 84 | ### `generate/2` 85 | Generate an TOTP password. 86 | 87 | Same as `generate(<<"secret">>, CurrentTime, #{})`. 88 | 89 | ### `generate/3` 90 | Generate an TOTP password. 91 | 92 | The following options are supported: 93 | 94 | | Name | Type | Description | Default | 95 | |--------------|---------|---------------------------------------------------|---------| 96 | | size | integer | The number of digits in a password. | 6 | 97 | | algorithm | atom | The crypto algorithm use to generate the password | sha | 98 | | step | integer | The time step in seconds | 30 | 99 | | initial_time | integer | The Unix time to start counting time steps | 0 | 100 | | current_time | integer | TODO | `Now()` | 101 | 102 | Example: 103 | ```erlang 104 | totp:generate(<<"secret">>, os:system_time(second), #{algorithm => sha512}). 105 | ``` 106 | 107 | ### `new_validator/1` 108 | Returns a validator state that can be used by `validate/2` to validate 109 | the TOTP password. 110 | 111 | Same as `new_validator(<<"secret">>, #{})`. 112 | 113 | ### `new_validator/2` 114 | Returns a validator state that can be used by `validate/2` to validate 115 | the TOTP password. 116 | 117 | The following options are supported: 118 | 119 | | Name | Type | Description | Default | 120 | |--------------|-----------|-----------------------------------------------------|---------| 121 | | size | integer | The number of digits in a password. | 6 | 122 | | step | integer | The length of a time period in seconds. | 30 | 123 | | look_behind | integer | The number of past periods to check for validity. | 1 | 124 | | look_ahead | integer | The number of future periods to check for validity. | 1 | 125 | | initial_time | timestamp | The initial timestamp used to compute time periods. | 0 | 126 | | algorithm | atom | The crypto algorithm use to generate the password. | sha | 127 | 128 | Example: 129 | ```erlang 130 | ValidatorState = totp:new_validator(<<"secret">>, #{size => 8}). 131 | ``` 132 | 133 | ### `validate/2` 134 | Validates a TOTP password given a validator state. 135 | 136 | Same as `validate(<<"secret">>, Password, os:system_time(second))`. 137 | 138 | ### `validate/3` 139 | Validates a TOTP password given a validator state. 140 | 141 | Example: 142 | ```erlang 143 | ValidatorState = totp:new_validator(<<"secret">>), 144 | {valid, NewValidatorState} = totp:validate(ValidatorState, 533881). 145 | ``` 146 | -------------------------------------------------------------------------------- /src/totp.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2022 Bryan Frimin . 2 | %% Copyright (c) 2021 Exograd SAS. 3 | %% 4 | %% Permission to use, copy, modify, and/or distribute this software for any 5 | %% purpose with or without fee is hereby granted, provided that the above 6 | %% copyright notice and this permission notice appear in all copies. 7 | %% 8 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 11 | %% SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 14 | %% IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | 16 | -module(totp). 17 | 18 | -export([generate/1, generate/2, generate/3, 19 | new_validator/1, new_validator/2, 20 | validate/2, validate/3, 21 | otpauth_uri/3]). 22 | 23 | -export_type([key/0, password/0, password_size/0, 24 | timestamp/0, step/0, 25 | validator_state/0]). 26 | 27 | -type key() :: hotp:key(). 28 | -type password() :: hotp:password(). 29 | -type password_size() :: hotp:password_size(). 30 | -type timestamp() :: integer(). 31 | -type step() :: pos_integer(). 32 | 33 | -opaque validator_state() :: #{key := key(), 34 | size := password_size(), 35 | initial_time := timestamp(), 36 | step := step(), 37 | algorithm := hotp:hmac_algorithms(), 38 | look_behind := non_neg_integer(), 39 | look_ahead := non_neg_integer(), 40 | last_period => non_neg_integer()}. 41 | 42 | -spec generate(key()) -> 43 | password(). 44 | generate(Key) -> 45 | generate(Key, os:system_time(second), #{}). 46 | 47 | -spec generate(key(), timestamp()) -> 48 | password(). 49 | generate(Key, CurrentTime) -> 50 | generate(Key, CurrentTime, #{}). 51 | 52 | -spec generate(key(), timestamp(), Options) -> 53 | password() 54 | when Options :: #{size => password_size(), 55 | step => step(), 56 | initial_time => timestamp(), 57 | algorithm => hotp:hmac_algorithms()}. 58 | generate(Key, CurrentTime, Options) -> 59 | Size = maps:get(size, Options, 6), 60 | Algorithm = maps:get(algorithm, Options, sha), 61 | Step = maps:get(step, Options, 30), 62 | InitialTime = maps:get(initial_time, Options, 0), 63 | TimePeriod = time_period(CurrentTime, InitialTime, Step), 64 | hotp:generate(Key, TimePeriod, #{size => Size, algorithm => Algorithm}). 65 | 66 | -spec time_period(timestamp(), timestamp(), integer()) -> 67 | non_neg_integer(). 68 | time_period(CurrentTime, InitialTime, Step) -> 69 | trunc(math:floor(CurrentTime - InitialTime) / Step). 70 | 71 | -spec new_validator(key()) -> 72 | validator_state(). 73 | new_validator(Key) -> 74 | new_validator(Key, #{}). 75 | 76 | -spec new_validator(key(), Options) -> 77 | validator_state() 78 | when Options :: #{size => password_size(), 79 | step => step(), 80 | algorithm => hotp:hmac_algorithms(), 81 | initial_time => timestamp(), 82 | look_behind => non_neg_integer(), 83 | look_ahead => non_neg_integer()}. 84 | new_validator(Key, Options) -> 85 | #{key => Key, 86 | size => maps:get(size, Options, 6), 87 | step => maps:get(step, Options, 30), 88 | algorithm => maps:get(algorithm, Options, sha), 89 | initial_time => maps:get(initial_time, Options, 0), 90 | look_behind => maps:get(look_behind, Options, 1), 91 | look_ahead => maps:get(look_ahead, Options, 1)}. 92 | 93 | -spec validate(validator_state(), password()) -> 94 | {valid, validator_state()} | invalid. 95 | validate(State, Password) -> 96 | validate(State, Password, os:system_time(second)). 97 | 98 | -spec validate(validator_state(), password(), timestamp()) -> 99 | {valid, validator_state()} | invalid. 100 | validate(#{initial_time := InitialTime, step := Step, size := Size, 101 | look_behind := LookBehind, look_ahead := LookAhead, 102 | algorithm := Algorithm, key := Key} = State, 103 | Password, CurrentTime) -> 104 | LastPeriod = maps:get(last_period, State, none), 105 | TimePeriod = time_period(CurrentTime, InitialTime, Step), 106 | if 107 | LastPeriod =:= TimePeriod -> 108 | invalid; 109 | true -> 110 | PossiblePeriods = 111 | lists:seq(TimePeriod - LookBehind, TimePeriod + LookAhead), 112 | IsValidPassword = 113 | fun(Period) -> 114 | hotp:generate(Key, Period, #{size => Size, algorithm => Algorithm}) 115 | =:= Password 116 | end, 117 | case lists:search(IsValidPassword, PossiblePeriods) of 118 | false -> 119 | invalid; 120 | {value, NewLastPeriod} -> 121 | State1 = State#{last_period => NewLastPeriod}, 122 | {valid, State1} 123 | end 124 | end. 125 | 126 | -spec otpauth_uri(validator_state(), binary(), binary()) -> 127 | binary(). 128 | otpauth_uri(#{key := Key, size := Size, step := Step, algorithm := Algorithm}, 129 | Issuer, Account) -> 130 | Parameters = [{<<"period">>, integer_to_binary(Step)}], 131 | otpauth:generate(totp, Key, Size, Algorithm, Issuer, Account, Parameters). 132 | --------------------------------------------------------------------------------