Packages

Fernet and Branca token encryption for Gleam

Current section

Files

Jump to
amaro src amaro@fernet.erl
Raw

src/amaro@fernet.erl

-module(amaro@fernet).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/amaro/fernet.gleam").
-export([key_to_string/1, generate_key/0, key_from_string/1, encrypt_with/4, encrypt/2, decrypt/2, decrypt_with_ttl/3]).
-export_type([key/0, error/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Encrypt and decrypt [Fernet](https://github.com/fernet/spec) tokens.\n"
"\n"
" Fernet tokens are authenticated and encrypted using AES-128-CBC and\n"
" HMAC-SHA256. Tokens are base64url-encoded and safe for use in URLs,\n"
" headers, and cookies.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" let key = fernet.generate_key()\n"
" let token = fernet.encrypt(key, plaintext: <<\"hello\":utf8>>)\n"
" let assert Ok(plaintext) = fernet.decrypt(key, token:)\n"
" ```\n"
).
-opaque key() :: {key, bitstring()}.
-type error() :: invalid_key |
invalid_token |
invalid_version |
invalid_signature |
token_expired |
decryption_failed.
-file("src/amaro/fernet.gleam", 78).
?DOC(" Encode a key as a base64url string with padding.\n").
-spec key_to_string(key()) -> binary().
key_to_string(Key) ->
gleam@bit_array:base64_url_encode(erlang:element(2, Key), true).
-file("src/amaro/fernet.gleam", 216).
-spec guard_ttl(
integer(),
gleam@option:option(gleam@time@duration:duration()),
fun(() -> {ok, bitstring()} | {error, error()})
) -> {ok, bitstring()} | {error, error()}.
guard_ttl(Token_timestamp, Ttl, Next) ->
case Ttl of
none ->
Next();
{some, Max_age} ->
Now = gleam@time@timestamp:system_time(),
Token_time = gleam@time@timestamp:from_unix_seconds(Token_timestamp),
Age = gleam@time@timestamp:difference(Token_time, Now),
case gleam@time@duration:compare(Age, Max_age) of
gt ->
{error, token_expired};
eq ->
Next();
lt ->
Next()
end
end.
-file("src/amaro/fernet.gleam", 242).
-spec guard_tag(
bitstring(),
bitstring(),
bitstring(),
fun(() -> {ok, bitstring()} | {error, error()})
) -> {ok, bitstring()} | {error, error()}.
guard_tag(Signing_key, Payload, Expected, Next) ->
case kryptos@hmac:verify(sha256, Signing_key, Payload, Expected) of
{ok, true} ->
Next();
{ok, false} ->
{error, invalid_signature};
{error, nil} ->
{error, invalid_signature}
end.
-file("src/amaro/fernet.gleam", 206).
-spec guard_version(integer(), fun(() -> {ok, bitstring()} | {error, error()})) -> {ok,
bitstring()} |
{error, error()}.
guard_version(Token_version, Next) ->
case Token_version =:= 16#80 of
true ->
Next();
false ->
{error, invalid_version}
end.
-file("src/amaro/fernet.gleam", 64).
?DOC(" Generate a random Fernet key using a cryptographically secure RNG.\n").
-spec generate_key() -> key().
generate_key() ->
{key, kryptos_ffi:random_bytes(32)}.
-file("src/amaro/fernet.gleam", 70).
?DOC(
" Decode a key from a base64url-encoded string. Returns `InvalidKey` if the\n"
" string is not valid base64url or does not decode to exactly 32 bytes.\n"
).
-spec key_from_string(binary()) -> {ok, key()} | {error, error()}.
key_from_string(Encoded) ->
case gleam@bit_array:base64_url_decode(Encoded) of
{ok, <<Data:32/binary>>} ->
{ok, {key, Data}};
_ ->
{error, invalid_key}
end.
-file("src/amaro/fernet.gleam", 235).
-spec split_key(key()) -> {bitstring(), bitstring()}.
split_key(Key) ->
Signing_key@1 = case gleam_stdlib:bit_array_slice(
erlang:element(2, Key),
0,
16
) of
{ok, Signing_key} -> Signing_key;
_assert_fail ->
erlang:error(#{gleam_error => let_assert,
message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"amaro/fernet"/utf8>>,
function => <<"split_key"/utf8>>,
line => 236,
value => _assert_fail,
start => 6528,
'end' => 6603,
pattern_start => 6539,
pattern_end => 6554})
end,
Encryption_key@1 = case gleam_stdlib:bit_array_slice(
erlang:element(2, Key),
16,
16
) of
{ok, Encryption_key} -> Encryption_key;
_assert_fail@1 ->
erlang:error(#{gleam_error => let_assert,
message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"amaro/fernet"/utf8>>,
function => <<"split_key"/utf8>>,
line => 237,
value => _assert_fail@1,
start => 6606,
'end' => 6703,
pattern_start => 6617,
pattern_end => 6635})
end,
{Signing_key@1, Encryption_key@1}.
-file("src/amaro/fernet.gleam", 92).
?DOC(false).
-spec encrypt_with(
key(),
bitstring(),
gleam@time@timestamp:timestamp(),
bitstring()
) -> {ok, binary()} | {error, error()}.
encrypt_with(Key, Plaintext, Now, Iv) ->
{Signing_key, Encryption_key} = split_key(Key),
{Seconds, _} = gleam@time@timestamp:to_unix_seconds_and_nanoseconds(Now),
gleam@result:'try'(
begin
_pipe = kryptos@block:aes_128(Encryption_key),
gleam@result:replace_error(_pipe, invalid_key)
end,
fun(Cipher) ->
gleam@result:'try'(
begin
_pipe@1 = kryptos@block:cbc(Cipher, Iv),
gleam@result:replace_error(_pipe@1, invalid_token)
end,
fun(Ctx) ->
gleam@result:'try'(
begin
_pipe@2 = kryptos@block:encrypt(Ctx, Plaintext),
gleam@result:replace_error(
_pipe@2,
decryption_failed
)
end,
fun(Ciphertext) ->
Payload = <<16#80,
Seconds:64/big,
Iv/bitstring,
Ciphertext/bitstring>>,
gleam@result:'try'(
begin
_pipe@3 = kryptos@hmac:new(
sha256,
Signing_key
),
gleam@result:replace_error(
_pipe@3,
invalid_signature
)
end,
fun(H) ->
Mac = begin
_pipe@4 = H,
_pipe@5 = crypto:mac_update(
_pipe@4,
Payload
),
crypto:mac_final(_pipe@5)
end,
{ok,
gleam@bit_array:base64_url_encode(
<<Payload/bitstring, Mac/bitstring>>,
true
)}
end
)
end
)
end
)
end
).
-file("src/amaro/fernet.gleam", 84).
?DOC(
" Encrypt plaintext into a Fernet token string. The current system time is\n"
" recorded in the token and a random IV is generated for each call.\n"
).
-spec encrypt(key(), bitstring()) -> binary().
encrypt(Key, Plaintext) ->
Now = gleam@time@timestamp:system_time(),
Iv = kryptos_ffi:random_bytes(16),
Token@1 = case encrypt_with(Key, Plaintext, Now, Iv) of
{ok, Token} -> Token;
_assert_fail ->
erlang:error(#{gleam_error => let_assert,
message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
file => <<?FILEPATH/utf8>>,
module => <<"amaro/fernet"/utf8>>,
function => <<"encrypt"/utf8>>,
line => 87,
value => _assert_fail,
start => 2610,
'end' => 2673,
pattern_start => 2621,
pattern_end => 2630})
end,
Token@1.
-file("src/amaro/fernet.gleam", 186).
-spec parse_token(bitstring()) -> {ok,
{integer(), integer(), bitstring(), bitstring(), bitstring()}} |
{error, error()}.
parse_token(Data) ->
gleam@bool:guard(
erlang:byte_size(Data) < 73,
{error, invalid_token},
fun() ->
Ciphertext_size = (((erlang:byte_size(Data) - 1) - 8) - 16) - 32,
case Data of
<<Version/integer,
Timestamp:64/big,
Iv:16/binary,
Ciphertext:Ciphertext_size/binary,
Tag:32/binary>> ->
{ok, {Version, Timestamp, Iv, Ciphertext, Tag}};
_ ->
{error, invalid_token}
end
end
).
-file("src/amaro/fernet.gleam", 147).
-spec do_decrypt(
key(),
binary(),
gleam@option:option(gleam@time@duration:duration())
) -> {ok, bitstring()} | {error, error()}.
do_decrypt(Key, Token, Ttl) ->
gleam@result:'try'(
begin
_pipe = gleam@bit_array:base64_url_decode(Token),
gleam@result:replace_error(_pipe, invalid_token)
end,
fun(Data) ->
gleam@result:'try'(
parse_token(Data),
fun(_use0) ->
{Token_version, Token_timestamp, Iv, Ciphertext, Tag} = _use0,
guard_version(
Token_version,
fun() ->
guard_ttl(
Token_timestamp,
Ttl,
fun() ->
{Signing_key, Encryption_key} = split_key(
Key
),
Payload_size = erlang:byte_size(Data) - 32,
gleam@result:'try'(
begin
_pipe@1 = gleam_stdlib:bit_array_slice(
Data,
0,
Payload_size
),
gleam@result:replace_error(
_pipe@1,
invalid_token
)
end,
fun(Payload) ->
guard_tag(
Signing_key,
Payload,
Tag,
fun() ->
gleam@result:'try'(
begin
_pipe@2 = kryptos@block:aes_128(
Encryption_key
),
gleam@result:replace_error(
_pipe@2,
decryption_failed
)
end,
fun(Cipher) ->
gleam@result:'try'(
begin
_pipe@3 = kryptos@block:cbc(
Cipher,
Iv
),
gleam@result:replace_error(
_pipe@3,
decryption_failed
)
end,
fun(Ctx) ->
_pipe@4 = kryptos@block:decrypt(
Ctx,
Ciphertext
),
gleam@result:replace_error(
_pipe@4,
decryption_failed
)
end
)
end
)
end
)
end
)
end
)
end
)
end
)
end
).
-file("src/amaro/fernet.gleam", 132).
?DOC(
" Decrypt a Fernet token and return the original plaintext. The token's HMAC\n"
" is verified before decryption. No expiry check is performed.\n"
).
-spec decrypt(key(), binary()) -> {ok, bitstring()} | {error, error()}.
decrypt(Key, Token) ->
do_decrypt(Key, Token, none).
-file("src/amaro/fernet.gleam", 139).
?DOC(
" Decrypt a Fernet token, rejecting it if its age exceeds `ttl`. Age is\n"
" measured as the difference between the current system time and the\n"
" timestamp embedded in the token.\n"
).
-spec decrypt_with_ttl(key(), binary(), gleam@time@duration:duration()) -> {ok,
bitstring()} |
{error, error()}.
decrypt_with_ttl(Key, Token, Ttl) ->
do_decrypt(Key, Token, {some, Ttl}).