Add listener TLS support and pinning tests

This commit is contained in:
2026-03-17 00:48:48 +01:00
parent 1f608ee2bd
commit e8fd6c7328
17 changed files with 1374 additions and 57 deletions

View File

@@ -123,6 +123,18 @@ defmodule Parrhesia.Web.ConnectionTest do
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
end
test "connection state keeps transport identity metadata" do
transport_identity = %{
source: :socket,
verified?: true,
spki_sha256: "client-spki-pin"
}
state = connection_state(transport_identity: transport_identity)
assert state.transport_identity == transport_identity
end
test "listener can require NIP-42 for reads and writes" do
listener =
listener(%{

View File

@@ -107,6 +107,91 @@ defmodule Parrhesia.Web.RouterTest do
assert allowed_conn.status == 200
end
test "GET /relay accepts proxy-asserted TLS identity from trusted proxies" do
test_listener =
listener(%{
transport: %{
scheme: :http,
tls: %{
mode: :proxy_terminated,
proxy_headers: %{enabled: true, required: true}
}
},
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
})
conn =
conn(:get, "/relay")
|> put_req_header("accept", "application/nostr+json")
|> put_req_header("x-parrhesia-client-cert-verified", "true")
|> put_req_header("x-parrhesia-client-spki-sha256", "proxy-spki-pin")
|> Plug.Test.put_peer_data(%{
address: {10, 1, 2, 3},
port: 443,
ssl_cert: nil
})
|> route_conn(test_listener)
assert conn.status == 200
end
test "GET /relay rejects missing proxy-asserted TLS identity when required" do
test_listener =
listener(%{
transport: %{
scheme: :http,
tls: %{
mode: :proxy_terminated,
proxy_headers: %{enabled: true, required: true}
}
},
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
})
conn =
conn(:get, "/relay")
|> put_req_header("accept", "application/nostr+json")
|> Plug.Test.put_peer_data(%{
address: {10, 1, 2, 3},
port: 443,
ssl_cert: nil
})
|> route_conn(test_listener)
assert conn.status == 403
assert conn.resp_body == "forbidden"
end
test "GET /relay rejects proxy-asserted TLS identity when the pin mismatches" do
test_listener =
listener(%{
transport: %{
scheme: :http,
tls: %{
mode: :proxy_terminated,
client_pins: [%{type: :spki_sha256, value: "expected-spki-pin"}],
proxy_headers: %{enabled: true, required: true}
}
},
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
})
conn =
conn(:get, "/relay")
|> put_req_header("accept", "application/nostr+json")
|> put_req_header("x-parrhesia-client-cert-verified", "true")
|> put_req_header("x-parrhesia-client-spki-sha256", "wrong-spki-pin")
|> Plug.Test.put_peer_data(%{
address: {10, 1, 2, 3},
port: 443,
ssl_cert: nil
})
|> route_conn(test_listener)
assert conn.status == 403
assert conn.resp_body == "forbidden"
end
test "POST /management requires authorization" do
conn =
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))

View File

@@ -0,0 +1,343 @@
defmodule Parrhesia.Web.TLSE2ETest do
use ExUnit.Case, async: false
alias Ecto.Adapters.SQL.Sandbox
alias Parrhesia.Repo
alias Parrhesia.Sync.Transport.WebSockexClient
alias Parrhesia.TestSupport.TLSCerts
alias Parrhesia.Web.Endpoint
setup do
:ok = Sandbox.checkout(Repo)
Sandbox.mode(Repo, {:shared, self()})
tmp_dir =
Path.join(
System.tmp_dir!(),
"parrhesia_tls_e2e_#{System.unique_integer([:positive, :monotonic])}"
)
File.mkdir_p!(tmp_dir)
on_exit(fn ->
Sandbox.mode(Repo, :manual)
_ = File.rm_rf(tmp_dir)
end)
{:ok, tmp_dir: tmp_dir}
end
test "HTTPS listener serves NIP-11 and reloads certificate files from disk", %{tmp_dir: tmp_dir} do
ca = TLSCerts.create_ca!(tmp_dir, "reload")
server_a = TLSCerts.issue_server_cert!(tmp_dir, ca, "reload-server-a")
server_b = TLSCerts.issue_server_cert!(tmp_dir, ca, "reload-server-b")
active_certfile = Path.join(tmp_dir, "active-server.cert.pem")
active_keyfile = Path.join(tmp_dir, "active-server.key.pem")
File.cp!(server_a.certfile, active_certfile)
File.cp!(server_a.keyfile, active_keyfile)
port = free_port()
endpoint_name = unique_name("TLSEndpointReload")
listener_id = :reload_tls
start_supervised!(
{Endpoint,
name: endpoint_name,
listeners: %{
listener_id =>
listener(listener_id, port, %{
transport: %{
scheme: :https,
tls: %{
mode: :server,
certfile: active_certfile,
keyfile: active_keyfile,
cipher_suite: :compatible
}
}
})
}}
)
assert_eventually(fn ->
case nip11_request(port, ca.certfile) do
{:ok, 200} -> true
_other -> false
end
end)
first_fingerprint = server_cert_fingerprint(port)
assert first_fingerprint == TLSCerts.cert_sha256!(server_a.certfile)
File.cp!(server_b.certfile, active_certfile)
File.cp!(server_b.keyfile, active_keyfile)
assert :ok = Endpoint.reload_listener(endpoint_name, listener_id)
assert_eventually(fn ->
server_cert_fingerprint(port) == TLSCerts.cert_sha256!(server_b.certfile)
end)
end
test "mutual TLS requires a client certificate and enforces optional client pins", %{
tmp_dir: tmp_dir
} do
ca = TLSCerts.create_ca!(tmp_dir, "mutual")
server = TLSCerts.issue_server_cert!(tmp_dir, ca, "mutual-server")
allowed_client = TLSCerts.issue_client_cert!(tmp_dir, ca, "allowed-client")
other_client = TLSCerts.issue_client_cert!(tmp_dir, ca, "other-client")
allowed_pin = TLSCerts.spki_pin!(allowed_client.certfile)
port = free_port()
start_supervised!(
{Endpoint,
name: unique_name("TLSEndpointMutual"),
listeners: %{
mutual_tls:
listener(:mutual_tls, port, %{
transport: %{
scheme: :https,
tls: %{
mode: :mutual,
certfile: server.certfile,
keyfile: server.keyfile,
cacertfile: ca.certfile,
client_pins: [%{type: :spki_sha256, value: allowed_pin}]
}
}
})
}}
)
assert {:error, _reason} = nip11_request(port, ca.certfile)
assert {:ok, 403} =
nip11_request(
port,
ca.certfile,
certfile: other_client.certfile,
keyfile: other_client.keyfile
)
assert {:ok, 200} =
nip11_request(
port,
ca.certfile,
certfile: allowed_client.certfile,
keyfile: allowed_client.keyfile
)
end
test "WSS relay accepts a pinned server certificate", %{tmp_dir: tmp_dir} do
ca = TLSCerts.create_ca!(tmp_dir, "wss")
server = TLSCerts.issue_server_cert!(tmp_dir, ca, "wss-server")
server_pin = TLSCerts.spki_pin!(server.certfile)
port = free_port()
start_supervised!(
{Endpoint,
name: unique_name("TLSEndpointWSS"),
listeners: %{
wss_tls:
listener(:wss_tls, port, %{
transport: %{
scheme: :https,
tls: %{
mode: :server,
certfile: server.certfile,
keyfile: server.keyfile
}
}
})
}}
)
server_config = %{
url: "wss://localhost:#{port}/relay",
tls: %{
mode: :required,
hostname: "localhost",
pins: [%{type: :spki_sha256, value: server_pin}]
}
}
assert {:ok, websocket} =
WebSockexClient.connect(
self(),
server_config,
websocket_opts: [ssl_options: websocket_ssl_options(ca.certfile)]
)
assert_receive {:sync_transport, ^websocket, :connected, _metadata}, 5_000
assert :ok = WebSockexClient.send_json(websocket, ["COUNT", "tls-sub", %{"kinds" => [1]}])
assert_receive {:sync_transport, ^websocket, :frame, ["COUNT", "tls-sub", payload]}, 5_000
assert is_map(payload)
assert Map.has_key?(payload, "count")
end
test "WSS relay rejects a mismatched pinned server certificate", %{tmp_dir: tmp_dir} do
ca = TLSCerts.create_ca!(tmp_dir, "wss-mismatch")
server = TLSCerts.issue_server_cert!(tmp_dir, ca, "wss-mismatch-server")
wrong_server = TLSCerts.issue_server_cert!(tmp_dir, ca, "wss-mismatch-other")
wrong_pin = TLSCerts.spki_pin!(wrong_server.certfile)
port = free_port()
start_supervised!(
{Endpoint,
name: unique_name("TLSEndpointWSSMismatch"),
listeners: %{
wss_tls_mismatch:
listener(:wss_tls_mismatch, port, %{
transport: %{
scheme: :https,
tls: %{
mode: :server,
certfile: server.certfile,
keyfile: server.keyfile
}
}
})
}}
)
server_config = %{
url: "wss://localhost:#{port}/relay",
tls: %{
mode: :required,
hostname: "localhost",
pins: [%{type: :spki_sha256, value: wrong_pin}]
}
}
assert {:error, %WebSockex.ConnError{original: {:tls_alert, {:handshake_failure, _reason}}}} =
WebSockexClient.connect(
self(),
server_config,
websocket_opts: [ssl_options: websocket_ssl_options(ca.certfile)]
)
end
defp listener(id, port, overrides) do
deep_merge(
%{
id: id,
enabled: true,
bind: %{ip: {127, 0, 0, 1}, port: port},
transport: %{scheme: :http, tls: %{mode: :disabled}},
proxy: %{trusted_cidrs: [], honor_x_forwarded_for: true},
network: %{allow_all: true},
features: %{
nostr: %{enabled: true},
admin: %{enabled: true},
metrics: %{enabled: false, access: %{allow_all: true}, auth_token: nil}
},
auth: %{nip42_required: false, nip98_required_for_admin: true},
baseline_acl: %{read: [], write: []},
bandit_options: []
},
overrides
)
end
defp nip11_request(port, cacertfile, opts \\ []) do
transport_opts =
[
mode: :binary,
verify: :verify_peer,
cacertfile: String.to_charlist(cacertfile),
server_name_indication: ~c"localhost",
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
|> maybe_put_file_opt(:certfile, Keyword.get(opts, :certfile))
|> maybe_put_file_opt(:keyfile, Keyword.get(opts, :keyfile))
case Req.get(
url: "https://localhost:#{port}/relay",
headers: [{"accept", "application/nostr+json"}],
decode_body: false,
connect_options: [transport_opts: transport_opts]
) do
{:ok, %Req.Response{status: status}} -> {:ok, status}
{:error, reason} -> {:error, reason}
end
end
defp server_cert_fingerprint(port) do
{:ok, socket} =
:ssl.connect(
~c"127.0.0.1",
port,
[verify: :verify_none, active: false, server_name_indication: ~c"localhost"],
5_000
)
{:ok, cert_der} = :ssl.peercert(socket)
:ok = :ssl.close(socket)
Base.encode64(:crypto.hash(:sha256, cert_der))
end
defp ca_certs(certfile) do
certfile
|> File.read!()
|> :public_key.pem_decode()
|> Enum.map(&elem(&1, 1))
end
defp maybe_put_file_opt(options, _key, nil), do: options
defp maybe_put_file_opt(options, key, value) do
Keyword.put(options, key, String.to_charlist(value))
end
defp websocket_ssl_options(cacertfile) do
[
cacerts: ca_certs(cacertfile),
server_name_indication: ~c"localhost",
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
end
defp deep_merge(left, right) when is_map(left) and is_map(right) do
Map.merge(left, right, fn _key, left_value, right_value ->
if is_map(left_value) and is_map(right_value) do
deep_merge(left_value, right_value)
else
right_value
end
end)
end
defp free_port do
{:ok, socket} = :gen_tcp.listen(0, [:binary, active: false, packet: :raw, reuseaddr: true])
{:ok, port} = :inet.port(socket)
:ok = :gen_tcp.close(socket)
port
end
defp unique_name(prefix) do
:"#{prefix}_#{System.unique_integer([:positive, :monotonic])}"
end
defp assert_eventually(fun, attempts \\ 40)
defp assert_eventually(_fun, 0), do: flunk("condition was not met in time")
defp assert_eventually(fun, attempts) do
if fun.() do
:ok
else
receive do
after
50 -> assert_eventually(fun, attempts - 1)
end
end
end
end