# Patch 4 of 5 ??? HTTP Layer > **Context for this agent**: This is part of a patchset adding optional TLS > to the Arweave p2p network. Nodes gossip TLS public keys (SPKI) via a signed > `GET /peers/keys` endpoint. Peers that learn a SPKI connect over TLS with > SPKI pinning. Fully backwards-compatible ??? nodes without TLS are unaffected. > Ask anything: "why did they do X?", "would approach Y work?", "explain Z differently." ## What This Patch Does Three existing files: | File | Change | |------|--------| | `ar_http.erl` | `open_connection/1` checks `ar_tls:peer_tls_info(Peer)`; known SPKI ??? TLS, unknown ??? plain HTTP | | `ar_http_iface_middleware.erl` | Adds `GET /peers/keys` handler: 404 if no TLS configured, 200+signed JSON otherwise | | `ar_http_iface_client.erl` | Adds `get_tls_peers/1` and `process_tls_peers_response/2` (with SPKI cross-check) | ## SPKI Cross-Check in `process_tls_peers_response` (Task 4) `ar_tls:verify_gossip_doc/1` verifies the signature against the key *in the document*. That proves the document wasn't forged, but not that the key hasn't been rotated by a compromised node. A bad actor could publish a new valid document signed with a new key, and a naive verifier would silently overwrite the stored SPKI. The cross-check: after verifying the signature, compare `NodeSPKI` against any previously pinned SPKI for that peer. If they differ, reject with `spki_mismatch`. ```erlang process_tls_peers_response(Peer, Body) -> case ar_tls:verify_gossip_doc(Body) of {error, _} -> {error, invalid_gossip_doc}; {ok, NodeSPKI} -> case ar_tls:peer_tls_info(Peer) of {ok, Pinned} when Pinned =/= NodeSPKI -> ?LOG_WARNING([{event, tls_gossip_spki_mismatch}, {peer, ar_util:format_peer(Peer)}]), {error, spki_mismatch}; _ -> ar_tls:set_peer_tls_info(Peer, NodeSPKI), %% ... extract and store peer list entries ... end end. ``` No pin yet (`not_found`) ??? accept and store. Pin matches ??? accept. Pin differs ??? reject. ## Norm-Check: `get_peers` (existing) vs `get_tls_peers` (new) Same `try/case` skeleton, same `p2p_headers()`, same timeouts order. Key difference: `get_peers` hard-crashes on non-200 (the `=` match inside `begin`); `get_tls_peers` explicitly handles 404 because old nodes won't have the endpoint and that's normal. ```erlang %% EXISTING ??? crashes on non-200, caller expects 'unavailable' from catch get_peers(Peer) -> try begin {ok, {{<<"200">>, _}, _, Body, _, _}} = ar_http:req(...), PeerArray = ar_serialize:dejsonify(Body), lists:map(fun ar_util:parse_peer/1, PeerArray) end catch _:_ -> unavailable end. %% NEW ??? explicit status handling, non-fatal, returns {error,_} on anything unexpected get_tls_peers(Peer) -> try case ar_http:req(...) of {ok, {{<<"200">>, _}, _, Body, _, _}} -> process_tls_peers_response(Peer, Body); {ok, {{<<"404">>, _}, _, _, _, _}} -> {error, not_supported}; _ -> {error, request_failed} end catch _:Reason -> {error, Reason} end. ``` ## Norm-Check: `/peers/keys` handler placement Placed immediately before the `/peers` handler in `ar_http_iface_middleware.erl`. Pattern matching is top-to-bottom; `[<<"peers">>, <<"keys">>]` (2-segment) must come before `[<<"peers">>]` (1-segment) or it would never match. Response shape follows the project's other JSON endpoints: `{200, #{<<"content-type">> => <<"application/json">>}, Body, Req}`. The 404 uses `{404, #{}, <<>>, Req}` ??? empty body, same as other "not available here" responses in the middleware. ## Norm-Check: `open_connection/1` ??? map merge for optional TLS `GunOpts` was previously a single map literal. TLS options are conditionally overlaid with `maps:merge/2`: ```erlang TlsOpts = case ar_tls:peer_tls_info(Peer) of {ok, _SPKIDer} = PeerInfo -> #{transport => tls, tls_opts => ar_tls:gun_tls_opts(PeerInfo)}; not_found -> #{} end, GunOpts = maps:merge(#{retry => 0, ...}, TlsOpts), ``` Unknown peer ??? `TlsOpts = #{}` ??? `maps:merge` is a no-op ??? same plain-HTTP behaviour as before. No new code path for the common case. ## Project File Listings ``` apps/arweave/src/ (~130 .erl files, prefix ar_*) ar_cli_parser.erl ar_http.erl ar_http_iface_client.erl ar_http_iface_middleware.erl ar_http_iface_server.erl ar_peers.erl ar_sup.erl ar_tls.erl <- NEW ar_util.erl ar_wallet.erl apps/arweave/test/ (~50 _tests.erl files) ar_config_tests.erl ar_http_iface_tests.erl ar_http_util_tests.erl ar_semaphore_tests.erl ar_tls_tests.erl <- NEW ar_wallet_tests.erl apps/arweave_config/include/ arweave_config.hrl <- modified arweave_config_spec.hrl ``` ## The Patch ```diff %% HTTP LAYER: 3 existing files %% apps/arweave/src/ar_http.erl ??? TLS transport selection %% apps/arweave/src/ar_http_iface_middleware.erl ??? GET /peers/keys endpoint %% apps/arweave/src/ar_http_iface_client.erl ??? gossip fetch + SPKI cross-check %% %% ar_http.erl: open_connection/1 checks ar_tls:peer_tls_info(Peer); if SPKI known, %% merges {transport=>tls, tls_opts=>...} into GunOpts. Unknown peers get plain HTTP. %% No TLS-then-fallback ??? downgrade attacks not possible. %% %% ar_http_iface_middleware.erl: adds handle(GET, [<<"peers">>, <<"keys">>], ...) %% Returns 404 when no TLS configured, 200+JSON signed doc otherwise. %% Placed immediately before the existing /peers handler (more specific path first). %% %% ar_http_iface_client.erl: get_tls_peers/1 fetches /peers/keys (non-fatal on 404). %% process_tls_peers_response/2 verifies the gossip doc signature, then cross-checks %% the NodeSPKI against any previously pinned value ??? rejects with spki_mismatch %% if they differ (Task 4 security fix). diff --git a/apps/arweave/src/ar_http.erl b/apps/arweave/src/ar_http.erl index 28fc4ef..a4e986a 100644 --- a/apps/arweave/src/ar_http.erl +++ b/apps/arweave/src/ar_http.erl @@ -268,7 +268,16 @@ open_connection(#{ peer := Peer } = Args) -> {IPOrHost, Port} = get_ip_port(Peer), ConnectTimeout = maps:get(connect_timeout, Args, maps:get(timeout, Args, ?HTTP_REQUEST_CONNECT_TIMEOUT)), - GunOpts = #{ + %% Use TLS only when the remote peer's SPKI is already known. + %% Unknown peers get plain HTTP ??? no TLS-then-fallback. + TlsOpts = case ar_tls:peer_tls_info(Peer) of + {ok, _SPKIDer} = PeerInfo -> + #{transport => tls, + tls_opts => ar_tls:gun_tls_opts(PeerInfo)}; + not_found -> + #{} + end, + GunOpts = maps:merge(#{ retry => 0, connect_timeout => ConnectTimeout, http_opts => #{ @@ -287,7 +296,7 @@ open_connection(#{ peer := Peer } = Args) -> {send_timeout_close, Config#config.'http_client.tcp.send_timeout_close'}, {send_timeout, Config#config.'http_client.tcp.send_timeout'} ] - }, + }, TlsOpts), gun:open(IPOrHost, Port, GunOpts). get_ip_port({_, _} = Peer) -> diff --git a/apps/arweave/src/ar_http_iface_client.erl b/apps/arweave/src/ar_http_iface_client.erl index c1cfaaa..d1d4e06 100644 --- a/apps/arweave/src/ar_http_iface_client.erl +++ b/apps/arweave/src/ar_http_iface_client.erl @@ -10,6 +10,7 @@ get_block/3, get_tx/2, get_txs/2, get_tx_from_remote_peers/3, get_tx_data/2, get_wallet_list_chunk/2, get_wallet_list_chunk/3, get_wallet_list/2, add_peer/1, get_info/1, get_info/2, get_peers/1, + get_tls_peers/1, get_time/2, get_height/1, get_block_index/3, get_sync_record/1, get_sync_record/3, get_sync_record/4, get_footprints/3, get_chunk_binary/3, get_mempool/1, @@ -1372,6 +1373,71 @@ get_peers(Peer) -> catch _:_ -> unavailable end. +%% @doc Fetch TLS peer key gossip document from a remote peer. +%% Returns {ok, TlsPeerCount} on success, {error, Reason} on failure. +%% Errors are non-fatal (old nodes won't have the endpoint). +get_tls_peers(Peer) -> + try + case ar_http:req(#{ + method => get, + peer => Peer, + path => "/peers/keys", + headers => p2p_headers(), + connect_timeout => 500, + timeout => 5 * 1000 + }) of + {ok, {{<<"200">>, _}, _, Body, _, _}} -> + process_tls_peers_response(Peer, Body); + {ok, {{<<"404">>, _}, _, _, _, _}} -> + {error, not_supported}; + _ -> + {error, request_failed} + end + catch _:Reason -> + {error, Reason} + end. + +process_tls_peers_response(Peer, Body) -> + case ar_tls:verify_gossip_doc(Body) of + {error, _} -> + {error, invalid_gossip_doc}; + {ok, NodeSPKI} -> + %% Reject if this peer previously advertised a different key. + case ar_tls:peer_tls_info(Peer) of + {ok, Pinned} when Pinned =/= NodeSPKI -> + ?LOG_WARNING([{event, tls_gossip_spki_mismatch}, + {peer, ar_util:format_peer(Peer)}]), + {error, spki_mismatch}; + _ -> + ar_tls:set_peer_tls_info(Peer, NodeSPKI), + %% Signature verified; now extract and store peer TLS keys. + {Props} = jiffy:decode(Body), + Peers = proplists:get_value(<<"peers">>, Props), + TlsCount = lists:foldl( + fun(PeerEntry, Acc) -> + {PeerProps} = PeerEntry, + AddrBin = proplists:get_value(<<"addr">>, PeerProps), + KeyB64 = proplists:get_value(<<"key">>, PeerProps, undefined), + case KeyB64 of + undefined -> + Acc; + _ -> + case ar_util:safe_parse_peer(binary_to_list(AddrBin)) of + {ok, [ParsedPeer | _]} -> + SPKIDer = ar_util:decode(KeyB64), + ar_tls:set_peer_tls_info(ParsedPeer, SPKIDer), + Acc + 1; + _ -> + Acc + end + end + end, + 0, + Peers + ), + {ok, TlsCount} + end + end. %% @doc Process the response of an /block call. handle_block_response(_Peer, _Encoding, {ok, {{<<"400">>, _}, _, _, _, _}}) -> @@ -1510,7 +1576,7 @@ handle_cm_noop_response(Response) -> p2p_headers() -> {ok, Config} = arweave_config:get_env(), [{<<"x-p2p-port">>, integer_to_binary(Config#config.port)}, - {<<"x-release">>, integer_to_binary(?RELEASE_NUMBER)}]. + {<<"x-release">>, integer_to_binary(?RELEASE_NUMBER)}]. cm_p2p_headers() -> {ok, Config} = arweave_config:get_env(), diff --git a/apps/arweave/src/ar_http_iface_middleware.erl b/apps/arweave/src/ar_http_iface_middleware.erl index 9451c0a..248080f 100644 --- a/apps/arweave/src/ar_http_iface_middleware.erl +++ b/apps/arweave/src/ar_http_iface_middleware.erl @@ -861,6 +861,17 @@ handle(<<"POST">>, [<<"unsigned_tx">>], Req, Pid) -> {Status, Headers, Body, Req} end; +%% Return the signed TLS peer key gossip document. +%% GET request to endpoint /peers/keys. +handle(<<"GET">>, [<<"peers">>, <<"keys">>], Req, _Pid) -> + case ar_tls:local_tls_info() of + not_configured -> + {404, #{}, <<>>, Req}; + {ok, _} -> + Body = ar_tls:signed_tls_peers(), + {200, #{<<"content-type">> => <<"application/json">>}, Body, Req} + end; + %% Return the list of peers held by the node. %% GET request to endpoint /peers. handle(<<"GET">>, [<<"peers">>], Req, _Pid) -> ```