diff --git a/src/preloaded/codec/dev_httpsig_keyid.erl b/src/core/lib/lib_httpsig_keyid.erl similarity index 99% rename from src/preloaded/codec/dev_httpsig_keyid.erl rename to src/core/lib/lib_httpsig_keyid.erl index 262797339..194f0f365 100644 --- a/src/preloaded/codec/dev_httpsig_keyid.erl +++ b/src/core/lib/lib_httpsig_keyid.erl @@ -8,7 +8,7 @@ %%% %%% These functions are abstracted in order to allow for the addition of new %%% schemes in the future. --module(dev_httpsig_keyid). +-module(lib_httpsig_keyid). -export([req_to_key_material/2, keyid_to_committer/1, keyid_to_committer/2]). -include_lib("include/hb.hrl"). diff --git a/src/core/resolver/hb_message.erl b/src/core/resolver/hb_message.erl index 6da578938..92034310f 100644 --- a/src/core/resolver/hb_message.erl +++ b/src/core/resolver/hb_message.erl @@ -62,6 +62,7 @@ -export([with_only_committers/2, with_only_committers/3, commitment_devices/2]). -export([verify/1, verify/2, verify/3, paranoid_verify/2, paranoid_verify/3]). -export([commit/2, commit/3, signers/2, type/1, minimize/1]). +-export([verified_committers/2, verified_committers/3]). -export([normalize_commitments/2, normalize_commitments/3, is_signed_key/3]). -export([commitment/2, commitment/3, commitments/3]). -export([with_only_committed/2, without_unless_signed/3]). @@ -673,6 +674,107 @@ signers(Msg, Opts) -> hb_ao:raw(<<"message@1.0">>, <<"committers">>, Msg, #{}, Opts) ). +%% @doc Return committers whose commitments verify and cover the required keys. +verified_committers(Request, Opts) -> + verified_committers(Request, [], Opts). + +verified_committers(Request, RequiredKeys, Opts) -> + Commitments = hb_maps:get(<<"commitments">>, Request, #{}, Opts), + lists:filtermap( + fun({CommitmentID, Commitment}) -> + LoadedCommitment = hb_cache:ensure_all_loaded(Commitment, Opts), + case valid_commitment( + Request, + CommitmentID, + LoadedCommitment, + RequiredKeys, + Opts + ) of + {true, Committer} -> {true, Committer}; + false -> false + end + end, + hb_maps:to_list(Commitments, Opts) + ). + +valid_commitment( + Request, + CommitmentID, + Commitment = + #{ + <<"commitment-device">> := _CommitmentDevice + }, + RequiredKeys, + Opts) -> + Committed = commitment_keys(Commitment, Opts), + Present = committed_keys_present(Committed, RequiredKeys), + case Present andalso verify_commitment(Request, CommitmentID, Opts) of + true -> + case verified_committer(Commitment, Opts) of + undefined -> false; + Committer -> {true, Committer} + end; + false -> false + end; +valid_commitment(_Request, _CommitmentID, _Commitment, _RequiredKeys, _Opts) -> + false. + +%% @doc Return the verified committer identity implied by a commitment. +verified_committer(Commitment, Opts) -> + case hb_maps:get(<<"keyid">>, Commitment, undefined, Opts) of + undefined -> + hb_maps:get(<<"committer">>, Commitment, undefined, Opts); + KeyID -> + case { + hb_maps:get(<<"commitment-device">>, Commitment, undefined, Opts), + hb_maps:get(<<"type">>, Commitment, undefined, Opts) + } of + {<<"ans104@1.0">>, Type} when Type =/= undefined -> + ans104_keyid_to_committer(KeyID, Type); + _ -> + lib_httpsig_keyid:keyid_to_committer(KeyID) + end + end. + +%% @doc Return the committer implied by an ANS-104 keyid and signature type. +ans104_keyid_to_committer(<<"publickey:", _/binary>> = KeyID, Type) -> + try + hb_util:human_id( + ar_wallet:to_address( + hb_util:decode(hb_util:remove_scheme_prefix(KeyID)), + ar_tx:deserialize_sig_type(Type) + ) + ) + catch _:_ -> + undefined + end; +ans104_keyid_to_committer(KeyID, _Type) -> + lib_httpsig_keyid:keyid_to_committer(KeyID). + +committed_keys_present(Committed, RequiredKeys) -> + lists:all( + fun(Key) -> lists:member(Key, Committed) end, + RequiredKeys + ). + +verify_commitment(Request, CommitmentID, Opts) -> + try + {ok, VerificationBase} = hb_message:with_only_committed(Request, Opts), + hb_message:verify( + VerificationBase, + #{ <<"commitment-ids">> => [CommitmentID] }, + Opts + ) + catch + _:_ -> false + end. + +commitment_keys(Commitment, Opts) -> + RawCommitted = maps:get(<<"committed">>, Commitment, []), + try hb_util:message_to_ordered_list(RawCommitted, Opts) + catch _:_ -> RawCommitted + end. + %% @doc Pretty-print a message. print(Msg) -> print(Msg, 0). print(Msg, Indent) -> diff --git a/src/preloaded/codec/dev_httpsig.erl b/src/preloaded/codec/dev_httpsig.erl index c7a8b7739..29cd423f7 100644 --- a/src/preloaded/codec/dev_httpsig.erl +++ b/src/preloaded/codec/dev_httpsig.erl @@ -85,7 +85,7 @@ verify(Base, Req, RawOpts) -> Opts = opts(RawOpts), {ok, EncMsg, EncComm, _} = normalize_for_encoding(Base, Req, Opts), SigBase = signature_base(EncMsg, EncComm, Opts), - KeyRes = dev_httpsig_keyid:req_to_key_material(Req, Opts), + KeyRes = lib_httpsig_keyid:req_to_key_material(Req, Opts), RawSignature = hb_util:decode(Signature = maps:get(<<"signature">>, Req)), ?event_debug(debug_httpsig, { @@ -211,8 +211,8 @@ commit(BaseMsg, Req = #{ <<"type">> := <<"hmac-sha256">> }, RawOpts) -> % Extract the key material from the request. Opts = opts(RawOpts), ?event_debug({req_to_key_material, {priv_req, Req}}), - {ok, Scheme, Key, KeyID} = dev_httpsig_keyid:req_to_key_material(Req, Opts), - Committer = dev_httpsig_keyid:keyid_to_committer(Scheme, KeyID), + {ok, Scheme, Key, KeyID} = lib_httpsig_keyid:req_to_key_material(Req, Opts), + Committer = lib_httpsig_keyid:keyid_to_committer(Scheme, KeyID), % Remove any existing hmac commitments with the given keyid before adding % the new one. Msg = diff --git a/src/preloaded/codec/dev_httpsig_siginfo.erl b/src/preloaded/codec/dev_httpsig_siginfo.erl index 4cbd41ca3..3a0c278b7 100644 --- a/src/preloaded/codec/dev_httpsig_siginfo.erl +++ b/src/preloaded/codec/dev_httpsig_siginfo.erl @@ -294,7 +294,7 @@ sf_siginfo_to_commitment(Msg, BodyKeys, SFSig, SFSigInput, Opts) -> end, KeyID = maps:get(<<"keyid">>, Commitment4, <<>>), Commitment5 = - case dev_httpsig_keyid:keyid_to_committer(KeyID) of + case lib_httpsig_keyid:keyid_to_committer(KeyID) of undefined -> Commitment4; Committer -> diff --git a/src/preloaded/message/dev_message.erl b/src/preloaded/message/dev_message.erl index 6244c86d6..23df511b5 100644 --- a/src/preloaded/message/dev_message.erl +++ b/src/preloaded/message/dev_message.erl @@ -1005,6 +1005,48 @@ test_verify(KeyType) -> ) ). +verified_committers_supports_httpsig_and_ed25519_test() -> + Msg = #{ <<"a">> => <<"b">> }, + RequiredKeys = [<<"a">>], + HTTPSigWallet = ar_wallet:new(?RSA_KEY_TYPE), + HTTPSigSigned = + hb_message:commit( + Msg, + #{ <<"priv-wallet">> => HTTPSigWallet }, + #{ <<"commitment-device">> => <<"httpsig@1.0">> } + ), + HTTPSigCommitters = + hb_message:verified_committers(HTTPSigSigned, RequiredKeys, #{}), + ?assert( + lists:member( + hb_util:human_id(ar_wallet:to_address(HTTPSigWallet)), + HTTPSigCommitters + ) + ), + Ed25519Wallet = ar_wallet:new(?EDDSA_KEY_TYPE), + Ed25519Signed = + hb_message:commit( + Msg, + #{ <<"priv-wallet">> => Ed25519Wallet }, + #{ + <<"commitment-device">> => <<"ans104@1.0">>, + <<"type">> => ?EDDSA_SIGN_TYPE + } + ), + Ed25519Committers = + hb_message:verified_committers(Ed25519Signed, RequiredKeys, #{}), + ?assert( + lists:member( + hb_util:human_id( + ar_wallet:to_address( + ar_wallet:to_pubkey(Ed25519Wallet), + ?EDDSA_KEY_TYPE + ) + ), + Ed25519Committers + ) + ). + set_nested_link_test() -> Opts = #{ <<"store">> => [hb_test_utils:test_store(hb_store_lmdb)] }, diff --git a/src/preloaded/node/dev_meta.erl b/src/preloaded/node/dev_meta.erl index 3c04eb1fd..ce70b4f3c 100644 --- a/src/preloaded/node/dev_meta.erl +++ b/src/preloaded/node/dev_meta.erl @@ -27,22 +27,22 @@ info(_) -> #{ exports => [<<"info">>, <<"build">>, <<"is-operator">>] }. %% @doc Utility function for determining if a request is from the `operator' of %% the node. is_operator(Request, NodeMsg) -> - RequestSigners = hb_message:signers(Request, NodeMsg), - Operator = - hb_opts:get( - operator, - case hb_opts:get(priv_wallet, no_viable_wallet, NodeMsg) of - no_viable_wallet -> unclaimed; - Wallet -> ar_wallet:to_address(Wallet) - end, - NodeMsg - ), - EncOperator = - case Operator of - unclaimed -> unclaimed; - NativeAddress -> hb_util:human_id(NativeAddress) - end, - EncOperator == unclaimed orelse lists:member(EncOperator, RequestSigners). + is_operator(Request, [], NodeMsg). + +is_operator(Request, RequiredKeys, NodeMsg) when is_list(RequiredKeys) -> + case operator(NodeMsg) of + unclaimed -> false; + EncOperator -> + VerifiedCommitters = + hb_message:verified_committers(Request, RequiredKeys, NodeMsg), + ?event( + {is_operator, + {enc_operator, EncOperator}, + {verified_committers, VerifiedCommitters} + } + ), + lists:member(EncOperator, VerifiedCommitters) + end; %% @doc Return whether the request in the body is signed by the node operator. is_operator(_Base, Req, NodeMsg) -> @@ -138,7 +138,12 @@ info(_, Request, NodeMsg) -> NodeMsg ); false -> - update_node_message(Request, NodeMsg) + % AO routing has narrowed the path to the resolved function, + % so restore the committed path before signature checks. + update_node_message( + Request#{ <<"path">> => <<"/~meta@1.0/info">> }, + NodeMsg + ) end; _ -> ?event({get_config_req, Request, NodeMsg}), @@ -424,39 +429,22 @@ is(Request, NodeMsg) -> is(operator, Request, NodeMsg). is(admin, Request, NodeMsg) -> % Does the caller have the right to change the node message? - RequestSigners = hb_message:signers(Request, NodeMsg), - ValidOperator = - hb_util:bin( - hb_opts:get( - operator, - case hb_opts:get(priv_wallet, no_viable_wallet, NodeMsg) of - no_viable_wallet -> unclaimed; - Wallet -> ar_wallet:to_address(Wallet) - end, - NodeMsg - ) - ), - EncOperator = - case ValidOperator of - <<"unclaimed">> -> unclaimed; - NativeAddress -> hb_util:human_id(NativeAddress) - end, + RequiredKeys = signed_request_keys(Request, NodeMsg), + ValidCommitters = + hb_message:verified_committers(Request, RequiredKeys, NodeMsg), + EncOperator = operator(NodeMsg), ?event({is, {operator, - {valid_operator, ValidOperator}, {encoded_operator, EncOperator}, - {request_signers, RequestSigners} + {request_signers, ValidCommitters} } }), - EncOperator == unclaimed orelse lists:member(EncOperator, RequestSigners); + case EncOperator of + unclaimed -> claims_operator(Request, ValidCommitters, NodeMsg); + _ -> lists:member(EncOperator, ValidCommitters) + end; is(operator, Req, NodeMsg) -> - % Is the caller explicitly set to be the operator? - % Get the operator from the node message - Operator = hb_opts:get(operator, unclaimed, NodeMsg), - % Get the request signers - RequestSigners = hb_message:signers(Req, NodeMsg), - % Ensure the operator is present in the request - lists:member(Operator, RequestSigners); + is_operator(Req, NodeMsg); is(initiator, Request, NodeMsg) -> % Is the caller the first identity that configured the node message? NodeHistory = hb_opts:get(node_history, [], NodeMsg), @@ -466,13 +454,15 @@ is(initiator, Request, NodeMsg) -> ?event(meta, {is_initiator, node_history, empty}), false; [InitializationRequest | _] -> - % Extract signature from first entry - InitializationRequestSigners = hb_message:signers(InitializationRequest, NodeMsg), - % Get request signers - RequestSigners = hb_message:signers(Request, NodeMsg), - % Ensure all signers of the initalization request are present in the + % Extract verified committers from first entry. + InitializationRequestSigners = + hb_message:verified_committers(InitializationRequest, [], NodeMsg), + % Get verified request committers. + RequestSigners = hb_message:verified_committers(Request, [], NodeMsg), + % Ensure all signers of the initialization request are present in the % request. AllSignersPresent = + InitializationRequestSigners =/= [] andalso lists:all( fun(Signer) -> lists:member(Signer, RequestSigners) end, InitializationRequestSigners @@ -488,6 +478,67 @@ is(initiator, Request, NodeMsg) -> end end. +operator(NodeMsg) -> + Operator = + hb_opts:get( + operator, + case hb_opts:get(priv_wallet, no_viable_wallet, NodeMsg) of + no_viable_wallet -> unclaimed; + Wallet -> ar_wallet:to_address(Wallet) + end, + NodeMsg + ), + encode_operator(Operator). + +encode_operator(Operator) -> + case hb_util:bin(Operator) of + <<"unclaimed">> -> unclaimed; + NativeAddress -> hb_util:human_id(NativeAddress) + end. + +signed_request_keys(Request, NodeMsg) when is_map(Request) -> + [ + Key + || + {Key, Value} <- hb_maps:to_list(Request, NodeMsg), + requires_signature(Key, Value) + ]; +signed_request_keys(_Request, _NodeMsg) -> + []. + +requires_signature(<<"body">>, <<>>) -> + false; +requires_signature(Key, _Value) -> + not lists:member(Key, transport_keys()). + +transport_keys() -> + [ + <<"accept">>, + <<"accept-bundle">>, + <<"ao-peer">>, + <<"ao-peer-port">>, + <<"commitments">>, + <<"content-digest">>, + <<"content-type">>, + <<"cookie">>, + <<"host">>, + <<"method">>, + <<"path">>, + <<"priv">>, + <<"signature">>, + <<"signature-input">>, + <<"ao-types">>, + <<"user-agent">> + ]. + +claims_operator(Request, ValidCommitters, NodeMsg) -> + case hb_maps:find(<<"operator">>, Request, NodeMsg) of + {ok, Operator} -> + lists:member(encode_operator(Operator), ValidCommitters); + error -> + false + end. + %%% Tests %% @doc Test that we can get the node message. @@ -537,6 +588,41 @@ unauthorized_set_node_msg_fails_test() -> ?assertEqual(not_found, hb_ao:get(<<"evil-config-item">>, Res, Opts)), ?assertEqual(0, length(hb_ao:get(<<"node-history">>, Res, [], Opts))). +forged_committer_set_node_msg_fails_test_parallel() -> + Owner = ar_wallet:new(), + Attacker = ar_wallet:new(), + OwnerAddr = hb_util:human_id(ar_wallet:to_address(Owner)), + Opts = #{ <<"priv-wallet">> => Owner }, + Signed = + hb_message:commit( + #{ + <<"path">> => <<"/~meta@1.0/info">>, + <<"evil-config-item">> => <<"BAD">> + }, + #{ <<"priv-wallet">> => Attacker } + ), + Forged = + Signed#{ + <<"commitments">> => + hb_maps:map( + fun(_ID, Commitment) -> + Commitment#{ <<"committer">> => OwnerAddr } + end, + hb_maps:get(<<"commitments">>, Signed, #{}, #{}) + ) + }, + ?assertEqual(false, is(admin, Forged, Opts)). + +unsigned_unclaimed_node_claim_fails_test_parallel() -> + Owner = ar_wallet:new(), + OwnerAddr = hb_util:human_id(ar_wallet:to_address(Owner)), + Req = + #{ + <<"path">> => <<"/~meta@1.0/info">>, + <<"operator">> => OwnerAddr + }, + ?assertEqual(false, is(admin, Req, #{ <<"operator">> => unclaimed })). + %% @doc Test that we can set the node message if the request is signed by the %% owner of the node. authorized_set_node_msg_succeeds_test() -> @@ -580,7 +666,7 @@ permanent_node_message_test() -> Owner = ar_wallet:new(), Node = hb_http_server:start_node( Opts =#{ - <<"operator">> => <<"unclaimed">>, + <<"operator">> => hb_util:human_id(ar_wallet:to_address(Owner)), <<"initialized">> => false, <<"test-config-item">> => <<"test">>, <<"store">> => StoreOpts