Add listener TLS support and pinning tests
This commit is contained in:
@@ -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(%{
|
||||
|
||||
@@ -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" => %{}}))
|
||||
|
||||
343
test/parrhesia/web/tls_e2e_test.exs
Normal file
343
test/parrhesia/web/tls_e2e_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user