# Patch 1 of 5 — Bug Fix: `ar_http_iface_server.erl`
> **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
Pre-existing runtime crash, independent of the TLS gossip feature.
Commit `b07b1d8` converted `TransportOpts` from a proplist to a map, but left
the TLS listener path using `++` (list-append) on it. Maps don't support `++`,
so starting the node with TLS configured would crash at runtime.
The fix moves `certfile` and `keyfile` into `socket_opts` inside the map —
the correct ranch/cowboy API for map-style transport options.
## Why It's in This PR
It's a real bug that affects anyone using the existing `tls_cert_file` /
`tls_key_file` config today (from PR #505, 2023). Since this PR adds TLS
connectivity, leaving a crash in the TLS listener path would be confusing.
The PR description calls it out explicitly with the offending commit hash.
## Norm-Check: `cowboy:start_tls` call shape
After b07b1d8, `TransportOpts` is a map. `start_clear` and `start_tls` both
accept map-style opts. Per ranch docs, TLS-specific socket options (cert, key)
go in the `socket_opts` key inside the map, not appended with `++`.
```erlang
%% BEFORE (crashes — can't ++ a map)
cowboy:start_tls(ar_http_iface_listener, TransportOpts ++ [
{certfile, TlsCertfilePath},
{keyfile, TlsKeyfilePath}
], ProtocolOpts)
%% AFTER (correct map form)
cowboy:start_tls(ar_http_iface_listener,
TransportOpts#{socket_opts => [
{port, Config#config.port},
{certfile, TlsCertfilePath},
{keyfile, TlsKeyfilePath}
]},
ProtocolOpts)
```
## 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
%% BUG FIX (standalone): apps/arweave/src/ar_http_iface_server.erl
%%
%% Pre-existing crash: commit b07b1d8 converted TransportOpts to a map but left
%% the TLS listener path using old proplist `++` syntax, which crashes at
runtime.
%% Fix moves cert/key into socket_opts inside the map, per ranch docs.
%%
%% This change is fully independent of the TLS gossip feature and can be
%% reviewed, understood, and applied on its own.
diff --git a/apps/arweave/src/ar_http_iface_server.erl
b/apps/arweave/src/ar_http_iface_server.erl
index 7a6c78c..74f8c16 100644
--- a/apps/arweave/src/ar_http_iface_server.erl
+++ b/apps/arweave/src/ar_http_iface_server.erl
@@ -128,10 +128,16 @@ start_http_iface_listener(Config) ->
not_set ->
cowboy:start_clear(ar_http_iface_listener,
TransportOpts, ProtocolOpts);
_ ->
- cowboy:start_tls(ar_http_iface_listener, TransportOpts
++ [
- {certfile, TlsCertfilePath},
- {keyfile, TlsKeyfilePath}
- ], ProtocolOpts)
+ %% Fix: b07b1d8 converted TransportOpts to a map but
left the TLS
+ %% path using the old proplist ++ style, which crashes
at runtime.
+ %% Cert/key go into socket_opts alongside the port, per
ranch docs.
+ cowboy:start_tls(ar_http_iface_listener,
+ TransportOpts#{socket_opts => [
+ {port, Config#config.port},
+ {certfile, TlsCertfilePath},
+ {keyfile, TlsKeyfilePath}
+ ]},
+ ProtocolOpts)
end.
name_route([]) ->
```
# Patch 2 of 5 — Config & Wiring
> **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
Four existing files. No new behaviour is visible without `ar_tls.erl` (patch 3).
| File | Change |
|------|--------|
| `arweave_config.hrl` | Adds `tls_peers = []` field; updates comments on
existing `tls_cert_file`/`tls_key_file` |
| `ar_cli_parser.erl` | Adds `"generate"` atom clause for `tls_cert_file` and a
new `tls_peers` parse clause |
| `ar_sup.erl` | Creates `ar_tls` ETS table; calls `ar_tls:init/0` after all
ETS tables exist |
| `ar_peers.erl` | `get_peer_peers/1` calls `get_tls_peers(Peer)` non-fatally
alongside the existing `/peers` fetch |
## `tls_peers` Config Key
Mirrors the existing `peers` config key. Format: `tls_peers
ip:port:base64url_spki`.
```
peers 1.2.3.4:1984
tls_peers 1.2.3.4:1984:AAAB...base64url_spki...
```
On startup `ar_tls:init/0` pre-populates ETS from this list — first connection
to that peer already uses TLS, no gossip bootstrap needed. Also the manual
escape hatch for key rotation: clear the entry, restart, re-seed the new SPKI.
## ETS Table Design
| ETS Key | Value | Purpose |
|---------|-------|---------|
| `local_tls_key` | SPKI binary | This node's own public key |
| `local_cert_file` | path string | For signing gossip docs |
| `local_key_file` | path string | For signing gossip docs |
| `{tls_peer_key, Peer}` | SPKI binary | Forward: peer → SPKI |
| `{tls_key_peer, SPKI}` | Peer tuple | Reverse: SPKI → peer |
## Norm-Check: ETS table creation in `ar_sup.erl`
All tables created in one block at supervisor init. New `ar_tls` table follows
the same options as `ar_peers`, `ar_shutdown_manager`, etc.:
```erlang
ets:new(ar_shutdown_manager, [set, public, named_table, {read_concurrency,
true}]),
ets:new(ar_timer, [set, public, named_table, {read_concurrency,
true}]),
ets:new(ar_peers, [set, public, named_table, {read_concurrency,
true}]),
ets:new(ar_tls, [set, public, named_table, {read_concurrency,
true}]), %% NEW
ets:new(ar_http, [set, public, named_table]),
```
## Norm-Check: `ar_cli_parser.erl` clause ordering
More-specific string match must come before generic catch-all:
```erlang
parse(["tls_cert_file", "generate" | Rest], C) -> %% specific: atom result
parse(Rest, C#config{ tls_cert_file = generate });
parse(["tls_cert_file", CertFilePath | Rest], C) -> %% generic: file path
AbsCertFilePath = filename:absname(CertFilePath),
ar_util:assert_file_exists_and_readable(AbsCertFilePath),
parse(Rest, C#config{ tls_cert_file = AbsCertFilePath });
```
`tls_peers` splits on the **trailing** colon (`string:split(Entry, ":",
trailing)`)
to separate `ip:port` from the SPKI — handles any number of colons in the
address.
## Norm-Check: `get_peer_peers/1` in `ar_peers.erl`
```erlang
%% BEFORE
get_peer_peers(Peer) ->
case ar_http_iface_client:get_peers(Peer) of
unavailable -> [];
Peers -> Peers
end.
%% AFTER
get_peer_peers(Peer) ->
case ar_http_iface_client:get_peers(Peer) of
unavailable -> [];
Peers ->
%% Also fetch TLS key gossip from the peer (non-fatal).
ar_http_iface_client:get_tls_peers(Peer),
Peers
end.
```
`get_tls_peers` result is intentionally dropped — it's a side-effect that
populates ETS. `Peers` is returned unchanged.
## 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
%% CONFIG & WIRING: 4 existing files
%% apps/arweave_config/include/arweave_config.hrl — 3 new config fields
%% apps/arweave/src/ar_cli_parser.erl — CLI parse clauses for
each
%% apps/arweave/src/ar_sup.erl — ar_tls ETS table creation
%% apps/arweave/src/ar_peers.erl — gossip hook in
get_peer_peers/1
%%
%% Adds tls_cert_file, tls_key_file (existing fields, new `generate` atom
handled),
%% and tls_peers [{Peer, SPKIDer}] config list. ar_sup creates the ETS table
that
%% ar_tls.erl owns. ar_peers fires get_tls_peers/1 non-fatally alongside the
%% existing /peers fetch — the return value is intentionally ignored
(side-effect only).
%%
%% No new visible behaviour without ar_tls.erl present.
diff --git a/apps/arweave/src/ar_cli_parser.erl
b/apps/arweave/src/ar_cli_parser.erl
index 4ba78ac..132af57 100644
--- a/apps/arweave/src/ar_cli_parser.erl
+++ b/apps/arweave/src/ar_cli_parser.erl
@@ -827,6 +827,8 @@ parse(["defragment_module", DefragModuleString | Rest], C)
->
{init, stop, [1]}
], C}
end;
+parse(["tls_cert_file", "generate" | Rest], C) ->
+ parse(Rest, C#config{ tls_cert_file = generate });
parse(["tls_cert_file", CertFilePath | Rest], C) ->
AbsCertFilePath = filename:absname(CertFilePath),
ar_util:assert_file_exists_and_readable(AbsCertFilePath),
@@ -835,6 +837,22 @@ parse(["tls_key_file", KeyFilePath | Rest], C) ->
AbsKeyFilePath = filename:absname(KeyFilePath),
ar_util:assert_file_exists_and_readable(AbsKeyFilePath),
parse(Rest, C#config{ tls_key_file = AbsKeyFilePath });
+parse(["tls_peers", Entry | Rest], C) ->
+ %% Format: "ip:port:base64url_spki"
+ case string:split(Entry, ":", trailing) of
+ [AddrPart, KeyB64] ->
+ case ar_util:safe_parse_peer(AddrPart) of
+ {ok, [Peer | _]} ->
+ SPKIDer =
ar_util:decode(list_to_binary(KeyB64)),
+ parse(Rest, C#config{
+ tls_peers = [{Peer, SPKIDer} |
C#config.tls_peers]
+ });
+ _ ->
+ parse(Rest, C)
+ end;
+ _ ->
+ parse(Rest, C)
+ end;
parse(["http_api.tcp.idle_timeout_seconds", Num | Rest], C) ->
parse(Rest, C#config { http_api_transport_idle_timeout =
list_to_integer(Num) * 1000 });
parse(["coordinated_mining" | Rest], C) ->
diff --git a/apps/arweave/src/ar_peers.erl b/apps/arweave/src/ar_peers.erl
index ad173a4..412c392 100644
--- a/apps/arweave/src/ar_peers.erl
+++ b/apps/arweave/src/ar_peers.erl
@@ -622,7 +622,10 @@ terminate(Reason, _State) ->
get_peer_peers(Peer) ->
case ar_http_iface_client:get_peers(Peer) of
unavailable -> [];
- Peers -> Peers
+ Peers ->
+ %% Also fetch TLS key gossip from the peer (non-fatal).
+ ar_http_iface_client:get_tls_peers(Peer),
+ Peers
end.
get_or_init_performance(Peer) ->
diff --git a/apps/arweave/src/ar_sup.erl b/apps/arweave/src/ar_sup.erl
index 188b545..a7c9997 100644
--- a/apps/arweave/src/ar_sup.erl
+++ b/apps/arweave/src/ar_sup.erl
@@ -33,6 +33,7 @@ init([]) ->
ets:new(ar_shutdown_manager, [set, public, named_table,
{read_concurrency, true}]),
ets:new(ar_timer, [set, public, named_table, {read_concurrency, true}]),
ets:new(ar_peers, [set, public, named_table, {read_concurrency, true}]),
+ ets:new(ar_tls, [set, public, named_table, {read_concurrency, true}]),
ets:new(ar_http, [set, public, named_table]),
ets:new(ar_rate_limiter, [set, public, named_table, {read_concurrency,
true}]),
ets:new(ar_blacklist_middleware, [set, public, named_table]),
@@ -67,6 +68,9 @@ init([]) ->
ets:new(block_index, [ordered_set, public, named_table]),
ets:new(node_state, [set, public, named_table]),
ets:new(mining_state, [set, public, named_table, {read_concurrency,
true}]),
+ %% Initialise TLS state (resolves/generates cert if TLS is configured).
+ %% This must happen after ETS tables are created and before any
listener starts.
+ ar_tls:init(),
Children = [
?CHILD(ar_shutdown_manager, worker),
?CHILD(ar_rate_limiter, worker),
diff --git a/apps/arweave_config/include/arweave_config.hrl
b/apps/arweave_config/include/arweave_config.hrl
index 42de946..8c55fa7 100644
--- a/apps/arweave_config/include/arweave_config.hrl
+++ b/apps/arweave_config/include/arweave_config.hrl
@@ -383,8 +383,13 @@
defragmentation_modules = [],
block_throttle_by_ip_interval =
?DEFAULT_BLOCK_THROTTLE_BY_IP_INTERVAL_MS,
block_throttle_by_solution_interval =
?DEFAULT_BLOCK_THROTTLE_BY_SOLUTION_INTERVAL_MS,
- tls_cert_file = not_set, %% required to enable TLS
- tls_key_file = not_set, %% required to enable TLS
+ %% Path to PEM cert for TLS on the main port; set to 'generate' to
auto-generate
+ %% a self-signed P-256 cert+key in {data_dir}/tls/
+ tls_cert_file = not_set,
+ tls_key_file = not_set, %% path to PEM private key for TLS on the main
port
+ %% Pre-configured peer TLS keys: [{Peer, SPKIDer}] tuples.
+ %% Populated from 'tls_peers ip:port:base64url_spki' config entries.
+ tls_peers = [],
http_api_transport_idle_timeout =
?DEFAULT_COWBOY_TCP_IDLE_TIMEOUT_SECOND*1000,
coordinated_mining = false,
cm_api_secret = not_set,
```
# 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) ->
```
# Patch 5 of 5 â New Test Module: `ar_tls_tests.erl`
> **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
Adds `apps/arweave/test/ar_tls_tests.erl` â 15 EUnit tests. All run without a
live node or network (ETS only, tmp files for generated certs). Uses
`-ifdef(AR_TEST)` to access `spki_from_cert_file/1` which is not exported
in production builds.
## Test Coverage
| Test function | What it verifies |
|---------------|-----------------|
| `spki_from_cert_file_test_` | Extracts SPKI from a real openssl-generated
P-256 cert; errors on missing file |
| `local_tls_info_not_configured_test_` | Empty ETS â `not_configured` |
| `local_tls_info_configured_test_` | ETS with `local_tls_key` entry â `{ok,
SPKI}` |
| `gun_tls_opts_pinning_test` | `{ok,SPKI}` â `verify_peer` + correct
`verify_fun` + `server_name_indication=disable` |
| `verify_peer_spki_bad_cert_test` | `{bad_cert, _}` event â `{valid, State}`
(skip CA errors) |
| `verify_peer_spki_extension_test` | `{extension, _}` event â `{unknown,
State}` |
| `verify_peer_spki_valid_test` | `valid` event â `{valid, State}` |
| `peer_tls_info_test_` (4 subtests) | not_found, set+get, overwrite,
bidirectional ETS mapping |
| `tls_cert_file_generate_test_` | `init/0` with `generate` atom â creates
cert+key files, `local_tls_info` ok |
| `tls_peers_config_test_` | `init/0` with `tls_peers` list â `peer_tls_info`
returns the pre-seeded SPKI |
## Norm-Check: Simple test vs generator test
Comparing against `ar_http_util_tests.erl` (no setup) and `ar_config_tests.erl`
(config record, setup/teardown):
```erlang
%% Simple: plain function, no shared state needed
gun_tls_opts_pinning_test() ->
FakeSPKI = <<"some_spki_der_for_test">>,
Opts = ar_tls:gun_tls_opts({ok, FakeSPKI}),
?assertEqual(verify_peer, proplists:get_value(verify, Opts)).
%% Generator: _test_() suffix, {setup, Setup, Teardown, Tests}
peer_tls_info_test_() ->
{setup,
fun setup_clean_ets/0,
fun teardown_ets/1,
[
?_test(test_peer_not_found()),
?_test(test_peer_set_and_get()),
...
]}.
```
## Norm-Check: `AR_TEST` macro, not `TEST`
The project uses `-ifdef(AR_TEST)` to gate test-only exports. The compile
command passes `-D AR_TEST`. Using `-ifdef(TEST)` (common in other Erlang
projects) would silently omit the export and cause "undefined function" at
test time.
## Norm-Check: `setup_clean_ets/0` pattern for named ETS tables
Named ETS tables persist across test cases within the same VM. The helper
creates the table if it doesn't exist, or clears it if it does â safe whether
the table was created by a prior test or by the test framework itself:
```erlang
setup_clean_ets() ->
case ets:info(ar_tls) of
undefined -> ets:new(ar_tls, [set, public, named_table,
{read_concurrency, true}]);
_ -> ets:delete_all_objects(ar_tls)
end.
```
## Norm-Check: Section numbering in comments
Test sections are numbered (`%% 5. Pinning opts...`). After removing the TOFU
test (was section 5) in Task 1, subsequent sections were renumbered: 6â5,
7â6.
Keeping numbering contiguous is a stated preference in the original author
instructions ("short, clear, consistent and parallel").
## 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
%% NEW TEST MODULE: apps/arweave/test/ar_tls_tests.erl
%%
%% 15 EUnit tests. All run without a live node or network â only ETS and tmp
files.
%% Tests use -ifdef(AR_TEST) exports from ar_tls.erl (spki_from_cert_file/1).
%%
%% Coverage:
%% 1. spki_from_cert_file/1 â extracts SPKI from a real
openssl-generated cert
%% 2. local_tls_info not_configured â ETS empty â not_configured
%% 3. local_tls_info configured â ETS populated â {ok, SPKI}
%% 4. gun_tls_opts pinning â {ok,SPKI} â verify_peer + verify_fun
%% 5. verify_peer_spki/3 â bad_cert/extension/valid event handling
%% 6. peer_tls_info round-trip â set/get/overwrite/bidirectional ETS
mapping
%% 7. tls_cert_file=generate â init/0 creates cert+key files,
local_tls_info ok
%% 8. tls_peers config pre-seeding â init/0 with tls_peers list â
peer_tls_info ok
%%
%% Section numbering: 5 (TOFU test removed in Task 1), 6, 7... renumbered to 5,
6...
--- /dev/null 2026-04-05 22:13:40.373703200 +0000
+++ b/apps/arweave/test/ar_tls_tests.erl 2026-04-07 01:08:06.026856089
+0000
@@ -0,0 +1,230 @@
+%%% @doc Unit tests for ar_tls.
+%%%
+%%% These tests exercise the TLS peer key gossip helpers, SPKI extraction,
+%%% auto-cert generation, and the gossip document signing/verification
+%%% round-trip without starting any network listeners or requiring a live
+%%% Arweave node.
+
+-module(ar_tls_tests).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("public_key/include/public_key.hrl").
+-include_lib("arweave_config/include/arweave_config.hrl").
+
+%% ===================================================================
+%% 1. spki_from_cert_file/1
+%% ===================================================================
+
+spki_from_cert_file_test_() ->
+ {setup,
+ fun make_tmp_cert/0,
+ fun del_dir/1,
+ fun({TmpDir, _CertFile, _KeyFile}) ->
+ CertFile = filename:join(TmpDir, "test.crt"),
+ [
+ ?_assertMatch({ok, SPKIDer} when is_binary(SPKIDer)
andalso
+ byte_size(SPKIDer) > 0,
ar_tls:spki_from_cert_file(CertFile)),
+ ?_assertMatch({error, _},
ar_tls:spki_from_cert_file("/nonexistent"))
+ ]
+ end}.
+
+%% ===================================================================
+%% 3. local_tls_info/0 returns not_configured when no cert files
+%% ===================================================================
+
+local_tls_info_not_configured_test_() ->
+ {setup,
+ fun setup_clean_ets/0,
+ fun teardown_ets/1,
+ [
+ ?_assertEqual(not_configured, ar_tls:local_tls_info())
+ ]}.
+
+%% ===================================================================
+%% 4. local_tls_info/0 returns {ok, SPKIDer} when cert files present
+%% ===================================================================
+
+local_tls_info_configured_test_() ->
+ {setup,
+ fun() ->
+ setup_clean_ets(),
+ FakeSPKI = <<"fake_spki_der_bytes">>,
+ ets:insert(ar_tls, {local_tls_key, FakeSPKI}),
+ FakeSPKI
+ end,
+ fun(_) -> teardown_ets(ok) end,
+ fun(FakeSPKI) ->
+ [
+ ?_assertEqual({ok, FakeSPKI}, ar_tls:local_tls_info())
+ ]
+ end}.
+
+
+%% ===================================================================
+%% 5. Pinning opts â gun_tls_opts({ok, SPKI}) returns verify_peer
+%% ===================================================================
+
+gun_tls_opts_pinning_test() ->
+ FakeSPKI = <<"some_spki_der_for_test">>,
+ Opts = ar_tls:gun_tls_opts({ok, FakeSPKI}),
+ ?assertEqual(verify_peer, proplists:get_value(verify, Opts)),
+ {Fun, State} = proplists:get_value(verify_fun, Opts),
+ ?assert(is_function(Fun, 3)),
+ ?assertEqual(FakeSPKI, State),
+ ?assertEqual(disable, proplists:get_value(server_name_indication,
Opts)).
+
+%% ===================================================================
+%% 6. verify_peer_spki/3 accepts matching, rejects mismatched
+%% ===================================================================
+
+verify_peer_spki_bad_cert_test() ->
+ ?assertMatch({valid, <<"state">>},
+ ar_tls:verify_peer_spki(ignored, {bad_cert, unknown_ca},
<<"state">>)).
+
+verify_peer_spki_extension_test() ->
+ ?assertMatch({unknown, <<"state">>},
+ ar_tls:verify_peer_spki(ignored, {extension, whatever},
<<"state">>)).
+
+verify_peer_spki_valid_test() ->
+ ?assertMatch({valid, <<"state">>},
+ ar_tls:verify_peer_spki(ignored, valid, <<"state">>)).
+
+%% ===================================================================
+%% Peer TLS info round-trip (ETS-backed)
+%% ===================================================================
+
+peer_tls_info_test_() ->
+ {setup,
+ fun setup_clean_ets/0,
+ fun teardown_ets/1,
+ [
+ ?_test(test_peer_not_found()),
+ ?_test(test_peer_set_and_get()),
+ ?_test(test_peer_overwrite()),
+ ?_test(test_bidirectional_mapping())
+ ]}.
+
+test_peer_not_found() ->
+ ?assertEqual(not_found, ar_tls:peer_tls_info({1, 2, 3, 4, 1984})).
+
+test_peer_set_and_get() ->
+ Peer = {10, 0, 0, 1, 1984},
+ SPKI = <<"test_spki_der">>,
+ ok = ar_tls:set_peer_tls_info(Peer, SPKI),
+ ?assertEqual({ok, SPKI}, ar_tls:peer_tls_info(Peer)).
+
+test_peer_overwrite() ->
+ Peer = {10, 0, 0, 3, 1984},
+ ok = ar_tls:set_peer_tls_info(Peer, <<"old_spki">>),
+ ok = ar_tls:set_peer_tls_info(Peer, <<"new_spki">>),
+ ?assertEqual({ok, <<"new_spki">>}, ar_tls:peer_tls_info(Peer)).
+
+test_bidirectional_mapping() ->
+ Peer = {10, 0, 0, 4, 1984},
+ SPKI = <<"bidir_test_spki">>,
+ ok = ar_tls:set_peer_tls_info(Peer, SPKI),
+ %% Forward: peer -> key
+ ?assertEqual({ok, SPKI}, ar_tls:peer_tls_info(Peer)),
+ %% Reverse: key -> peer
+ ?assertMatch([{{tls_key_peer, SPKI}, Peer}],
+ ets:lookup(ar_tls, {tls_key_peer, SPKI})).
+
+%% ===================================================================
+%% tls_cert_file = generate triggers auto-cert generation via init/0
+%% ===================================================================
+
+tls_cert_file_generate_test_() ->
+ {setup,
+ fun() ->
+ setup_clean_ets(),
+ TmpDir = lists:flatten(io_lib:format("/tmp/ar_tls_gen_test_~p",
[rand:uniform(1000000)])),
+ ok = filelib:ensure_dir(filename:join(TmpDir, "dummy")),
+ Config = #config{ data_dir = TmpDir, tls_cert_file = generate },
+ application:set_env(arweave, config, Config),
+ TmpDir
+ end,
+ fun(TmpDir) ->
+ teardown_ets(ok),
+ os:cmd("rm -rf " ++ TmpDir)
+ end,
+ fun(TmpDir) ->
+ [
+ ?_test(begin
+ ok = ar_tls:init(),
+ CertFile = filename:join([TmpDir, "tls",
"arweave.crt"]),
+ KeyFile = filename:join([TmpDir, "tls",
"arweave.key"]),
+ ?assert(filelib:is_regular(CertFile)),
+ ?assert(filelib:is_regular(KeyFile)),
+ ?assertMatch({ok, _SPKIDer},
ar_tls:local_tls_info())
+ end)
+ ]
+ end}.
+
+%% ===================================================================
+%% tls_peers config pre-seeding via init/0
+%% ===================================================================
+
+tls_peers_config_test_() ->
+ {setup,
+ fun() ->
+ setup_clean_ets(),
+ Peer = {192, 168, 1, 1, 1984},
+ SPKI = <<1,2,3,4,5>>,
+ Config = #config{tls_peers = [{Peer, SPKI}]},
+ application:set_env(arweave, config, Config),
+ {Peer, SPKI}
+ end,
+ fun(_) -> teardown_ets(ok) end,
+ fun({Peer, SPKI}) ->
+ [
+ ?_test(begin
+ ok = ar_tls:init(),
+ ?assertEqual({ok, SPKI},
ar_tls:peer_tls_info(Peer))
+ end)
+ ]
+ end}.
+
+%% ===================================================================
+%% Helpers
+%% ===================================================================
+
+make_tmp_cert() ->
+ Dir = lists:flatten(io_lib:format("/tmp/ar_tls_test_~p",
[rand:uniform(1000000)])),
+ ok = file:make_dir(Dir),
+ CertFile = filename:join(Dir, "test.crt"),
+ KeyFile = filename:join(Dir, "test.key"),
+ %% Generate using openssl if available, otherwise skip
+ case os:find_executable("openssl") of
+ false ->
+ {Dir, CertFile, KeyFile};
+ _ ->
+ Cmd = lists:flatten(io_lib:format(
+ "openssl req -x509 -newkey ec"
+ " -pkeyopt ec_paramgen_curve:P-256"
+ " -keyout ~s -out ~s"
+ " -days 3650 -noenc"
+ " -subj '/CN=arweave-test' 2>&1",
+ [KeyFile, CertFile]
+ )),
+ os:cmd(Cmd),
+ {Dir, CertFile, KeyFile}
+ end.
+
+del_dir({Dir, _, _}) ->
+ os:cmd("rm -rf " ++ Dir).
+
+setup_clean_ets() ->
+ case ets:info(ar_tls) of
+ undefined ->
+ ets:new(ar_tls, [set, public, named_table,
{read_concurrency, true}]);
+ _ ->
+ ets:delete_all_objects(ar_tls)
+ end.
+
+teardown_ets(_) ->
+ case ets:info(ar_tls) of
+ undefined ->
+ ok;
+ _ ->
+ ets:delete_all_objects(ar_tls)
+ end.
```