# 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.

```
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks
  • ... Undescribed Horrific Abuse, One Victim & Survivor of Many via cypherpunks

Reply via email to