Skip to content

Commit

Permalink
feat(hb_http_signature): wip implement sign and verify functions #13
Browse files Browse the repository at this point in the history
  • Loading branch information
TillaTheHun0 committed Dec 10, 2024
1 parent 435419d commit 00f9585
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 46 deletions.
25 changes: 18 additions & 7 deletions src/ar_wallet.erl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
%%% @doc Utilities for manipulating wallets.
-module(ar_wallet).
-export([sign/2, verify/3, to_address/1, to_address/2, new/0, new/1]).
-export([sign/2, sign/3, hmac/1, hmac/2, verify/3, to_address/1, to_address/2, new/0, new/1]).
-export([new_keyfile/2, load_keyfile/1, load_key/1]).

-include("include/ar.hrl").
Expand All @@ -20,24 +20,35 @@ new(KeyType = {KeyAlg, PublicExpnt}) when KeyType =:= {rsa, 65537} ->
{{KeyType, Priv, Pub}, {KeyType, Pub}}.

%% @doc Sign some data with a private key.
sign({{rsa, PublicExpnt}, Priv, Pub}, Data)
when PublicExpnt =:= 65537 ->
sign(Key, Data) ->
sign(Key, Data, sha256).

%% @doc sign some data, hashed using the provided DigestType.
%% TODO: support signing for other key types
sign({{rsa, PublicExpnt}, Priv, Pub}, Data, DigestType) when PublicExpnt =:= 65537 ->
rsa_pss:sign(
Data,
sha256,
DigestType,
#'RSAPrivateKey'{
publicExponent = PublicExpnt,
modulus = binary:decode_unsigned(Pub),
privateExponent = binary:decode_unsigned(Priv)
}
).

hmac(Data) ->
hmac(Data, sha256).

hmac(Data, DigestType) -> crypto:mac(hmac, DigestType, <<"ar">>, Data).

%% @doc Verify that a signature is correct.
verify({{rsa, PublicExpnt}, Pub}, Data, Sig)
when PublicExpnt =:= 65537 ->
verify(Key, Data, Sig) ->
verify(Key, Data, Sig, sha256).

verify({{rsa, PublicExpnt}, Pub}, Data, Sig, DigestType) when PublicExpnt =:= 65537 ->
rsa_pss:verify(
Data,
sha256,
DigestType,
Sig,
#'RSAPublicKey'{
publicExponent = PublicExpnt,
Expand Down
167 changes: 128 additions & 39 deletions src/hb_http_signature.erl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-module(hb_http_signature).

-export([authority/3, sign/2, sign/3]).
-export([authority/3, sign/2, sign/3, verify/2, verify/3]).

% https://datatracker.ietf.org/doc/html/rfc9421#section-2.2.7-14
-define(EMPTY_QUERY_PARAMS, $?).
Expand Down Expand Up @@ -86,10 +86,19 @@
-spec authority(
[binary() | component_identifier()],
#{binary() => binary() | integer()},
binary()
{} %TODO: type out a key_pair
) -> authority_state().
authority(ComponentIdentifiers, SigParams, Key) ->
% TODO: overwrite keyid in SigParams given the Key?
authority(ComponentIdentifiers, SigParams, PubKey = {KeyType, _Pub}) when is_atom(KeyType) ->
authority(ComponentIdentifiers, SigParams, {{}, PubKey});
authority(ComponentIdentifiers, SigParams, PrivKey = {KeyType, _Priv, Pub}) when is_atom(KeyType) ->
authority(ComponentIdentifiers, SigParams, {PrivKey, {KeyType, Pub}});
authority(ComponentIdentifiers, SigParams, KeyPair = {_Priv, {_KeyType, PubKey}}) ->
% TODO: should we check if keyid if already set and throw if so?
% for now, just overwriting
%
% The keyid in the signature params will always be the public key
% of the signer
SigParamsWithKeyId = maps:put(keyid, PubKey, SigParams),
#{
% parse each component identifier into a Structured Field Item:
%
Expand All @@ -102,11 +111,12 @@ authority(ComponentIdentifiers, SigParams, Key) ->
component_identifiers => lists:map(fun sf_item/1, ComponentIdentifiers),
% TODO: add checks to allow only valid signature parameters
% https://datatracker.ietf.org/doc/html/rfc9421#name-signature-parameters
sig_params => SigParams,
key => Key
sig_params => SigParamsWithKeyId,
% TODO: validate the key is supported?
key_pair => KeyPair
}.

%%% @doc using the provided Authority and Request Message Context, create a Name, Signature and SignatureInput
%%% @doc using the provided Authority and Request Message Context, and create a Signature and SignatureInput
%%% that can be used to additional signatures to a corresponding HTTP Message
-spec sign(authority_state(), request_message()) -> {ok, {binary(), binary(), binary()}}.
sign(Authority, Req) ->
Expand All @@ -115,30 +125,68 @@ sign(Authority, Req) ->
%%% that can be used to additional signatures to a corresponding HTTP Message
-spec sign(authority_state(), request_message(), response_message()) -> {ok, {binary(), binary(), binary()}}.
sign(Authority, Req, Res) ->
ComponentIdentifiers = maps:get(component_identifiers, Authority),
SignatureComponentsLine = signature_components_line(ComponentIdentifiers, Req, Res),
SignatureParamsLine = signature_params_line(ComponentIdentifiers, maps:get(sig_params, Authority)),
SignatureBase = signature_base(SignatureComponentsLine, SignatureParamsLine),
SignatureInput = SignatureParamsLine,
% Create signature using SignatureBase and authority#key
Signature = create_signature(Authority, SignatureBase),
Name = random_an_binary(5),
{ok, {Name, SignatureInput, Signature}}.

%%% @doc perform the actual signing of the signature base, using the provided key
%%% TODO: needs to be implemented
create_signature(Authority, SignatureBase) ->
Key = maps:get(key, Authority),
% TODO: implement
Signature = <<"SIGNED", SignatureBase/binary>>,
Signature.
{Priv, _Pub} = maps:get(key_pair, Authority),
{SignatureInput, SignatureBase} = signature_base(Authority, Req, Res),
Signature = ar_wallet:sign(Priv, SignatureBase, sha512),
{ok, {SignatureInput, Signature}}.

%%% @doc same verify/3, but with an empty Request Message Context
verify(Verifier, Msg) ->
% Assume that the Msg is a response message, and use an empty Request message context
%
% A corollary is that a signature containing any components from the request will produce
% an error. It is the caller's responsibility to provide the required Message Context
% in order to verify the signature
verify(Verifier, #{}, Msg).

%%% @doc Given the signature name, and the Request/Response Message Context
%%% verify the named signature by constructing the signature base and comparing
verify(#{ sig_name := SigName, key := Key }, Req, Res) ->
% Signature and Signature-Input fields are each themself a dictionary structured field.
% Ergo, we can use our same utilities to extract the value at the desired key, in this case,
% the signature name. Because our utilities already implement the relevant portions
% of RFC-9421, we get the error handling here as well.
%
% See https://datatracker.ietf.org/doc/html/rfc9421#section-3.2-3.2
SigNameParams = [{<<"key">>}, {string, bin(SigName)}],
SignatureIdentifier = {item, {string, <<"signature">>}, SigNameParams},
SignatureInputIdentifier = {item, {string, <<"signature-input">>}, SigNameParams},
% extract signature and signature params
case {extract_field(SignatureIdentifier, Req, Res), extract_field(SignatureInputIdentifier, Req, Res)} of
{{ok, Signature}, {ok, SignatureInput}} ->
% The value encoded within signature input is also a structured field,
% that encodes the ComponentIdentifiers and the Signature Params.
%
% So we parse this value, and then use it to construct out signature base
{list, ComponentIdentifiers, SigParams} = hb_http_structured_fields:list(SignatureInput),
Authority = authority(ComponentIdentifiers, SigParams, Key),
{_, SignatureBase} = signature_base(Authority, Req, Res),
{_Priv, Pub} = maps:get(key_pair, Authority),
% Now verify the signature base signed with the provided key matches the signature
ar_wallet:verify(Pub, SignatureBase, Signature);
% An issue with parsing the signature
{SignatureErr, {ok, _}} -> SignatureErr;
% An issue with parsing the signature input
{{ok, _}, SignatureInputErr} -> SignatureInputErr;
% An issue with parsing both, so just return the first one from the signature parsing
% TODO: maybe could merge the errors?
{SignatureErr, _} -> SignatureErr
end.

%%% @doc create the signature base that will be signed in order to create the Signature and SignatureInput.
%%%
%%% This implements a portion of RFC-9421
%%% See https://datatracker.ietf.org/doc/html/rfc9421#name-creating-the-signature-base
signature_base(ComponentsLine, ParamsLine) ->
<<ComponentsLine/binary, <<"\n">>/binary, <<"\"@signature-params\": ">>/binary, ParamsLine/binary>>.
signature_base(Authority, Req, Res) when is_map(Authority) ->
ComponentIdentifiers = maps:get(component_identifiers, Authority),
ComponentsLine = signature_components_line(ComponentIdentifiers, Req, Res),
ParamsLine = signature_params_line(ComponentIdentifiers, maps:get(sig_params, Authority)),
SignatureBase = <<ComponentsLine/binary, <<"\n">>/binary, <<"\"@signature-params\": ">>/binary, ParamsLine/binary>>,
{ParamsLine, SignatureBase}.

join_signature_base(ComponentsLine, ParamsLine) ->
SignatureBase = <<ComponentsLine/binary, <<"\n">>/binary, <<"\"@signature-params\": ">>/binary, ParamsLine/binary>>,
SignatureBase.

%%% @doc Given a list of Component Identifiers and a Request/Response Message context, create the
%%% "signature-base-line" portion of the signature base
Expand Down Expand Up @@ -557,13 +605,6 @@ bin(Item) when is_integer(Item) ->
bin(Item) ->
iolist_to_binary(Item).

random_an_binary(Length) ->
Characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
ListLength = length(Characters),
RandomIndexes = [rand:uniform(ListLength) || _ <- lists:seq(1, Length)],
RandomChars = [lists:nth(Index, Characters) || Index <- RandomIndexes],
list_to_binary(RandomChars).

%%% @doc Recursively trim space characters from the beginning of the binary
trim_ws(<<$\s, Bin/bits>>) -> trim_ws(Bin);
%%% @doc No space characters at the beginning so now trim them from the end
Expand Down Expand Up @@ -618,20 +659,68 @@ sign_test() ->
{item, {string, <<"foo">>}, [{<<"req">>, true}]},
"\"foo\";key=\"a\""
],
SigParams = #{created => 1733165109501, nonce => "foobar", keyid => "key1"},
Authority = authority(ComponentIdentifiers, SigParams, <<"foo-key">>),
SigParams = #{},
Key = hb:wallet(),
Authority = authority(ComponentIdentifiers, SigParams, Key),

?assertMatch(
{ok, {_SignatureInput, _Signature}},
sign(Authority, Req, Res)
),
ok.

{ok, {_Name, SignatureInput, Signature}} = sign(Authority, Req, Res),
% TODO: assertions on Signature once signing is implemented
verify_test() ->
Req = #{
url => <<"https://foo.bar/id-123/Data?another=one&fizz=buzz">>,
method => "get",
headers => #{
<<"foo">> => <<"req-b-bar">>
},
trailers => #{}
},
Res = #{
status => 202,
headers => #{
"fizz" => "res-l-bar",
<<"Foo">> => "a=1, b=2;x=1;y=2, c=(a b c), d"
},
trailers => #{}
},
ComponentIdentifiers = [
{item, {string, <<"@method">>}, []},
<<"\"@path\"">>,
{item, {string, <<"foo">>}, [{<<"req">>, true}]},
"\"foo\";key=\"a\""
],
SigParams = #{},
Key = {_Priv, Pub} = hb:wallet(),
Authority = authority(ComponentIdentifiers, SigParams, Key),

% Create the signature and signature input
{ok, {SignatureInput, Signature}} = sign(Authority, Req, Res),

SigName = <<"awesome">>,
NewHeaders = maps:merge(
maps:get(headers, Res),
#{
<<"signature">> => hb_http_structured_fields:dictionary(#{ SigName => {item, {string, Signature}, []} }),
<<"signature-input">> => hb_http_structured_fields:dictionary(#{ SigName => {item, {string, SignatureInput}, []} })
}
),

SignedRes = maps:put(headers, NewHeaders, Res),

Result = verify(#{ sig_name => SigName, key => Pub }, Req, SignedRes),
erlang:display(Result),
ok.

signature_base_test() ->
join_signature_base_test() ->
ParamsLine =
<<"(\"@method\" \"@path\" \"foo\";req \"foo\";key=\"a\");created=1733165109501;nonce=\"foobar\";keyid=\"key1\"">>,
ComponentsLine = <<"\"@method\": GET\n\"@path\": /id-123/Data\n\"foo\";req: req-b-bar\n\"foo\";key=\"a\": 1">>,
?assertEqual(
<<ComponentsLine/binary, <<"\n">>/binary, <<"\"@signature-params\": ">>/binary, ParamsLine/binary>>,
signature_base(ComponentsLine, ParamsLine)
join_signature_base(ComponentsLine, ParamsLine)
).

signature_components_line_test() ->
Expand Down

0 comments on commit 00f9585

Please sign in to comment.