On Monday, April 6, 2026, Undescribed Horrific Abuse, One Victim & Survivor
of Many <[email protected]> wrote:

>
# Patch 3 of 5 — New Module: `ar_tls.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

The entire new `ar_tls` module (~310 lines). Every other patch either feeds
data into it or calls out of it.

Public API:
```erlang
-export([init/0, local_tls_info/0, peer_tls_info/1, set_peer_tls_info/2,
         gun_tls_opts/1, verify_peer_spki/3, signed_tls_peers/0, 
verify_gossip_doc/1]).
%% -ifdef(AR_TEST) only: spki_from_cert_file/1
```

## Core Concept: SPKI as Identity

SPKI = SubjectPublicKeyInfo DER — the raw public key structure TLS already
uses internally. For P-256 it's 91 bytes. Chosen over a certificate fingerprint
because it's stable across cert renewals (as long as the key pair doesn't
change), which is important for a long-lived p2p network.

The ETS table maps `{tls_peer_key, Peer} -> SPKIDer` (and reverse). When
`ar_http:open_connection/1` sees `{ok, SPKI}` for a peer, it passes those to
`gun_tls_opts/1` which installs `verify_peer_spki/3` as the SSL verify_fun.
That function checks the cert's actual SPKI against the stored value during
the TLS handshake and rejects if they differ.

## Why No TOFU (Trust On First Use)?

An earlier draft had `gun_tls_opts(not_found) -> [{verify, verify_none}]` —
skip verification on first contact, pin afterward. Removed because:

If an attacker is on-path for the first connection, they present their cert,
get pinned, and every future "protected" connection goes to them. TOFU is
acceptable for SSH (interactive users see prompts and can notice changes), but
not for an automated financial network. The fix: only connect TLS when you
already have a trusted SPKI from gossip or config. Unknown peers use plain
HTTP — same as today. No MITM window.

## Why No TLS Key in HTTP Headers?

Earlier draft added an `x-tls-pubkey` header. Removed because: trusting a key
in a header requires trusting the connection it arrived on. Over plain HTTP a
MITM attacker substitutes their own key; you "upgrade" to TLS with their cert.
The header only provides security when you already have a secure channel —
precisely when you don't need it.

Key discovery is exclusively via `/peers/keys` — a cryptographically signed
document verifiable without trusting the transport.

## Gossip Document Format and Signing

```json
{
  "node": "<base64url SPKI of signing miner>",
  "peers": [
    {"addr": "1.2.3.4:1984", "key": "<base64url SPKI>"},
    {"addr": "5.6.7.8:1984"}
  ],
  "timestamp": 1234567890,
  "signature": "<base64url ECDSA-P256 sig over {node,peers,timestamp} JSON>"
}
```

`sign_gossip_payload/1` reads the PEM key path from ETS, decodes to
`ECPrivateKey`, calls `public_key:sign(Payload, sha256, Key)`.

`verify_gossip_doc/1` decodes NodeSPKI from `"node"`, calls
`public_key:der_decode('SubjectPublicKeyInfo', ...)`, then
`public_key:verify(Payload, sha256, Sig, SubjectPKI)`. No transport trust 
needed.

Plain peers omit `"key"` entirely (not `null`). Timestamp not validated for
freshness yet — deferred to keep PR small. Replays can only re-establish a
previously valid mapping; forgery needs the private key.

## Auto-Cert Generation (`tls_cert_file = generate`)

`auto_generate_cert/1` in `init/0`:
1. Skips if `{data_dir}/tls/arweave.{crt,key}` already exist (idempotent).
2. `public_key:generate_key({namedCurve, secp256r1})` — P-256 key pair.
3. Minimal `OTPTBSCertificate` (CN=arweave, 10-year validity, self-signed).
4. `public_key:pkix_sign(TBS, ECKey)` signs it.
5. Writes PEM files, extracts SPKI, stores in ETS.

P-256 chosen because OTP's `public_key` library supports it cleanly for cert
generation. secp256k1 doesn't have `pkix_sign` support. Cert metadata is
irrelevant — only the SPKI matters for pinning.

## Connection Flow

```
open_connection(Peer)   [ar_http.erl — patch 4]
  |
  +-- ar_tls:peer_tls_info(Peer)
        not_found  ->  plain HTTP (GunOpts unchanged)
        {ok, SPKI} ->  TLS (GunOpts += transport=tls, 
tls_opts=gun_tls_opts({ok,SPKI}))
                           |
                           SSL handshake -> verify_peer_spki/3
                           SPKI mismatch -> {fail, bad_spki}  (connection 
refused)
                           SPKI match    -> {valid, SPKI}
```

No fallback. TLS failure for a pinned peer means the connection fails — no
silent downgrade.

## Bootstrap / Cold-Start

No config + no gossip yet → all connections plain HTTP. As `get_peer_peers/1`
calls `get_tls_peers(Peer)` for discovered peers, SPKIs accumulate in ETS.
Subsequent connections use TLS automatically.

`*.arweave.xyz` bootstrap nodes have CA-signed certs; CA-path verification
deferred to a follow-up PR. For now they connect plain HTTP until gossip
provides their SPKI.

## Q&A for This Agent

- "Why not Let's Encrypt / a CA?" — Requires DNS, domain, renewal coordination
  across thousands of independent miners. SPKI gossip is self-contained.
- "Why P-256 not secp256k1?" — OTP `pkix_sign` supports P-256; secp256k1 has no
  built-in cert generation path in OTP.
- "Two nodes with the same SPKI?" — Impossible for distinct key pairs (ECDLP).
- "Why sign gossip with TLS key not the Arweave wallet key?" — The TLS key IS
  the identity being advertised. Signing proves possession. A wallet key would
  add indirection without adding security.
- "Timestamp not validated — isn't that a replay attack?" — Replays only
  re-establish a previously valid mapping; they can't introduce new keys.
  Freshness checking deferred to keep initial PR minimal.
- "How does key rotation work?" — Operator clears/edits the `tls_peers` config
  entry, restarts the node, re-seeds the new SPKI. Automated rotation is a
  follow-up design question.


## 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 MODULE: apps/arweave/src/ar_tls.erl
%%
%% The entire ar_tls module — this is the core of the feature.  All other 
patches
%% either feed data into it or call out of it.  Contents:
%%
%%   init/0               — reads config, optionally auto-generates a 
self-signed
%%                          P-256 cert, extracts SPKI, populates ETS, pre-seeds
%%                          tls_peers from config
%%   local_tls_info/0     — returns {ok, SPKI} or not_configured
%%   peer_tls_info/1      — ETS lookup: peer -> SPKI
%%   set_peer_tls_info/2  — ETS insert: bidirectional peer<->SPKI mapping
%%   gun_tls_opts/1       — TLS options for gun (SPKI-pinned verify_peer)
%%   verify_peer_spki/3   — SSL verify_fun: rejects certs whose SPKI != stored
%%   signed_tls_peers/0   — builds + signs the /peers/keys gossip document
%%   verify_gossip_doc/1  — verifies signature, returns {ok, NodeSPKI}
%%
%% Presented as a diff against /dev/null (new file).

--- /dev/null   2026-04-05 22:13:40.373703200 +0000
+++ b/apps/arweave/src/ar_tls.erl       2026-04-07 01:08:06.026856089 +0000
@@ -0,0 +1,317 @@
+%%% @doc Manages TLS certificate lifecycle and peer TLS key gossip.
+%%%
+%%% This is a plain module (no gen_server); persistent state lives in the
+%%% `ar_tls' ETS table, which is owned by `ar_sup' and therefore lives for
+%%% the lifetime of the VM.
+%%%
+%%% Backwards-compatibility contract
+%%% ----------------------------------
+%%% * No `tls_cert_file' / `tls_key_file' configured (default) – no TLS;
+%%%   plain-HTTP behaviour is completely unchanged.
+%%% * `tls_cert_file + tls_key_file' set → main port becomes TLS.
+%%% * `tls_cert_file = generate' → auto-generate self-signed P-256 cert+key
+%%%   in `{data_dir}/tls/' on startup.
+%%% * Nodes without TLS: `/peers/keys' returns 404, connections are plain
+%%%   HTTP — completely unaffected.
+%%%
+%%% Peer identity
+%%% -------------
+%%% SPKI (SubjectPublicKeyInfo DER) IS the identity.  IP:port is transport.
+%%% ETS stores bidirectional mapping: peer→key AND key→peer.
+
+-module(ar_tls).
+
+-export([
+       init/0,
+       local_tls_info/0,
+       peer_tls_info/1,
+       set_peer_tls_info/2,
+       gun_tls_opts/1,
+       verify_peer_spki/3,
+       signed_tls_peers/0,
+       verify_gossip_doc/1
+]).
+
+-ifdef(AR_TEST).
+-export([
+       spki_from_cert_file/1
+]).
+-endif.
+
+-include_lib("arweave/include/ar.hrl").
+-include_lib("arweave_config/include/arweave_config.hrl").
+-include_lib("public_key/include/public_key.hrl").
+
+%% ===================================================================
+%% Public API
+%% ===================================================================
+
+%% @doc Initialise the ar_tls ETS table entries.  When `tls_cert_file' is the
+%% atom `generate', auto-generate a self-signed P-256 cert+key.  Then if cert
+%% files are present, extract SPKI and store in ETS.
+%%
+%% Must be called from ar_sup:init/1 after the ETS table has been created.
+-spec init() -> ok.
+init() ->
+       {ok, Config} = application:get_env(arweave, config),
+       {CertFile, KeyFile} = case Config#config.tls_cert_file of
+               generate ->
+                       auto_generate_cert(Config);
+               _ ->
+                       {Config#config.tls_cert_file, 
Config#config.tls_key_file}
+       end,
+       case {CertFile, KeyFile} of
+               {not_set, _} ->
+                       ok;
+               {_, not_set} ->
+                       ok;
+               {_, _} ->
+                       case spki_from_cert_file(CertFile) of
+                               {ok, SPKIDer} ->
+                                       ets:insert(ar_tls, [
+                                               {local_tls_key, SPKIDer},
+                                               {local_cert_file, CertFile},
+                                               {local_key_file, KeyFile}
+                                       ]),
+                                       ?LOG_INFO([{event, tls_initialised},
+                                               {cert, CertFile},
+                                               {spki_size, 
byte_size(SPKIDer)}]),
+                                       ok;
+                               {error, Reason} ->
+                                       ?LOG_ERROR([{event, tls_init_failed},
+                                               {cert, CertFile},
+                                               {reason, Reason}]),
+                                       ok
+                       end
+       end,
+       %% Pre-seed any SPKIs from the tls_peers config list.
+       lists:foreach(
+               fun({Peer, SPKIDer}) -> set_peer_tls_info(Peer, SPKIDer) end,
+               Config#config.tls_peers
+       ).
+
+%% @doc Return `{ok, SPKIDer}' when this node has TLS configured,
+%% or `not_configured' otherwise.
+-spec local_tls_info() -> {ok, binary()} | not_configured.
+local_tls_info() ->
+       case ets:lookup(ar_tls, local_tls_key) of
+               [{_, SPKIDer}] ->
+                       {ok, SPKIDer};
+               [] ->
+                       not_configured
+       end.
+
+%% @doc Return `{ok, SPKIDer}' for a known peer, or `not_found' if the
+%% peer has not yet advertised its TLS public key.
+-spec peer_tls_info(Peer :: tuple()) -> {ok, binary()} | not_found.
+peer_tls_info(Peer) ->
+       case ets:lookup(ar_tls, {tls_peer_key, Peer}) of
+               [{{tls_peer_key, Peer}, SPKIDer}] ->
+                       {ok, SPKIDer};
+               [] ->
+                       not_found
+       end.
+
+%% @doc Record the TLS SPKI of a remote peer (bidirectional mapping).
+-spec set_peer_tls_info(Peer :: tuple(), SPKIDer :: binary()) -> ok.
+set_peer_tls_info(Peer, SPKIDer) ->
+       ets:insert(ar_tls, {{tls_peer_key, Peer}, SPKIDer}),
+       ets:insert(ar_tls, {{tls_key_peer, SPKIDer}, Peer}),
+       ok.
+
+%% @doc TLS options for gun when opening an outbound TLS connection.
+%% Only called when the peer's SPKI is already known (see 
ar_http:open_connection/1).
+-spec gun_tls_opts({ok, binary()}) -> [ssl:tls_client_option()].
+gun_tls_opts({ok, SPKIDer}) ->
+       [{verify, verify_peer},
+        {verify_fun, {fun ?MODULE:verify_peer_spki/3, SPKIDer}},
+        {cacerts, []},
+        {server_name_indication, disable}].
+
+%% @doc SSL verify_fun called by OTP during TLS handshake.
+%%
+%% We skip normal CA-chain failures (self-signed certs are expected) and
+%% check the peer certificate's SPKI against the stored value on the
+%% `valid_peer' event.
+-spec verify_peer_spki(term(), atom() | tuple(), binary()) ->
+       {valid, binary()} | {fail, atom()} | {unknown, binary()}.
+verify_peer_spki(_OtpCert, {bad_cert, _Reason}, State) ->
+       {valid, State};
+verify_peer_spki(_OtpCert, {extension, _Ext}, State) ->
+       {unknown, State};
+verify_peer_spki(_OtpCert, valid, State) ->
+       {valid, State};
+verify_peer_spki(OtpCert, valid_peer, ExpectedSPKI) ->
+       DerCert = public_key:pkix_encode('OTPCertificate', OtpCert, otp),
+       Cert = public_key:der_decode('Certificate', DerCert),
+       TBS = Cert#'Certificate'.tbsCertificate,
+       SPKI = TBS#'TBSCertificate'.subjectPublicKeyInfo,
+       ActualSPKI = public_key:der_encode('SubjectPublicKeyInfo', SPKI),
+       case ActualSPKI =:= ExpectedSPKI of
+               true ->
+                       {valid, ExpectedSPKI};
+               false ->
+                       ?LOG_WARNING([{event, tls_spki_mismatch},
+                               {expected_size, byte_size(ExpectedSPKI)},
+                               {actual_size, byte_size(ActualSPKI)}]),
+                       {fail, bad_spki}
+       end.
+
+%% @doc Sign a binary payload using this node's TLS private key.
+-spec sign_gossip_payload(Body :: binary()) -> binary().
+sign_gossip_payload(Body) ->
+       [{_, KeyFile}] = ets:lookup(ar_tls, local_key_file),
+       {ok, PemBin} = file:read_file(KeyFile),
+       [{_, KeyDer, _} | _] = public_key:pem_decode(PemBin),
+       ECPrivKey = public_key:der_decode('ECPrivateKey', KeyDer),
+       public_key:sign(Body, sha256, ECPrivKey).
+
+%% @doc Build a signed gossip document listing all known public peers
+%% with their TLS keys (if known).
+-spec signed_tls_peers() -> binary().
+signed_tls_peers() ->
+       {ok, LocalSPKI} = local_tls_info(),
+       AllPeers = ar_peers:get_peers(current),
+       PublicPeers = [P || P <- AllPeers, ar_peers:is_public_peer(P)],
+       PeerEntries = lists:sort(
+               fun(A, B) ->
+                       addr_bin(A) =< addr_bin(B)
+               end,
+               [peer_entry(P) || P <- PublicPeers]
+       ),
+       Timestamp = os:system_time(second),
+       Payload = jiffy:encode({[
+               {<<"node">>, ar_util:encode(LocalSPKI)},
+               {<<"peers">>, PeerEntries},
+               {<<"timestamp">>, Timestamp}
+       ]}),
+       Sig = sign_gossip_payload(Payload),
+       jiffy:encode({[
+               {<<"node">>, ar_util:encode(LocalSPKI)},
+               {<<"peers">>, PeerEntries},
+               {<<"timestamp">>, Timestamp},
+               {<<"signature">>, ar_util:encode(Sig)}
+       ]}).
+
+%% @doc Extract SubjectPublicKeyInfo DER from a PEM certificate file.
+-spec spki_from_cert_file(CertFile :: string()) -> {ok, binary()} | {error, 
term()}.
+spki_from_cert_file(CertFile) ->
+       try
+               {ok, PemBin} = file:read_file(CertFile),
+               [{'Certificate', DerCert, not_encrypted} | _] = 
public_key:pem_decode(PemBin),
+               Cert = public_key:der_decode('Certificate', DerCert),
+               TBS = Cert#'Certificate'.tbsCertificate,
+               SPKI = TBS#'TBSCertificate'.subjectPublicKeyInfo,
+               SPKIDer = public_key:der_encode('SubjectPublicKeyInfo', SPKI),
+               {ok, SPKIDer}
+       catch
+               _:Reason ->
+                       {error, Reason}
+       end.
+
+%% @doc Verify a signed gossip document (JSON binary).
+%% Returns `{ok, NodeSPKI}' if signature is valid, `{error, Reason}' otherwise.
+-spec verify_gossip_doc(JSONBin :: binary()) -> {ok, binary()} | {error, 
term()}.
+verify_gossip_doc(JSONBin) ->
+       try
+               {Props} = jiffy:decode(JSONBin),
+               NodeB64 = proplists:get_value(<<"node">>, Props),
+               Peers = proplists:get_value(<<"peers">>, Props),
+               Timestamp = proplists:get_value(<<"timestamp">>, Props),
+               SigB64 = proplists:get_value(<<"signature">>, Props),
+               NodeSPKI = ar_util:decode(NodeB64),
+               Sig = ar_util:decode(SigB64),
+               Payload = jiffy:encode({[
+                       {<<"node">>, NodeB64},
+                       {<<"peers">>, Peers},
+                       {<<"timestamp">>, Timestamp}
+               ]}),
+               SubjectPKI = public_key:der_decode('SubjectPublicKeyInfo', 
NodeSPKI),
+               case public_key:verify(Payload, sha256, Sig, SubjectPKI) of
+                       true ->
+                               {ok, NodeSPKI};
+                       false ->
+                               {error, invalid_signature}
+               end
+       catch
+               _:Reason ->
+                       {error, Reason}
+       end.
+
+%% ===================================================================
+%% Private helpers
+%% ===================================================================
+
+%% Auto-generate self-signed P-256 cert+key in {DataDir}/tls/.
+%% Only generate if the files don't already exist.
+auto_generate_cert(Config) ->
+       DataDir = Config#config.data_dir,
+       TlsDir = filename:join(DataDir, "tls"),
+       CertFile = filename:join(TlsDir, "arweave.crt"),
+       KeyFile = filename:join(TlsDir, "arweave.key"),
+       case filelib:is_regular(CertFile) andalso filelib:is_regular(KeyFile) of
+               true ->
+                       {CertFile, KeyFile};
+               false ->
+                       ok = filelib:ensure_dir(CertFile),
+                       ECKey = public_key:generate_key({namedCurve, 
secp256r1}),
+                       {Year, Month, Day} = date(),
+                       NotBefore = {utcTime, format_utc_time(Year, Month, 
Day)},
+                       NotAfter = {utcTime, format_utc_time(Year + 10, Month, 
Day)},
+                       Subject = {rdnSequence, [[#'AttributeTypeAndValue'{
+                               type = ?'id-at-commonName',
+                               value = {utf8String, <<"arweave">>}
+                       }]]},
+                       #'ECPrivateKey'{publicKey = PubKeyBin, parameters = 
Params} = ECKey,
+                       TBS = #'OTPTBSCertificate'{
+                               version = v3,
+                               serialNumber = rand:uniform(1 bsl 128),
+                               signature = #'SignatureAlgorithm'{
+                                       algorithm = ?'ecdsa-with-SHA256',
+                                       parameters = asn1_NOVALUE
+                               },
+                               issuer = Subject,
+                               validity = #'Validity'{
+                                       notBefore = NotBefore,
+                                       notAfter = NotAfter
+                               },
+                               subject = Subject,
+                               subjectPublicKeyInfo = 
#'OTPSubjectPublicKeyInfo'{
+                                       algorithm = #'PublicKeyAlgorithm'{
+                                               algorithm = ?'id-ecPublicKey',
+                                               parameters = Params
+                                       },
+                                       subjectPublicKey = #'ECPoint'{point = 
PubKeyBin}
+                               },
+                               extensions = asn1_NOVALUE
+                       },
+                       DerCert = public_key:pkix_sign(TBS, ECKey),
+                       CertPem = public_key:pem_encode([{'Certificate', 
DerCert, not_encrypted}]),
+                       KeyDer = public_key:der_encode('ECPrivateKey', ECKey),
+                       KeyPem = public_key:pem_encode([{'ECPrivateKey', 
KeyDer, not_encrypted}]),
+                       ok = file:write_file(CertFile, CertPem),
+                       ok = file:write_file(KeyFile, KeyPem),
+                       ?LOG_INFO([{event, tls_auto_cert_generated},
+                               {cert, CertFile},
+                               {key, KeyFile}]),
+                       {CertFile, KeyFile}
+       end.
+
+%% Format a date as UTC time string "YYMMDDHHMMSSZ".
+format_utc_time(Year, Month, Day) ->
+       YY = Year rem 100,
+       lists:flatten(io_lib:format("~2..0B~2..0B~2..0B000000Z", [YY, Month, 
Day])).
+
+%% Build a peer entry for the gossip document.
+peer_entry(Peer) ->
+       Addr = list_to_binary(ar_util:format_peer(Peer)),
+       case peer_tls_info(Peer) of
+               {ok, SPKIDer} ->
+                       {[{<<"addr">>, Addr}, {<<"key">>, 
ar_util:encode(SPKIDer)}]};
+               not_found ->
+                       {[{<<"addr">>, Addr}]}
+       end.
+
+%% Extract the addr binary for sorting.
+addr_bin({[{<<"addr">>, Addr} | _]}) ->
+       Addr.

```
  • ... 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