├── .github └── workflows │ └── erlang.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── code.dict ├── elvis.config ├── names.dict ├── rebar.config ├── rebar.lock ├── src ├── fernet.app.src └── fernet.erl └── test ├── fernet_spec_SUITE.erl └── fernet_spec_SUITE_data ├── generate.json ├── invalid.json └── verify.json /.github/workflows/erlang.yml: -------------------------------------------------------------------------------- 1 | name: Erlang CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | build: 8 | 9 | runs-on: ubuntu-20.04 10 | 11 | strategy: 12 | matrix: 13 | otp: ['23.3', '24.3', '25.2.1'] 14 | rebar: ['3.20.0'] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: erlef/setup-beam@v1 19 | id: setup-beam 20 | with: 21 | otp-version: ${{matrix.otp}} 22 | rebar3-version: ${{matrix.rebar}} 23 | - name: Restore _build 24 | uses: actions/cache@v2 25 | with: 26 | path: _build 27 | key: _build-cache-for-os-${{runner.os}}-otp-${{steps.setup-beam.outputs.otp-version}}-rebar3-${{steps.setup-beam.outputs.rebar3-version}}-hash-${{hashFiles('rebar.lock')}} 28 | - name: Restore rebar3's cache 29 | uses: actions/cache@v2 30 | with: 31 | path: ~/.cache/rebar3 32 | key: rebar3-cache-for-os-${{runner.os}}-otp-${{steps.setup-beam.outputs.otp-version}}-rebar3-${{steps.setup-beam.outputs.rebar3-version}}-hash-${{hashFiles('rebar.lock')}} 33 | - name: Compile 34 | run: rebar3 compile 35 | - name: Format check 36 | run: rebar3 format --verify 37 | - name: Run tests and verifications 38 | run: rebar3 test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | doc/ 2 | .rebar3 3 | _* 4 | .eunit 5 | *.o 6 | *.beam 7 | *.plt 8 | *.swp 9 | *.swo 10 | .erlang.cookie 11 | ebin 12 | log 13 | erl_crash.dump 14 | .rebar 15 | logs 16 | _build 17 | .idea 18 | *.iml 19 | rebar3.crashdump 20 | .viminfo 21 | .vimrc 22 | *.swp 23 | \#* 24 | .#* 25 | .emacs* 26 | .DS_Store 27 | *.dump 28 | tmp 29 | *~ 30 | TEST-*.xml 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See the [Releases](../../releases) page. 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2015, Heroku Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fernet for Erlang # 2 | 3 | This is an Erlang implementation of the [Fernet specification](https://github.com/fernet/spec) which 4 | 5 | > "takes a user-provided message (an arbitrary sequence of 6 | > bytes), a key (256 bits), and the current time, and produces a token, which 7 | > contains the message in a form that can't be read or altered without the key." 8 | 9 | ## Interface ## 10 | 11 | ```erlang 12 | 1> Key = fernet:generate_encoded_key(). 13 | <<"iXOktbuC7QYXM9aF_m49VAqdkZ6jQBMsqjYwEHTm5ps=">> 14 | 15 | 2> Token = fernet:generate_token("hello", Key). 16 | <<"gAAAAABVguk6wOivag6ZN_76fP2EXltZGJ9yPLLXKg4aBR9ekbhVnYmkJOuqTGl_GlmNlg6Z_KDl2wb1duRV41CNbF931n4LgA==">> 17 | 18 | 3> fernet:verify_and_decrypt_token(Token, Key, infinity). 19 | {ok,<<"hello">>} 20 | 21 | 4> TTL = 10. % seconds 22 | 10 23 | 24 | 5> fernet:verify_and_decrypt_token(Token, Key, TTL). 25 | {error, too_old} 26 | ``` 27 | -------------------------------------------------------------------------------- /code.dict: -------------------------------------------------------------------------------- 1 | %% Code pieces that appear in comments 2 | 1.0.0 3 | 18.0 4 | 16-byte 5 | 32-byte 6 | 64-bit 7 | c5ff9095f5d38f9ab86e5543e02686f03b3ec971b9ab47ae23566a54e08c2a0c 8 | diff-ttl 9 | gaaaaaadwj6xaaecawqfbgcicqolda0od3hkmatm5lfqgaerz-fwpom73qeock9ugib28xe5vz6oxq5nmxbx_v7mrfyudzum 10 | generate_iv 11 | generate_key 12 | timestamp_to_seconds 13 | ttl 14 | ttl_sec 15 | unixtime 16 | github 17 | -------------------------------------------------------------------------------- /elvis.config: -------------------------------------------------------------------------------- 1 | [{elvis, 2 | [{config, 3 | [#{dirs => ["src"], 4 | filter => "*.erl", 5 | ruleset => erl_files, 6 | rules => 7 | [{elvis_style, atom_naming_convention, #{regex => "^([a-z0-9]*_?)*?$"}}, 8 | {elvis_text_style, line_length, #{limit => 150}}, 9 | {elvis_style, dont_repeat_yourself, #{min_complexity => 20}}, 10 | {elvis_style, no_throw, disable}]}, 11 | #{dirs => ["test"], 12 | filter => "*.erl", 13 | ruleset => erl_files, 14 | rules => 15 | [%% Variables in eunit macros are called, for instance, __V 16 | {elvis_style, variable_naming_convention, #{regex => "^_?_?([A-Z][0-9a-zA-Z]*)_?$"}}, 17 | {elvis_style, dont_repeat_yourself, #{min_complexity => 20}}]}, 18 | #{dirs => ["."], 19 | filter => "*rebar.config", 20 | ruleset => rebar_config}, 21 | #{dirs => ["."], 22 | filter => "elvis.config", 23 | ruleset => elvis_config}]}]}]. 24 | -------------------------------------------------------------------------------- /names.dict: -------------------------------------------------------------------------------- 1 | %% Names or specific words that are not in the english dictionary 2 | 3 | api 4 | base64url 5 | erlang 6 | fernet 7 | heroku 8 | interoperability 9 | kevin 10 | mcdermott 11 | megasecs 12 | microsecs 13 | src 14 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | {erl_opts, 2 | [warn_unused_import, warn_export_vars, warnings_as_errors, verbose, report, debug_info]}. 3 | 4 | {minimum_otp_vsn, "23"}. 5 | 6 | {cover_enabled, true}. 7 | 8 | {deps, [{base64url, "1.0.1"}, {pkcs7, "1.0.2"}]}. 9 | 10 | {eunit_opts, [verbose, {report, {eunit_surefire, [{dir, "."}]}}]}. 11 | 12 | %% Remove proper from pkcs7 deps 13 | {overrides, [{override, pkcs7, [{deps, []}]}]}. 14 | 15 | {profiles, [{test, [{deps, [{erlware_commons, "1.6.0"}, {jsx, "3.1.0"}]}]}]}. 16 | 17 | {dialyzer, 18 | [{warnings, [no_return, error_handling]}, 19 | {plt_apps, top_level_deps}, 20 | {plt_extra_apps, []}, 21 | {plt_location, local}, 22 | {base_plt_apps, [erts, stdlib, kernel]}, 23 | {base_plt_location, global}]}. 24 | 25 | {xref_checks, 26 | [undefined_function_calls, 27 | locals_not_used, 28 | deprecated_function_calls, 29 | deprecated_functions]}. 30 | 31 | {spellcheck, 32 | [{ignore_regex, 33 | "(https://|[a-z0-9]:[0-9a-z]|
|--+|==*|[a-z]/[a-z]|[a-z][(]|[?][A-Z]|->)"},
34 |   {files, ["src/*"]},
35 |   {additional_dictionaries, ["code.dict", "names.dict"]}]}.
36 | 
37 | {alias, [{test, [format, spellcheck, lint, hank, xref, dialyzer, eunit, ct, cover]}]}.
38 | 
39 | {project_plugins,
40 |  [{rebar3_hex, "~> 7.0.6"},
41 |   {rebar3_format, "~> 1.3.0"},
42 |   {rebar3_lint, "~> 3.0.1"},
43 |   {rebar3_hank, "~> 1.4.0"},
44 |   {rebar3_ex_doc, "~> 0.2.17"},
45 |   {rebar3_sheldon, "~> 0.4.2"}]}.
46 | 
47 | {ex_doc,
48 |  [{source_url, <<"https://github.com/fernet/fernet-erl">>},
49 |   {extras, [<<"README.md">>, <<"LICENSE">>]},
50 |   {main, <<"readme">>}]}.
51 | 
52 | {hex, [{doc, ex_doc}]}.
53 | 


--------------------------------------------------------------------------------
/rebar.lock:
--------------------------------------------------------------------------------
 1 | {"1.2.0",
 2 | [{<<"base64url">>,{pkg,<<"base64url">>,<<"1.0.1">>},0},
 3 |  {<<"pkcs7">>,{pkg,<<"pkcs7">>,<<"1.0.2">>},0}]}.
 4 | [
 5 | {pkg_hash,[
 6 |  {<<"base64url">>, <<"F8C7F2DA04CA9A5D0F5F50258F055E1D699F0E8BF4CFDB30B750865368403CF6">>},
 7 |  {<<"pkcs7">>, <<"CD9D50177BFBD3FD80E8BC79D2941D3B2968B64BD1151EF3E54D8C4B2605ED7E">>}]},
 8 | {pkg_hash_ext,[
 9 |  {<<"base64url">>, <<"F9B3ADD4731A02A9B0410398B475B33E7566A695365237A6BDEE1BB447719F5C">>},
10 |  {<<"pkcs7">>, <<"0E4FAA65411E204B7952712D58F657335109ECBB24CF79163DC96458BA8D6518">>}]}
11 | ].
12 | 


--------------------------------------------------------------------------------
/src/fernet.app.src:
--------------------------------------------------------------------------------
 1 | {application,
 2 |  fernet,
 3 |  [{description, "An Erlang fernet library"},
 4 |   {vsn, git},
 5 |   {modules, [fernet]},
 6 |   {registered, []},
 7 |   {licenses, ["MIT"]},
 8 |   {applications, [kernel, stdlib, base64url, pkcs7]},
 9 |   {env, []},
10 |   {links, [{"GitHub", "https://github.com/fernet/fernet-erl"}]}]}.
11 | 


--------------------------------------------------------------------------------
/src/fernet.erl:
--------------------------------------------------------------------------------
  1 | %%%-------------------------------------------------------------------
  2 | %%% @author Kevin McDermott 
  3 | %%% @copyright (C) 2014, Heroku
  4 | %%% @doc
  5 | %%%
  6 | %%% Implements fernet token generation and verification.
  7 | %%%
  8 | %%% See https://github.com/fernet/spec
  9 | %%%
 10 | %%% @end
 11 | %%% Created : 28 Nov 2014 by Kevin McDermott 
 12 | %%%-------------------------------------------------------------------
 13 | -module(fernet).
 14 | 
 15 | -export([generate_key/0, generate_encoded_key/0, encode_key/1, encode_key/2, decode_key/1,
 16 |          generate_token/2, verify_and_decrypt_token/3]).
 17 | 
 18 | -ifdef(TEST).
 19 | 
 20 | -export([verify_and_decrypt_token/4, generate_token/4]).
 21 | 
 22 | -endif.
 23 | 
 24 | -define(VERSION, 128).
 25 | -define(BLOCKSIZE, 16).
 26 | -define(HMACSIZE, 32).
 27 | -define(IVSIZE, 16).
 28 | -define(TSSIZE, 8).
 29 | -define(MAX_SKEW, 60).
 30 | 
 31 | -type key() :: <<_:256>>.
 32 | -type signing_key() :: <<_:128>>.
 33 | -type encryption_key() :: <<_:128>>.
 34 | -type encoded_key() :: binary().
 35 | -type encoded_token() :: binary().
 36 | 
 37 | -export_type([key/0, signing_key/0, encryption_key/0, encoded_key/0, encoded_token/0]).
 38 | 
 39 | %%%===================================================================
 40 | %%% API
 41 | %%%===================================================================
 42 | %% @doc Generate a pseudorandom 32 bytes key.
 43 | -spec generate_key() -> key().
 44 | generate_key() ->
 45 |     crypto:strong_rand_bytes(32).
 46 | 
 47 | %% @doc Generate a pseudorandom 32 bytes key, and encode it with the
 48 | %% proper base64url format for interoperability.
 49 | -spec generate_encoded_key() -> encoded_key().
 50 | generate_encoded_key() ->
 51 |     base64url:encode_mime(
 52 |         crypto:strong_rand_bytes(32)).
 53 | 
 54 | %% @doc Encode a key using base64url encoding format for interoperability
 55 | -spec encode_key(key()) -> encoded_key().
 56 | encode_key(<>) ->
 57 |     base64url:encode_mime(Key).
 58 | 
 59 | %% @doc Encode a signing key and an encryption key using base64url
 60 | %% encoding format for interoperability
 61 | -spec encode_key(signing_key(), encryption_key()) -> encoded_key().
 62 | encode_key(<>, <>) ->
 63 |     Key = <>,
 64 |     base64url:encode_mime(Key).
 65 | 
 66 | %% @doc Decode a base64url encoded key.
 67 | -spec decode_key(encoded_key()) -> key().
 68 | decode_key(Key) ->
 69 |     base64url:decode(Key).
 70 | 
 71 | %% @doc Generate a token for the provided Message using the supplied Key.
 72 | -spec generate_token(iodata(), key()) -> encoded_token().
 73 | generate_token(Message, Key) ->
 74 |     generate_token(Message, generate_iv(), erlang_system_seconds(), Key).
 75 | 
 76 | %% @doc Verify a token and extract the message
 77 | -spec verify_and_decrypt_token(encoded_token(), key(), TTL :: integer() | infinity) ->
 78 |                                   {ok, binary()} | {error, atom()}.
 79 | verify_and_decrypt_token(Token, Key, infinity) ->
 80 |     verify_and_decrypt_token(Token, Key, infinity, undefined);
 81 | verify_and_decrypt_token(Token, Key, TTL) ->
 82 |     verify_and_decrypt_token(Token, Key, TTL, erlang_system_seconds()).
 83 | 
 84 | %%%===================================================================
 85 | %%% Private
 86 | %%%===================================================================
 87 | generate_token(Message, IV, Seconds, Key) ->
 88 |     EncodedSeconds = seconds_to_binary(Seconds),
 89 |     Padded = pkcs7:pad(iolist_to_binary(Message)),
 90 |     <> = base64url:decode(Key),
 91 |     CypherText = block_encrypt(EncryptionKey, IV, Padded),
 92 |     Payload = payload(EncodedSeconds, IV, CypherText),
 93 |     Hmac = hmac(SigningKey, Payload),
 94 |     encode_token(<>).
 95 | 
 96 | verify_and_decrypt_token(EncodedToken, Key, TTL, Now) ->
 97 |     try DecodedToken = decode_token(EncodedToken),
 98 |         MsgSize = byte_size(DecodedToken) - (1 + ?TSSIZE + ?IVSIZE + ?HMACSIZE),
 99 |         validate_msg_size(MsgSize),
100 |         <> =
101 |             DecodedToken,
102 |         <> = decode_key(Key),
103 |         validate_vsn(Vsn),
104 |         validate_ttl(Now, binary_to_seconds(TS), TTL),
105 |         validate_hmac(Hmac, SigningKey, {TS, IV, CypherText}),
106 |         block_decrypt(EncryptionKey, IV, CypherText)
107 |     of
108 |         Decrypted ->
109 |             unpad(Decrypted)
110 |     catch
111 |         invalid_base64 = Err ->
112 |             {error, Err};
113 |         too_short = Err ->
114 |             {error, Err};
115 |         payload_size_not_multiple_of_block_size = Err ->
116 |             {error, Err};
117 |         bad_version = Err ->
118 |             {error, Err};
119 |         too_old = Err ->
120 |             {error, Err};
121 |         too_new = Err ->
122 |             {error, Err};
123 |         incorrect_mac = Err ->
124 |             {error, Err}
125 |     end.
126 | 
127 | unpad(Decrypted) ->
128 |     try
129 |         {ok, pkcs7:unpad(Decrypted)}
130 |     catch
131 |         error:_ ->
132 |             {error, payload_padding}
133 |     end.
134 | 
135 | encode_token(Token) ->
136 |     base64url:encode_mime(Token).
137 | 
138 | decode_token(EncodedToken) ->
139 |     try
140 |         base64url:decode(EncodedToken)
141 |     catch
142 |         error:badarg ->
143 |             throw(invalid_base64)
144 |     end.
145 | 
146 | %%-------------------------------------------------------------------
147 | %% Validation Helpers
148 | %%-------------------------------------------------------------------
149 | validate_msg_size(MsgSize) when MsgSize < 0 ->
150 |     throw(too_short);
151 | validate_msg_size(MsgSize) when MsgSize rem ?BLOCKSIZE =/= 0 ->
152 |     throw(payload_size_not_multiple_of_block_size);
153 | validate_msg_size(_) ->
154 |     ok.
155 | 
156 | validate_vsn(<<128>>) ->
157 |     ok;
158 | validate_vsn(_) ->
159 |     throw(bad_version).
160 | 
161 | validate_ttl(_, _, infinity) ->
162 |     ok;
163 | validate_ttl(Now, TS, TTL) ->
164 |     case Now - TS of
165 |         Diff when Diff < 0, abs(Diff) < ?MAX_SKEW ->
166 |             ok; % in the past but within skew
167 |         Diff when Diff < 0 ->
168 |             throw(too_new); % in the past, with way too large of a  skew
169 |         Diff when Diff >= 0, Diff < TTL ->
170 |             ok; % absolutely okay
171 |         Diff when Diff > 0, Diff > TTL ->
172 |             throw(too_old) % according to spec, skew doesn't apply here
173 |     end.
174 | 
175 | validate_hmac(Hmac, SigningKey, {TS, IV, CypherText}) ->
176 |     ReHmac = hmac(SigningKey, payload(TS, IV, CypherText)),
177 |     case verify_in_constant_time(Hmac, ReHmac) of
178 |         true ->
179 |             ok;
180 |         false ->
181 |             throw(incorrect_mac)
182 |     end.
183 | 
184 | %%-------------------------------------------------------------------
185 | %% Crypto Helpers
186 | %%-------------------------------------------------------------------
187 | payload(EncodedSeconds, IV, CypherText) ->
188 |     <>.
189 | 
190 | hmac(Key, Payload) ->
191 |     crypto:mac(hmac, sha256, Key, Payload).
192 | 
193 | %% @doc Verifies two hashes for matching purpose, in constant time. That allows
194 | %% a safer verification as no attacker can use the time it takes to compare hash
195 | %% values to find an attack vector (past figuring out the complexity)
196 | verify_in_constant_time(X, Y) ->
197 |     case byte_size(X) == byte_size(Y) of
198 |         true ->
199 |             verify_in_constant_time(X, Y, 0);
200 |         false ->
201 |             false
202 |     end.
203 | 
204 | verify_in_constant_time(<>, <>, Result) ->
205 |     verify_in_constant_time(RestX, RestY, X bxor Y bor Result);
206 | verify_in_constant_time(<<>>, <<>>, Result) ->
207 |     Result == 0.
208 | 
209 | block_encrypt(Key, IV, Padded) ->
210 |     crypto:crypto_one_time(aes_128_cbc, Key, IV, Padded, true).
211 | 
212 | block_decrypt(Key, IV, Cypher) ->
213 |     crypto:crypto_one_time(aes_128_cbc, Key, IV, Cypher, false).
214 | 
215 | -spec generate_iv() -> <<_:128>>.
216 | generate_iv() ->
217 |     crypto:strong_rand_bytes(16).
218 | 
219 | %%-------------------------------------------------------------------
220 | %% Time Helpers
221 | %%-------------------------------------------------------------------
222 | -spec seconds_to_binary(integer()) -> binary().
223 | seconds_to_binary(Seconds) ->
224 |     <>.
225 | 
226 | -spec binary_to_seconds(binary()) -> integer().
227 | binary_to_seconds(<>) ->
228 |     Bin.
229 | 
230 | -spec erlang_system_seconds() -> integer().
231 | erlang_system_seconds() ->
232 |     try
233 |         erlang:system_time(seconds)
234 |     catch
235 |         error:undef -> % Pre 18.0
236 |             timestamp_to_seconds(os:timestamp())
237 |     end.
238 | 
239 | -spec timestamp_to_seconds(erlang:timestamp()) -> integer().
240 | timestamp_to_seconds({MegaSecs, Secs, MicroSecs}) ->
241 |     round(((MegaSecs * 1000000 + Secs) * 1000000 + MicroSecs) / 1000000).
242 | 
243 | %%%===================================================================
244 | %%% Tests
245 | %%%===================================================================
246 | -ifdef(TEST).
247 | 
248 | -include_lib("eunit/include/eunit.hrl").
249 | 
250 | % timestamp_to_seconds should return the number of seconds since the Unixtime
251 | % Epoch represented by a tuple {MegaSecs, Secs, MicroSecs} as returned by now()
252 | timestamp_to_seconds_test() ->
253 |     ?assertEqual(1412525041, timestamp_to_seconds({1412, 525041, 377060})).
254 | 
255 | % generate_key should generate a 32-byte binary
256 | generate_key_test() ->
257 |     ?assertEqual(32, byte_size(generate_key())).
258 | 
259 | % generate_key should generate a 32-byte binary
260 | generate_encoded_key_test() ->
261 |     ?assertEqual(32, byte_size(decode_key(generate_encoded_key()))).
262 | 
263 | % generate_iv should generate a 16-byte binary
264 | generate_iv_test() ->
265 |     ?assertEqual(16, byte_size(generate_iv())).
266 | 
267 | encode_key_test() ->
268 |     Key = test_key(),
269 |     ?assertEqual(<<"cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=">>, encode_key(Key)).
270 | 
271 | decode_key_test() ->
272 |     Key = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
273 |     ?assertEqual(test_key(), decode_key(Key)).
274 | 
275 | test_key() ->
276 |     <<115, 15, 244, 199, 175, 61, 70, 146, 62, 142, 212, 81, 238, 129, 60, 135, 247, 144, 176,
277 |       162, 38, 188, 150, 169, 45, 228, 155, 94, 156, 5, 225, 238>>.
278 | 
279 | %[
280 | %  {
281 | %    "token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
282 | %    "now": "1985-10-26T01:20:00-07:00",
283 | %    "iv": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
284 | %    "src": "hello",
285 | %    "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
286 | %  }
287 | %]
288 | % 1985-10-26T01:20:00-07:00 == 499162800 Seconds since the epoch
289 | %
290 | generate_token_test_() ->
291 |     Tok = generate_token("hello",
292 |                          <<0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15>>,
293 |                          499162800,
294 |                          "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="),
295 |     [?_assertEqual(<<"gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==">>,
296 |                    Tok),
297 |      ?_assertEqual(decode_token("gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA=="),
298 |                    decode_token(Tok))].
299 | 
300 | generate_hmac_test() ->
301 |     SigningKey = <<115, 15, 244, 199, 175, 61, 70, 146, 62, 142, 212, 81, 238, 129, 60, 135>>,
302 |     Payload =
303 |         <<128, 0, 0, 0, 0, 29, 192, 158, 176, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14,
304 |           15, 45, 54, 213, 202, 70, 85, 98, 153, 253, 225, 48, 8, 99, 56, 4, 178>>,
305 |     ExpectedHmac = <<"c5ff9095f5d38f9ab86e5543e02686f03b3ec971b9ab47ae23566a54e08c2a0c">>,
306 |     Hmac = base16(hmac(SigningKey, Payload)),
307 |     ?assertEqual(ExpectedHmac, Hmac).
308 | 
309 | generate_hmac4_test() ->
310 |     EncodedSeconds = <<0, 0, 0, 0, 29, 192, 158, 176>>,
311 |     IV = <<0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15>>,
312 |     CypherText = <<45, 54, 213, 202, 70, 85, 98, 153, 253, 225, 48, 8, 99, 56, 4, 178>>,
313 |     SigningKey = <<115, 15, 244, 199, 175, 61, 70, 146, 62, 142, 212, 81, 238, 129, 60, 135>>,
314 |     Hmac = base16(hmac(SigningKey, payload(EncodedSeconds, IV, CypherText))),
315 |     ?assertEqual(<<"c5ff9095f5d38f9ab86e5543e02686f03b3ec971b9ab47ae23566a54e08c2a0c">>,
316 |                  Hmac).
317 | 
318 | %% Convert seconds since the Unixtime Epoch to a 64-bit unsigned big-endian integer.
319 | seconds_to_binary_test() ->
320 |     ?assertEqual(<<0, 0, 0, 0, 29, 192, 158, 176>>, seconds_to_binary(499162800)).
321 | 
322 | %% Convert a 64-bit unsigned big-endian integer to seconds since the Unixtime
323 | %% Epoch.
324 | binary_to_seconds_test() ->
325 |     ?assertEqual(499162800, binary_to_seconds(<<0, 0, 0, 0, 29, 192, 158, 176>>)).
326 | 
327 | block_encrypt_test() ->
328 |     Key = <<247, 144, 176, 162, 38, 188, 150, 169, 45, 228, 155, 94, 156, 5, 225, 238>>,
329 |     Message = <<104, 101, 108, 108, 111, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11>>,
330 |     IV = <<0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15>>,
331 |     ?assertEqual(<<45, 54, 213, 202, 70, 85, 98, 153, 253, 225, 48, 8, 99, 56, 4, 178>>,
332 |                  block_encrypt(Key, IV, Message)).
333 | 
334 | -spec base16(binary()) -> <<_:_*16>>.
335 | base16(Data) ->
336 |     << <<(hex(N div 16)), (hex(N rem 16))>> || <> <= Data >>.
337 | 
338 | hex(N) when N < 10 ->
339 |     N + $0;
340 | hex(N) when N < 16 ->
341 |     N - 10 + $a.
342 | 
343 | %% [
344 | %%   {
345 | %%     "token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
346 | %%     "now": "1985-10-26T01:20:01-07:00",
347 | %%     "ttl_sec": 60,
348 | %%     "src": "hello",
349 | %%     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
350 | %%   }
351 | %% ]
352 | 
353 | verify_and_decrypt_token_test() ->
354 |     Token =
355 |         "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
356 |     TTL = 60,
357 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
358 |     Now = 499162800,
359 |     {ok, Message} = verify_and_decrypt_token(Token, Secret, TTL, Now),
360 |     ?assertEqual(<<"hello">>, Message).
361 | 
362 | verify_and_decrypt_token_expired_ttl_test() ->
363 |     Token =
364 |         "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
365 |     TTL = 60,
366 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
367 |     Now = 499162800 + 121,
368 |     {error, too_old} = verify_and_decrypt_token(Token, Secret, TTL, Now).
369 | 
370 | verify_and_decrypt_token_too_new_ttl_test() ->
371 |     Token =
372 |         "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
373 |     TTL = 60,
374 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
375 |     Now = 499162800 - 70,
376 |     {error, too_new} = verify_and_decrypt_token(Token, Secret, TTL, Now).
377 | 
378 | verify_and_decrypt_token_ignore_ttl_test() ->
379 |     Token =
380 |         "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
381 |     TTL = infinity,
382 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
383 |     Now = 499162800 - 70,
384 |     {ok, <<"hello">>} = verify_and_decrypt_token(Token, Secret, TTL, Now).
385 | 
386 | verify_and_decrypt_token_invalid_version_test() ->
387 |     Token =
388 |         "gQAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLKY7covSkDHw9ma-418Z5yfJ0bAi-R_TUVpW6VSXlO8JA==",
389 |     TTL = 60,
390 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
391 |     Now = 499162800,
392 |     {error, bad_version} = verify_and_decrypt_token(Token, Secret, TTL, Now).
393 | 
394 | %% From https://github.com/fernet/spec/blob/master/invalid.json
395 | 
396 | invalid_incorrect_mac_test() ->
397 |     Token =
398 |         "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykQUFBQUFBQUFBQQ==",
399 |     Now = 499162800,
400 |     TTL = 60,
401 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
402 |     ?assertEqual({error, incorrect_mac}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
403 | 
404 | invalid_too_short_test() ->
405 |     Token = "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPA==",
406 |     Now = 499162800,
407 |     TTL = 60,
408 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
409 |     ?assertEqual({error, too_short}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
410 | 
411 | invalid_invalid_base64_test() ->
412 |     Token =
413 |         "%%%%%%%%%%%%%AECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==",
414 |     Now = 499162800,
415 |     TTL = 60,
416 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
417 |     ?assertEqual({error, invalid_base64}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
418 | 
419 | invalid_payload_size_to_block_size_test() ->
420 |     Token =
421 |         "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPOm73QeoCk9uGib28Xe5vz6oxq5nmxbx_v7mrfyudzUm",
422 |     Now = 499162800,
423 |     TTL = 60,
424 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
425 |     ?assertEqual({error, payload_size_not_multiple_of_block_size},
426 |                  verify_and_decrypt_token(Token, Secret, TTL, Now)).
427 | 
428 | invalid_payload_padding_test() ->
429 |     Token =
430 |         "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0ODz4LEpdELGQAad7aNEHbf-JkLPIpuiYRLQ3RtXatOYREu2FWke6CnJNYIbkuKNqOhw==",
431 |     Now = 499162800,
432 |     TTL = 60,
433 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
434 |     ?assertEqual({error, payload_padding}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
435 | 
436 | invalid_far_future_skew_test() ->
437 |     Token =
438 |         "gAAAAAAdwStRAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAnja1xKYyhd-Y6mSkTOyTGJmw2Xc2a6kBd-iX9b_qXQcw==",
439 |     Now = 499162800,
440 |     TTL = 60,
441 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
442 |     ?assertEqual({error, too_new}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
443 | 
444 | invalid_expired_ttl_test() ->
445 |     Token =
446 |         "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==",
447 |     Now = 499162800 + 90,
448 |     TTL = 60,
449 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
450 |     ?assertEqual({error, too_old}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
451 | 
452 | invalid_incorrect_iv_test() ->
453 |     %% An invalid IV causes a padding error!
454 |     Token =
455 |         "gAAAAAAdwJ6xBQECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAkLhFLHpGtDBRLRTZeUfWgHSv49TF2AUEZ1TIvcZjK1zQ=",
456 |     Now = 499162800,
457 |     TTL = 60,
458 |     Secret = "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4=",
459 |     ?assertEqual({error, payload_padding}, verify_and_decrypt_token(Token, Secret, TTL, Now)).
460 | 
461 | -endif.
462 | 


--------------------------------------------------------------------------------
/test/fernet_spec_SUITE.erl:
--------------------------------------------------------------------------------
 1 | -module(fernet_spec_SUITE).
 2 | 
 3 | -behaviour(ct_suite).
 4 | 
 5 | -include_lib("eunit/include/eunit.hrl").
 6 | -include_lib("common_test/include/ct.hrl").
 7 | 
 8 | -export([all/0, verify_test/1, invalid_test/1, generate_test/1]).
 9 | 
10 | -define(EPOCH_OFFSET, 62167219200).
11 | 
12 | %% Specific test cases or groups to run.  The test case is named as a
13 | %% single atom.  Groups are named as {group, GroupName}.  The tests
14 | %% will run in the order given in the list.
15 | all() ->
16 |     [verify_test, invalid_test, generate_test].
17 | 
18 | %%====================================================================
19 | %% Setup and teardown
20 | %%====================================================================
21 | 
22 | read_fixture_file(Config, Filename) ->
23 |     DataDir = ?config(data_dir, Config),
24 |     file:read_file(
25 |         filename:join(DataDir, Filename)).
26 | 
27 | timestamp_to_seconds(Timestamp) ->
28 |     DateTime = ec_date:parse(binary_to_list(Timestamp)),
29 |     {Date, {Hours, Minutes, Seconds, _Offset}} = DateTime,
30 |     Time = {Hours, Minutes, Seconds},
31 |     calendar:datetime_to_gregorian_seconds({Date, Time}) - ?EPOCH_OFFSET.
32 | 
33 | %%====================================================================
34 | %% Tests
35 | %%====================================================================
36 | 
37 | verify_test(Config) ->
38 |     {ok, Raw} = read_fixture_file(Config, "verify.json"),
39 |     lists:foreach(fun(V) ->
40 |                      Src = maps:get(src, V),
41 |                      Now = timestamp_to_seconds(maps:get(now, V)),
42 |                      {ok, Src} =
43 |                          fernet:verify_and_decrypt_token(
44 |                              maps:get(token, V), maps:get(secret, V), maps:get(ttl_sec, V), Now)
45 |                   end,
46 |                   jsx:decode(Raw, [{labels, attempt_atom}, return_maps])),
47 |     Config.
48 | 
49 | invalid_test(Config) ->
50 |     {ok, Raw} = read_fixture_file(Config, "invalid.json"),
51 |     lists:foreach(fun(V) ->
52 |                      Now = timestamp_to_seconds(maps:get(now, V)),
53 |                      %% Note, this doesn't check the desc field.
54 |                      {error, _} =
55 |                          fernet:verify_and_decrypt_token(
56 |                              maps:get(token, V), maps:get(secret, V), maps:get(ttl_sec, V), Now)
57 |                   end,
58 |                   jsx:decode(Raw, [{labels, attempt_atom}, return_maps])),
59 |     Config.
60 | 
61 | generate_test(Config) ->
62 |     {ok, Raw} = read_fixture_file(Config, "generate.json"),
63 |     lists:foreach(fun(V) ->
64 |                      Now = timestamp_to_seconds(maps:get(now, V)),
65 |                      Token = maps:get(token, V),
66 |                      Token =
67 |                          fernet:generate_token(
68 |                              maps:get(src, V),
69 |                              list_to_binary(maps:get(iv, V)),
70 |                              Now,
71 |                              maps:get(secret, V))
72 |                   end,
73 |                   jsx:decode(Raw, [{labels, attempt_atom}, return_maps])),
74 |     Config.
75 | 


--------------------------------------------------------------------------------
/test/fernet_spec_SUITE_data/generate.json:
--------------------------------------------------------------------------------
 1 | [
 2 |   {
 3 |     "token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
 4 |     "now": "1985-10-26T01:20:00-07:00",
 5 |     "iv": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
 6 |     "src": "hello",
 7 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
 8 |   }
 9 | ]
10 | 


--------------------------------------------------------------------------------
/test/fernet_spec_SUITE_data/invalid.json:
--------------------------------------------------------------------------------
 1 | [
 2 |   {
 3 |     "desc": "incorrect mac",
 4 |     "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykQUFBQUFBQUFBQQ==",
 5 |     "now": "1985-10-26T01:20:01-07:00",
 6 |     "ttl_sec": 60,
 7 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
 8 |   },
 9 |   {
10 |     "desc": "too short",
11 |     "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPA==",
12 |     "now": "1985-10-26T01:20:01-07:00",
13 |     "ttl_sec": 60,
14 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
15 |   },
16 |   {
17 |     "desc": "invalid base64",
18 |     "token": "%%%%%%%%%%%%%AECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==",
19 |     "now": "1985-10-26T01:20:01-07:00",
20 |     "ttl_sec": 60,
21 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
22 |   },
23 |   {
24 |     "desc": "payload size not multiple of block size",
25 |     "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPOm73QeoCk9uGib28Xe5vz6oxq5nmxbx_v7mrfyudzUm",
26 |     "now": "1985-10-26T01:20:01-07:00",
27 |     "ttl_sec": 60,
28 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
29 |   },
30 |   {
31 |     "desc": "payload padding error",
32 |     "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0ODz4LEpdELGQAad7aNEHbf-JkLPIpuiYRLQ3RtXatOYREu2FWke6CnJNYIbkuKNqOhw==",
33 |     "now": "1985-10-26T01:20:01-07:00",
34 |     "ttl_sec": 60,
35 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
36 |   },
37 |   {
38 |     "desc": "far-future TS (unacceptable clock skew)",
39 |     "token": "gAAAAAAdwStRAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAnja1xKYyhd-Y6mSkTOyTGJmw2Xc2a6kBd-iX9b_qXQcw==",
40 |     "now": "1985-10-26T01:20:01-07:00",
41 |     "ttl_sec": 60,
42 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
43 |   },
44 |   {
45 |     "desc": "expired TTL",
46 |     "token": "gAAAAAAdwJ6xAAECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAl1-szkFVzXTuGb4hR8AKtwcaX1YdykRtfsH-p1YsUD2Q==",
47 |     "now": "1985-10-26T01:21:31-07:00",
48 |     "ttl_sec": 60,
49 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
50 |   },
51 |   {
52 |     "desc": "incorrect IV (causes padding error)",
53 |     "token": "gAAAAAAdwJ6xBQECAwQFBgcICQoLDA0OD3HkMATM5lFqGaerZ-fWPAkLhFLHpGtDBRLRTZeUfWgHSv49TF2AUEZ1TIvcZjK1zQ==",
54 |     "now": "1985-10-26T01:20:01-07:00",
55 |     "ttl_sec": 60,
56 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
57 |   }
58 | ]
59 | 


--------------------------------------------------------------------------------
/test/fernet_spec_SUITE_data/verify.json:
--------------------------------------------------------------------------------
 1 | [
 2 |   {
 3 |     "token": "gAAAAAAdwJ6wAAECAwQFBgcICQoLDA0ODy021cpGVWKZ_eEwCGM4BLLF_5CV9dOPmrhuVUPgJobwOz7JcbmrR64jVmpU4IwqDA==",
 4 |     "now": "1985-10-26T01:20:01-07:00",
 5 |     "ttl_sec": 60,
 6 |     "src": "hello",
 7 |     "secret": "cw_0x689RpI-jtRR7oE8h_eQsKImvJapLeSbXpwF4e4="
 8 |   }
 9 | ]
10 | 


--------------------------------------------------------------------------------