diff --git a/README.md b/README.md index b2fa6a0..7a55d5d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ It exposes: - operational HTTP endpoints such as `/health`, `/ready`, and `/metrics` on listeners that enable them - a NIP-86-style management API at `POST /management` on listeners that enable the `admin` feature +Listeners can run in plain HTTP, HTTPS, mutual TLS, or proxy-terminated TLS modes. The current TLS implementation supports: + +- server TLS on listener sockets +- optional client certificate admission with listener-side client pin checks +- proxy-asserted client TLS identity on trusted proxy hops +- admin-triggered certificate reload by restarting an individual listener from disk + ## Supported NIPs Current `supported_nips` list: @@ -174,6 +181,8 @@ CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/fa | `:metrics.bind.port` | `PARRHESIA_METRICS_ENDPOINT_PORT` | `9568` | Optional dedicated metrics listener port | | `:metrics.enabled` | `PARRHESIA_METRICS_ENDPOINT_ENABLED` | `false` | Enables the optional dedicated metrics listener | +Listener `transport.tls` supports `:disabled`, `:server`, `:mutual`, and `:proxy_terminated`. For TLS-enabled listeners, the main config-file fields are `certfile`, `keyfile`, optional `cacertfile`, optional `cipher_suite`, optional `client_pins`, and `proxy_headers` for proxy-terminated identity. + #### `:limits` | Atom key | ENV | Default | diff --git a/docs/ARCH.md b/docs/ARCH.md index c78650b..2a74c3c 100644 --- a/docs/ARCH.md +++ b/docs/ARCH.md @@ -103,8 +103,10 @@ Failure model: Ingress model: - Ingress is defined through `config :parrhesia, :listeners, ...`. -- Each listener has its own bind/transport settings, proxy trust, network allowlist, enabled features (`nostr`, `admin`, `metrics`), auth requirements, and baseline read/write ACL. +- Each listener has its own bind/transport settings, TLS mode, proxy trust, network allowlist, enabled features (`nostr`, `admin`, `metrics`), auth requirements, and baseline read/write ACL. - Listeners can therefore expose different security postures, for example a public relay listener and a VPN-only sync-capable listener. +- TLS-capable listeners support direct server TLS, mutual TLS with optional client pin checks, and proxy-terminated TLS identity on explicitly trusted proxy hops. +- Certificate reload is currently implemented as admin-triggered listener restart from disk rather than background file watching. ## 5) Core runtime components diff --git a/lib/parrhesia/api/admin.ex b/lib/parrhesia/api/admin.ex index cafcc2a..0ab670d 100644 --- a/lib/parrhesia/api/admin.ex +++ b/lib/parrhesia/api/admin.ex @@ -7,9 +7,11 @@ defmodule Parrhesia.API.Admin do alias Parrhesia.API.Identity alias Parrhesia.API.Sync alias Parrhesia.Storage + alias Parrhesia.Web.Endpoint @supported_acl_methods ~w(acl_grant acl_revoke acl_list) @supported_identity_methods ~w(identity_ensure identity_get identity_import identity_rotate) + @supported_listener_methods ~w(listener_reload) @supported_sync_methods ~w( sync_get_server sync_health @@ -96,7 +98,8 @@ defmodule Parrhesia.API.Admin do end (storage_supported ++ - @supported_acl_methods ++ @supported_identity_methods ++ @supported_sync_methods) + @supported_acl_methods ++ + @supported_identity_methods ++ @supported_listener_methods ++ @supported_sync_methods) |> Enum.uniq() |> Enum.sort() end @@ -111,6 +114,22 @@ defmodule Parrhesia.API.Admin do Identity.import(params) end + defp listener_reload(params) do + case normalize_listener_id(fetch_value(params, :id)) do + :all -> + Endpoint.reload_all() + |> ok_result() + + {:ok, listener_id} -> + listener_id + |> Endpoint.reload_listener() + |> ok_result() + + :error -> + {:error, :not_found} + end + end + defp sync_put_server(params, opts), do: Sync.put_server(params, opts) defp sync_remove_server(params, opts) do @@ -173,6 +192,7 @@ defmodule Parrhesia.API.Admin do defp execute_builtin("identity_ensure", params, _opts), do: identity_ensure(params) defp execute_builtin("identity_import", params, _opts), do: identity_import(params) defp execute_builtin("identity_rotate", params, _opts), do: identity_rotate(params) + defp execute_builtin("listener_reload", params, _opts), do: listener_reload(params) defp execute_builtin("sync_put_server", params, opts), do: sync_put_server(params, opts) defp execute_builtin("sync_remove_server", params, opts), do: sync_remove_server(params, opts) defp execute_builtin("sync_get_server", params, opts), do: sync_get_server(params, opts) @@ -203,6 +223,35 @@ defmodule Parrhesia.API.Admin do defp maybe_put_opt(opts, _key, nil), do: opts defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value) + defp ok_result(:ok), do: {:ok, %{"ok" => true}} + defp ok_result({:error, _reason} = error), do: error + defp ok_result(other), do: other + + defp normalize_listener_id(nil), do: :all + + defp normalize_listener_id(listener_id) when is_atom(listener_id) do + {:ok, listener_id} + end + + defp normalize_listener_id(listener_id) when is_binary(listener_id) do + case Supervisor.which_children(Endpoint) do + children when is_list(children) -> + Enum.find_value(children, :error, &match_listener_child(&1, listener_id)) + + _other -> + :error + end + end + + defp normalize_listener_id(_listener_id), do: :error + + defp match_listener_child({{:listener, id}, _pid, _type, _modules}, listener_id) do + normalized_id = Atom.to_string(id) + if normalized_id == listener_id, do: {:ok, id}, else: false + end + + defp match_listener_child(_child, _listener_id), do: false + defp fetch_required_string(map, key) do case fetch_value(map, key) do value when is_binary(value) and value != "" -> {:ok, value} diff --git a/lib/parrhesia/api/request_context.ex b/lib/parrhesia/api/request_context.ex index 50a7752..976eab9 100644 --- a/lib/parrhesia/api/request_context.ex +++ b/lib/parrhesia/api/request_context.ex @@ -9,6 +9,7 @@ defmodule Parrhesia.API.RequestContext do remote_ip: nil, subscription_id: nil, peer_id: nil, + transport_identity: nil, metadata: %{} @type t :: %__MODULE__{ @@ -18,6 +19,7 @@ defmodule Parrhesia.API.RequestContext do remote_ip: String.t() | nil, subscription_id: String.t() | nil, peer_id: String.t() | nil, + transport_identity: map() | nil, metadata: map() } diff --git a/lib/parrhesia/sync/tls.ex b/lib/parrhesia/sync/tls.ex index 1f10d12..5133388 100644 --- a/lib/parrhesia/sync/tls.ex +++ b/lib/parrhesia/sync/tls.ex @@ -1,18 +1,6 @@ defmodule Parrhesia.Sync.TLS do @moduledoc false - require Record - - Record.defrecordp( - :otp_certificate, - Record.extract(:OTPCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl") - ) - - Record.defrecordp( - :otp_tbs_certificate, - Record.extract(:OTPTBSCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl") - ) - @type tls_config :: %{ mode: :required | :disabled, hostname: String.t(), @@ -44,10 +32,19 @@ defmodule Parrhesia.Sync.TLS do server_name_indication: String.to_charlist(hostname), customize_hostname_check: [ match_fun: :public_key.pkix_verify_hostname_match_fun(:https) - ], - verify_fun: - {&verify_certificate/3, %{pins: MapSet.new(Enum.map(pins, & &1.value)), matched?: false}} + ] ] + |> maybe_put_verify_fun(pins) + end + + defp maybe_put_verify_fun(options, []), do: options + + defp maybe_put_verify_fun(options, pins) do + Keyword.put( + options, + :verify_fun, + {&verify_certificate/3, %{pins: MapSet.new(Enum.map(pins, & &1.value)), matched?: false}} + ) end defp verify_certificate(_cert, :valid_peer, %{matched?: true} = state), do: {:valid, state} @@ -56,7 +53,21 @@ defmodule Parrhesia.Sync.TLS do defp verify_certificate(_cert, {:bad_cert, reason}, _state), do: {:fail, reason} defp verify_certificate(cert, _event, state) when is_binary(cert) do - matched? = MapSet.member?(state.pins, spki_pin(cert)) + matched? = MapSet.member?(state.pins, spki_pin_from_verify(cert)) + {:valid, %{state | matched?: state.matched? or matched?}} + rescue + _error -> {:fail, :invalid_certificate} + end + + defp verify_certificate({:OTPCertificate, _tbs, _sig_alg, _sig} = cert, _event, state) do + matched? = MapSet.member?(state.pins, spki_pin_from_verify(cert)) + {:valid, %{state | matched?: state.matched? or matched?}} + rescue + _error -> {:fail, :invalid_certificate} + end + + defp verify_certificate({:Certificate, _tbs, _sig_alg, _sig} = cert, _event, state) do + matched? = MapSet.member?(state.pins, spki_pin_from_verify(cert)) {:valid, %{state | matched?: state.matched? or matched?}} rescue _error -> {:fail, :invalid_certificate} @@ -65,19 +76,32 @@ defmodule Parrhesia.Sync.TLS do defp verify_certificate(_cert, _event, state), do: {:valid, state} defp spki_pin(cert_der) do - cert = :public_key.pkix_decode_cert(cert_der, :otp) + cert = :public_key.pkix_decode_cert(cert_der, :plain) + spki = cert |> elem(1) |> elem(7) - spki = - cert - |> otp_certificate(:tbsCertificate) - |> otp_tbs_certificate(:subjectPublicKeyInfo) - - spki - |> :public_key.der_encode(:SubjectPublicKeyInfo) + :public_key.der_encode(:SubjectPublicKeyInfo, spki) |> then(&:crypto.hash(:sha256, &1)) |> Base.encode64() end + defp spki_pin_from_verify(cert) when is_binary(cert), do: spki_pin(cert) + + defp spki_pin_from_verify({:OTPCertificate, _tbs, _sig_alg, _sig} = cert) do + cert + |> then(&:public_key.pkix_encode(:OTPCertificate, &1, :otp)) + |> spki_pin() + end + + defp spki_pin_from_verify({:Certificate, _tbs, _sig_alg, _sig} = cert) do + cert + |> then(&:public_key.der_encode(:Certificate, &1)) + |> spki_pin() + end + + defp spki_pin_from_verify(_cert) do + raise(ArgumentError, "invalid certificate") + end + defp system_cacerts do if function_exported?(:public_key, :cacerts_get, 0) do :public_key.cacerts_get() diff --git a/lib/parrhesia/sync/transport/websockex_client.ex b/lib/parrhesia/sync/transport/websockex_client.ex index 5d091f7..10463f6 100644 --- a/lib/parrhesia/sync/transport/websockex_client.ex +++ b/lib/parrhesia/sync/transport/websockex_client.ex @@ -17,7 +17,7 @@ defmodule Parrhesia.Sync.Transport.WebSockexClient do transport_opts = server.tls |> TLS.websocket_options() - |> Keyword.merge(Keyword.get(opts, :websocket_opts, [])) + |> merge_websocket_opts(Keyword.get(opts, :websocket_opts, [])) |> Keyword.put(:handle_initial_conn_failure, true) WebSockex.start(server.url, __MODULE__, state, transport_opts) @@ -71,4 +71,23 @@ defmodule Parrhesia.Sync.Transport.WebSockexClient do send(state.owner, {:sync_transport, self(), :disconnected, status}) {:ok, state} end + + defp merge_websocket_opts(base_opts, override_opts) do + override_ssl_options = Keyword.get(override_opts, :ssl_options) + + merged_ssl_options = + case {Keyword.get(base_opts, :ssl_options), override_ssl_options} do + {nil, nil} -> nil + {base_ssl, nil} -> base_ssl + {nil, override_ssl} -> override_ssl + {base_ssl, override_ssl} -> Keyword.merge(base_ssl, override_ssl) + end + + base_opts + |> Keyword.merge(Keyword.delete(override_opts, :ssl_options)) + |> maybe_put_ssl_options(merged_ssl_options) + end + + defp maybe_put_ssl_options(opts, nil), do: opts + defp maybe_put_ssl_options(opts, ssl_options), do: Keyword.put(opts, :ssl_options, ssl_options) end diff --git a/lib/parrhesia/test_support/tls_certs.ex b/lib/parrhesia/test_support/tls_certs.ex new file mode 100644 index 0000000..ebddb2b --- /dev/null +++ b/lib/parrhesia/test_support/tls_certs.ex @@ -0,0 +1,145 @@ +defmodule Parrhesia.TestSupport.TLSCerts do + @moduledoc false + + @spec create_ca!(String.t(), String.t()) :: map() + def create_ca!(dir, name) do + keyfile = Path.join(dir, "#{name}-ca.key.pem") + certfile = Path.join(dir, "#{name}-ca.cert.pem") + + openssl!([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-sha256", + "-days", + "2", + "-subj", + "/CN=#{name} Test CA", + "-keyout", + keyfile, + "-out", + certfile + ]) + + %{keyfile: keyfile, certfile: certfile} + end + + @spec issue_server_cert!(String.t(), map(), String.t()) :: map() + def issue_server_cert!(dir, ca, name) do + issue_cert!( + dir, + ca, + name, + "localhost", + ["DNS:localhost", "IP:127.0.0.1"], + "serverAuth" + ) + end + + @spec issue_client_cert!(String.t(), map(), String.t()) :: map() + def issue_client_cert!(dir, ca, name) do + issue_cert!(dir, ca, name, name, [], "clientAuth") + end + + @spec spki_pin!(String.t()) :: String.t() + def spki_pin!(certfile) do + certfile + |> der_cert!() + |> spki_pin() + end + + @spec cert_sha256!(String.t()) :: String.t() + def cert_sha256!(certfile) do + certfile + |> der_cert!() + |> then(&Base.encode64(:crypto.hash(:sha256, &1))) + end + + defp issue_cert!(dir, ca, name, common_name, san_entries, extended_key_usage) do + keyfile = Path.join(dir, "#{name}.key.pem") + csrfile = Path.join(dir, "#{name}.csr.pem") + certfile = Path.join(dir, "#{name}.cert.pem") + extfile = Path.join(dir, "#{name}.ext.cnf") + + openssl!([ + "req", + "-new", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=#{common_name}", + "-keyout", + keyfile, + "-out", + csrfile + ]) + + File.write!(extfile, extension_config(san_entries, extended_key_usage)) + + openssl!([ + "x509", + "-req", + "-in", + csrfile, + "-CA", + ca.certfile, + "-CAkey", + ca.keyfile, + "-CAcreateserial", + "-out", + certfile, + "-days", + "2", + "-sha256", + "-extfile", + extfile, + "-extensions", + "v3_req" + ]) + + %{keyfile: keyfile, certfile: certfile} + end + + defp extension_config(san_entries, extended_key_usage) do + san_block = + case san_entries do + [] -> "" + entries -> "subjectAltName = #{Enum.join(entries, ",")}\n" + end + + """ + [v3_req] + basicConstraints = CA:FALSE + keyUsage = digitalSignature,keyEncipherment + extendedKeyUsage = #{extended_key_usage} + #{san_block} + """ + end + + defp der_cert!(certfile) do + certfile + |> File.read!() + |> :public_key.pem_decode() + |> List.first() + |> elem(1) + end + + defp spki_pin(cert_der) do + cert = :public_key.pkix_decode_cert(cert_der, :plain) + spki = cert |> elem(1) |> elem(7) + + :public_key.der_encode(:SubjectPublicKeyInfo, spki) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode64() + end + + defp openssl!(args) do + case System.cmd("/usr/bin/openssl", args, stderr_to_stdout: true) do + {output, 0} -> output + {output, status} -> raise "openssl failed with status #{status}: #{output}" + end + end +end diff --git a/lib/parrhesia/web/connection.ex b/lib/parrhesia/web/connection.ex index b89bdaa..b982e45 100644 --- a/lib/parrhesia/web/connection.ex +++ b/lib/parrhesia/web/connection.ex @@ -45,6 +45,7 @@ defmodule Parrhesia.Web.Connection do defstruct subscriptions: %{}, authenticated_pubkeys: MapSet.new(), listener: nil, + transport_identity: nil, max_subscriptions_per_connection: @default_max_subscriptions_per_connection, subscription_index: Index, auth_challenges: Challenges, @@ -77,6 +78,7 @@ defmodule Parrhesia.Web.Connection do subscriptions: %{String.t() => subscription()}, authenticated_pubkeys: MapSet.t(String.t()), listener: map() | nil, + transport_identity: map() | nil, max_subscriptions_per_connection: pos_integer(), subscription_index: GenServer.server() | nil, auth_challenges: GenServer.server() | nil, @@ -105,6 +107,7 @@ defmodule Parrhesia.Web.Connection do state = %__MODULE__{ listener: Listener.from_opts(opts), + transport_identity: transport_identity(opts), max_subscriptions_per_connection: max_subscriptions_per_connection(opts), subscription_index: subscription_index(opts), auth_challenges: auth_challenges, @@ -1475,10 +1478,18 @@ defmodule Parrhesia.Web.Connection do caller: :websocket, remote_ip: state.remote_ip, subscription_id: subscription_id, - metadata: %{listener_id: state.listener.id} + transport_identity: state.transport_identity, + metadata: %{ + listener_id: state.listener.id, + transport_identity: state.transport_identity + } } end + defp transport_identity(opts) when is_list(opts), do: Keyword.get(opts, :transport_identity) + defp transport_identity(opts) when is_map(opts), do: Map.get(opts, :transport_identity) + defp transport_identity(_opts), do: nil + defp authorize_authenticated_pubkey(%{"pubkey" => pubkey}) when is_binary(pubkey) do ConnectionPolicy.authorize_authenticated_pubkey(pubkey) end diff --git a/lib/parrhesia/web/endpoint.ex b/lib/parrhesia/web/endpoint.ex index 210ff07..87f2dd5 100644 --- a/lib/parrhesia/web/endpoint.ex +++ b/lib/parrhesia/web/endpoint.ex @@ -7,14 +7,43 @@ defmodule Parrhesia.Web.Endpoint do alias Parrhesia.Web.Listener - def start_link(_init_arg \\ []) do - Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + listeners = Keyword.get(opts, :listeners, :configured) + Supervisor.start_link(__MODULE__, listeners, name: name) + end + + @spec reload_listener(Supervisor.supervisor(), atom()) :: :ok | {:error, term()} + def reload_listener(supervisor \\ __MODULE__, listener_id) when is_atom(listener_id) do + with :ok <- Supervisor.terminate_child(supervisor, {:listener, listener_id}), + {:ok, _pid} <- Supervisor.restart_child(supervisor, {:listener, listener_id}) do + :ok + else + {:error, :not_found} = error -> error + {:error, _reason} = error -> error + other -> other + end + end + + @spec reload_all(Supervisor.supervisor()) :: :ok | {:error, term()} + def reload_all(supervisor \\ __MODULE__) do + supervisor + |> Supervisor.which_children() + |> Enum.filter(fn {id, _pid, _type, _modules} -> + match?({:listener, _listener_id}, id) + end) + |> Enum.reduce_while(:ok, fn {{:listener, listener_id}, _pid, _type, _modules}, :ok -> + case reload_listener(supervisor, listener_id) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) end @impl true - def init(:ok) do + def init(listeners) do children = - Listener.all() + listeners(listeners) |> Enum.map(fn listener -> %{ id: {:listener, listener.id}, @@ -24,4 +53,22 @@ defmodule Parrhesia.Web.Endpoint do Supervisor.init(children, strategy: :one_for_one) end + + defp listeners(:configured), do: Listener.all() + + defp listeners(listeners) when is_list(listeners) do + Enum.map(listeners, fn + {id, listener} when is_atom(id) and is_map(listener) -> + Listener.from_opts(listener: Map.put_new(listener, :id, id)) + + listener -> + Listener.from_opts(listener: listener) + end) + end + + defp listeners(listeners) when is_map(listeners) do + listeners + |> Enum.map(fn {id, listener} -> {id, listener} end) + |> listeners() + end end diff --git a/lib/parrhesia/web/listener.ex b/lib/parrhesia/web/listener.ex index 1bc0e52..cdf26ca 100644 --- a/lib/parrhesia/web/listener.ex +++ b/lib/parrhesia/web/listener.ex @@ -4,6 +4,7 @@ defmodule Parrhesia.Web.Listener do import Bitwise alias Parrhesia.Protocol.Filter + alias Parrhesia.Web.TLS @private_cidrs [ "127.0.0.0/8", @@ -81,11 +82,38 @@ defmodule Parrhesia.Web.Listener do listener.proxy.trusted_cidrs end + @spec trusted_proxy_request?(t(), Plug.Conn.t()) :: boolean() + def trusted_proxy_request?(listener, conn) do + TLS.trusted_proxy_request?(conn, trusted_proxies(listener)) + end + @spec remote_ip_allowed?(t(), tuple() | String.t() | nil) :: boolean() def remote_ip_allowed?(listener, remote_ip) do access_allowed?(listener.network, remote_ip) end + @spec authorize_transport_request(t(), Plug.Conn.t()) :: + {:ok, map() | nil} | {:error, atom()} + def authorize_transport_request(listener, conn) do + TLS.authorize_request(listener.transport.tls, conn, trusted_proxy_request?(listener, conn)) + end + + @spec request_scheme(t(), Plug.Conn.t()) :: :http | :https + def request_scheme(listener, conn) do + TLS.request_scheme(listener.transport.tls, conn, trusted_proxy_request?(listener, conn)) + end + + @spec request_host(t(), Plug.Conn.t()) :: String.t() + def request_host(listener, conn) do + TLS.request_host(conn, trusted_proxy_request?(listener, conn)) + end + + @spec request_port(t(), Plug.Conn.t()) :: non_neg_integer() + def request_port(listener, conn) do + scheme = request_scheme(listener, conn) + TLS.request_port(listener.transport.tls, conn, trusted_proxy_request?(listener, conn), scheme) + end + @spec metrics_allowed?(t(), Plug.Conn.t()) :: boolean() def metrics_allowed?(listener, conn) do metrics = Map.get(listener.features, :metrics, %{}) @@ -97,17 +125,19 @@ defmodule Parrhesia.Web.Listener do @spec relay_url(t(), Plug.Conn.t()) :: String.t() def relay_url(listener, conn) do - scheme = listener.transport.scheme + scheme = request_scheme(listener, conn) + host = request_host(listener, conn) + port = request_port(listener, conn) ws_scheme = if scheme == :https, do: "wss", else: "ws" port_segment = - if default_http_port?(scheme, conn.port) do + if default_http_port?(scheme, port) do "" else - ":#{conn.port}" + ":#{port}" end - "#{ws_scheme}://#{conn.host}#{port_segment}#{conn.request_path}" + "#{ws_scheme}://#{host}#{port_segment}#{conn.request_path}" end @spec relay_auth_required?(t()) :: boolean() @@ -131,12 +161,18 @@ defmodule Parrhesia.Web.Listener do @spec bandit_options(t()) :: keyword() def bandit_options(listener) do + scheme = + case listener.transport.tls.mode do + mode when mode in [:server, :mutual] -> :https + _other -> listener.transport.scheme + end + [ ip: listener.bind.ip, port: listener.bind.port, - scheme: listener.transport.scheme, + scheme: scheme, plug: {Parrhesia.Web.ListenerPlug, listener: listener} - ] ++ listener.bandit_options + ] ++ TLS.bandit_options(listener.transport.tls) ++ listener.bandit_options end defp normalize_listeners(listeners) when is_list(listeners) do @@ -202,13 +238,15 @@ defmodule Parrhesia.Web.Listener do end defp normalize_transport(transport) when is_map(transport) do + scheme = normalize_scheme(fetch_value(transport, :scheme), :http) + %{ - scheme: normalize_scheme(fetch_value(transport, :scheme), :http), - tls: normalize_map(fetch_value(transport, :tls)) + scheme: scheme, + tls: TLS.normalize_config(fetch_value(transport, :tls), scheme) } end - defp normalize_transport(_transport), do: %{scheme: :http, tls: %{}} + defp normalize_transport(_transport), do: %{scheme: :http, tls: TLS.default_config()} defp normalize_proxy(proxy) when is_map(proxy) do %{ @@ -478,7 +516,7 @@ defmodule Parrhesia.Web.Listener do id: :public, enabled: true, bind: %{ip: {0, 0, 0, 0}, port: 4413}, - transport: %{scheme: :http, tls: %{}}, + transport: %{scheme: :http, tls: TLS.default_config()}, proxy: %{trusted_cidrs: [], honor_x_forwarded_for: true}, network: %{public?: false, private_networks_only?: false, allow_cidrs: [], allow_all?: true}, features: %{ @@ -524,9 +562,6 @@ defmodule Parrhesia.Web.Listener do end end - defp normalize_map(value) when is_map(value), do: value - defp normalize_map(_value), do: %{} - defp normalize_boolean(value, _default) when is_boolean(value), do: value defp normalize_boolean(nil, default), do: default defp normalize_boolean(_value, default), do: default diff --git a/lib/parrhesia/web/management.ex b/lib/parrhesia/web/management.ex index 4f261b4..ad9e9bb 100644 --- a/lib/parrhesia/web/management.ex +++ b/lib/parrhesia/web/management.ex @@ -7,6 +7,7 @@ defmodule Parrhesia.Web.Management do alias Parrhesia.API.Admin alias Parrhesia.API.Auth + alias Parrhesia.Web.Listener @spec handle(Plug.Conn.t(), keyword()) :: Plug.Conn.t() def handle(conn, opts \\ []) do @@ -94,14 +95,15 @@ defmodule Parrhesia.Web.Management do end defp full_request_url(conn) do - scheme = Atom.to_string(conn.scheme) - host = conn.host - port = conn.port + listener = Listener.from_conn(conn) + scheme = Listener.request_scheme(listener, conn) + host = Listener.request_host(listener, conn) + port = Listener.request_port(listener, conn) port_suffix = cond do - conn.scheme == :http and port == 80 -> "" - conn.scheme == :https and port == 443 -> "" + scheme == :http and port == 80 -> "" + scheme == :https and port == 443 -> "" true -> ":#{port}" end diff --git a/lib/parrhesia/web/remote_ip.ex b/lib/parrhesia/web/remote_ip.ex index 5264312..53ce8c1 100644 --- a/lib/parrhesia/web/remote_ip.ex +++ b/lib/parrhesia/web/remote_ip.ex @@ -10,7 +10,7 @@ defmodule Parrhesia.Web.RemoteIp do @spec call(Plug.Conn.t(), term()) :: Plug.Conn.t() def call(conn, _opts) do - if trusted_proxy?(conn) do + if trusted_proxy?(conn) and honor_x_forwarded_for?(conn) do case forwarded_ip(conn) do nil -> conn forwarded_ip -> %{conn | remote_ip: forwarded_ip} @@ -70,6 +70,11 @@ defmodule Parrhesia.Web.RemoteIp do end end + defp honor_x_forwarded_for?(conn) do + listener = Listener.from_conn(conn) + listener.proxy.honor_x_forwarded_for + end + defp parse_x_forwarded_for(value) when is_binary(value) do value |> String.split(",") diff --git a/lib/parrhesia/web/router.ex b/lib/parrhesia/web/router.ex index 9860044..5812a58 100644 --- a/lib/parrhesia/web/router.ex +++ b/lib/parrhesia/web/router.ex @@ -39,7 +39,7 @@ defmodule Parrhesia.Web.Router do if Listener.feature_enabled?(listener, :metrics) do case authorize_listener_request(conn, listener) do - :ok -> Metrics.handle(conn) + {:ok, conn} -> Metrics.handle(conn) {:error, :forbidden} -> send_resp(conn, 403, "forbidden") end else @@ -52,7 +52,7 @@ defmodule Parrhesia.Web.Router do if Listener.feature_enabled?(listener, :admin) do case authorize_listener_request(conn, listener) do - :ok -> Management.handle(conn, listener: listener) + {:ok, conn} -> Management.handle(conn, listener: listener) {:error, :forbidden} -> send_resp(conn, 403, "forbidden") end else @@ -65,7 +65,7 @@ defmodule Parrhesia.Web.Router do if Listener.feature_enabled?(listener, :nostr) do case authorize_listener_request(conn, listener) do - :ok -> + {:ok, conn} -> if accepts_nip11?(conn) do body = JSON.encode!(RelayInfo.document(listener)) @@ -79,7 +79,8 @@ defmodule Parrhesia.Web.Router do %{ listener: listener, relay_url: Listener.relay_url(listener, conn), - remote_ip: remote_ip(conn) + remote_ip: remote_ip(conn), + transport_identity: transport_identity(conn) }, timeout: 60_000, max_frame_size: max_frame_bytes() @@ -118,10 +119,12 @@ defmodule Parrhesia.Web.Router do defp authorize_listener_request(conn, listener) do with :ok <- authorize_remote_ip(conn), - true <- Listener.remote_ip_allowed?(listener, conn.remote_ip) do - :ok + true <- Listener.remote_ip_allowed?(listener, conn.remote_ip), + {:ok, transport_identity} <- Listener.authorize_transport_request(listener, conn) do + {:ok, maybe_put_transport_identity(conn, transport_identity)} else {:error, :ip_blocked} -> {:error, :forbidden} + {:error, _reason} -> {:error, :forbidden} false -> {:error, :forbidden} end end @@ -137,4 +140,14 @@ defmodule Parrhesia.Web.Router do _other -> nil end end + + defp maybe_put_transport_identity(conn, nil), do: conn + + defp maybe_put_transport_identity(conn, transport_identity) do + Plug.Conn.put_private(conn, :parrhesia_transport_identity, transport_identity) + end + + defp transport_identity(conn) do + Map.get(conn.private, :parrhesia_transport_identity) + end end diff --git a/lib/parrhesia/web/tls.ex b/lib/parrhesia/web/tls.ex new file mode 100644 index 0000000..ca8dbb8 --- /dev/null +++ b/lib/parrhesia/web/tls.ex @@ -0,0 +1,514 @@ +defmodule Parrhesia.Web.TLS do + @moduledoc false + + import Bitwise + + require Record + + Record.defrecordp( + :otp_certificate, + Record.extract(:OTPCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl") + ) + + Record.defrecordp( + :otp_tbs_certificate, + Record.extract(:OTPTBSCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl") + ) + + @default_proxy_headers %{ + enabled: false, + required: false, + verify_header: "x-parrhesia-client-cert-verified", + verified_values: ["1", "true", "success", "verified"], + cert_sha256_header: "x-parrhesia-client-cert-sha256", + spki_sha256_header: "x-parrhesia-client-spki-sha256", + subject_header: "x-parrhesia-client-cert-subject" + } + + @type pin :: %{ + type: :cert_sha256 | :spki_sha256, + value: String.t() + } + + @type config :: %{ + mode: :disabled | :server | :mutual | :proxy_terminated, + certfile: String.t() | nil, + keyfile: String.t() | nil, + cacertfile: String.t() | nil, + otp_app: atom() | nil, + cipher_suite: :strong | :compatible | nil, + client_pins: [pin()], + proxy_headers: map() + } + + @spec default_config() :: config() + def default_config do + %{ + mode: :disabled, + certfile: nil, + keyfile: nil, + cacertfile: nil, + otp_app: nil, + cipher_suite: nil, + client_pins: [], + proxy_headers: @default_proxy_headers + } + end + + @spec normalize_config(map() | nil, atom()) :: config() + def normalize_config(tls, scheme) + + def normalize_config(tls, scheme) when is_map(tls) do + defaults = + default_config() + |> Map.put(:mode, default_mode(scheme)) + + %{ + mode: normalize_mode(fetch_value(tls, :mode), defaults.mode), + certfile: normalize_optional_string(fetch_value(tls, :certfile)), + keyfile: normalize_optional_string(fetch_value(tls, :keyfile)), + cacertfile: normalize_optional_string(fetch_value(tls, :cacertfile)), + otp_app: normalize_optional_atom(fetch_value(tls, :otp_app)), + cipher_suite: normalize_cipher_suite(fetch_value(tls, :cipher_suite)), + client_pins: normalize_pins(fetch_value(tls, :client_pins)), + proxy_headers: normalize_proxy_headers(fetch_value(tls, :proxy_headers)) + } + end + + def normalize_config(_tls, scheme) do + %{default_config() | mode: default_mode(scheme)} + end + + @spec bandit_options(config()) :: keyword() + def bandit_options(%{mode: mode}) when mode in [:disabled, :proxy_terminated], do: [] + + def bandit_options(%{mode: mode} = tls) when mode in [:server, :mutual] do + transport_options = + transport_options(tls) + |> Keyword.merge(mutual_tls_options(tls)) + |> configure_server_tls() + + [ + scheme: :https, + thousand_island_options: [transport_options: transport_options] + ] + end + + @spec authorize_request(config(), Plug.Conn.t(), boolean()) :: + {:ok, map() | nil} | {:error, atom()} + def authorize_request(%{mode: :disabled}, _conn, _trusted_proxy?), do: {:ok, nil} + + def authorize_request(%{mode: :server}, conn, _trusted_proxy?) do + {:ok, socket_identity(conn)} + end + + def authorize_request(%{mode: :mutual} = tls, conn, _trusted_proxy?) do + with %{} = identity <- socket_identity(conn), + :ok <- verify_pins(identity, tls.client_pins, :client_certificate_pin_mismatch) do + {:ok, identity} + else + nil -> {:error, :client_certificate_required} + {:error, _reason} = error -> error + end + end + + def authorize_request(%{mode: :proxy_terminated} = tls, conn, trusted_proxy?) do + proxy_headers = tls.proxy_headers + + if proxy_headers.enabled do + authorize_proxy_identity(conn, proxy_headers, tls.client_pins, trusted_proxy?) + else + {:ok, nil} + end + end + + @spec request_scheme(config(), Plug.Conn.t(), boolean()) :: :http | :https + def request_scheme(%{mode: :proxy_terminated}, conn, true) do + case header_value(conn, "x-forwarded-proto") do + "https" -> :https + "http" -> :http + _other -> conn.scheme + end + end + + def request_scheme(_tls, conn, _trusted_proxy?), do: conn.scheme + + @spec request_host(Plug.Conn.t(), boolean()) :: String.t() + def request_host(conn, true) do + case header_value(conn, "x-forwarded-host") do + nil -> conn.host + value -> String.split(value, ",", parts: 2) |> List.first() |> String.trim() + end + end + + def request_host(conn, false), do: conn.host + + @spec request_port(config(), Plug.Conn.t(), boolean(), :http | :https) :: non_neg_integer() + def request_port(%{mode: :proxy_terminated}, conn, true, forwarded_scheme) do + case header_value(conn, "x-forwarded-port") do + nil -> default_port(forwarded_scheme) + value -> parse_port(value, default_port(forwarded_scheme)) + end + end + + def request_port(_tls, conn, _trusted_proxy?, _scheme), do: conn.port + + @spec trusted_proxy_request?(Plug.Conn.t(), [String.t()]) :: boolean() + def trusted_proxy_request?(conn, trusted_cidrs) when is_list(trusted_cidrs) do + case Plug.Conn.get_peer_data(conn) do + %{address: address} -> Enum.any?(trusted_cidrs, &ip_in_cidr?(address, &1)) + _other -> false + end + end + + def trusted_proxy_request?(_conn, _trusted_cidrs), do: false + + defp transport_options(tls) do + [] + |> maybe_put_opt(:keyfile, tls.keyfile) + |> maybe_put_opt(:certfile, tls.certfile) + |> maybe_put_opt(:cacertfile, tls.cacertfile) + |> maybe_put_opt(:otp_app, tls.otp_app) + |> maybe_put_opt(:cipher_suite, tls.cipher_suite) + end + + defp mutual_tls_options(%{mode: :mutual, cacertfile: nil}) do + [ + verify: :verify_peer, + fail_if_no_peer_cert: true, + cacerts: system_cacerts() + ] + end + + defp mutual_tls_options(%{mode: :mutual}) do + [ + verify: :verify_peer, + fail_if_no_peer_cert: true + ] + end + + defp mutual_tls_options(_tls), do: [] + + defp configure_server_tls(options) do + case Plug.SSL.configure(options) do + {:ok, configured} -> configured + {:error, message} -> raise ArgumentError, "invalid listener TLS config: #{message}" + end + end + + defp authorize_proxy_identity(conn, proxy_headers, pins, trusted_proxy?) do + cond do + not trusted_proxy? and proxy_headers.required -> + {:error, :proxy_tls_identity_required} + + not trusted_proxy? -> + {:ok, nil} + + true -> + conn + |> proxy_identity(proxy_headers) + |> authorize_proxy_identity_value(proxy_headers.required, pins) + end + end + + defp authorize_proxy_identity_value(nil, true, _pins), + do: {:error, :proxy_tls_identity_required} + + defp authorize_proxy_identity_value(nil, false, _pins), do: {:ok, nil} + + defp authorize_proxy_identity_value(%{verified?: false}, _required, _pins) do + {:error, :proxy_tls_identity_unverified} + end + + defp authorize_proxy_identity_value(identity, _required, pins) do + case verify_pins(identity, pins, :proxy_tls_identity_pin_mismatch) do + :ok -> {:ok, identity} + {:error, _reason} = error -> error + end + end + + defp proxy_identity(conn, proxy_headers) do + cert_sha256 = header_value(conn, proxy_headers.cert_sha256_header) + spki_sha256 = header_value(conn, proxy_headers.spki_sha256_header) + subject = header_value(conn, proxy_headers.subject_header) + verified? = verified_header?(conn, proxy_headers) + + if cert_sha256 || spki_sha256 || subject do + %{ + source: :proxy, + verified?: verified?, + cert_sha256: cert_sha256, + spki_sha256: spki_sha256, + subject: subject + } + end + end + + defp verified_header?(conn, proxy_headers) do + case header_value(conn, proxy_headers.verify_header) do + nil -> + false + + value -> + normalized = String.downcase(String.trim(value)) + normalized in Enum.map(proxy_headers.verified_values, &String.downcase/1) + end + end + + defp socket_identity(conn) do + case Plug.Conn.get_peer_data(conn) do + %{ssl_cert: cert_der} when is_binary(cert_der) -> + certificate_identity(cert_der, :socket) + + _other -> + nil + end + rescue + _error -> nil + end + + defp certificate_identity(cert_der, source) do + %{ + source: source, + verified?: true, + cert_sha256: Base.encode64(:crypto.hash(:sha256, cert_der)), + spki_sha256: spki_pin(cert_der), + subject: certificate_subject(cert_der) + } + end + + defp certificate_subject(cert_der) do + cert = :public_key.pkix_decode_cert(cert_der, :otp) + + cert + |> otp_certificate(:tbsCertificate) + |> otp_tbs_certificate(:subject) + |> inspect() + end + + defp verify_pins(_identity, [], _reason), do: :ok + + defp verify_pins(identity, pins, reason) do + if Enum.any?(pins, &pin_matches?(identity, &1)) do + :ok + else + {:error, reason} + end + end + + defp pin_matches?(identity, %{type: :cert_sha256, value: value}) do + identity.cert_sha256 == value + end + + defp pin_matches?(identity, %{type: :spki_sha256, value: value}) do + identity.spki_sha256 == value + end + + defp spki_pin(cert_der) do + cert = :public_key.pkix_decode_cert(cert_der, :plain) + spki = cert |> elem(1) |> elem(7) + + :public_key.der_encode(:SubjectPublicKeyInfo, spki) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode64() + end + + defp header_value(conn, header) when is_binary(header) and header != "" do + conn + |> Plug.Conn.get_req_header(header) + |> List.first() + end + + defp header_value(_conn, _header), do: nil + + defp default_mode(:https), do: :server + defp default_mode(_scheme), do: :disabled + + defp default_port(:https), do: 443 + defp default_port(_scheme), do: 80 + + defp parse_port(value, default) when is_binary(value) do + case Integer.parse(String.trim(value)) do + {port, ""} when port >= 0 -> port + _other -> default + end + end + + defp normalize_mode(:server, _default), do: :server + defp normalize_mode("server", _default), do: :server + defp normalize_mode(:mutual, _default), do: :mutual + defp normalize_mode("mutual", _default), do: :mutual + defp normalize_mode(:proxy_terminated, _default), do: :proxy_terminated + defp normalize_mode("proxy_terminated", _default), do: :proxy_terminated + defp normalize_mode(:disabled, _default), do: :disabled + defp normalize_mode("disabled", _default), do: :disabled + defp normalize_mode(_value, default), do: default + + defp normalize_cipher_suite(:strong), do: :strong + defp normalize_cipher_suite("strong"), do: :strong + defp normalize_cipher_suite(:compatible), do: :compatible + defp normalize_cipher_suite("compatible"), do: :compatible + defp normalize_cipher_suite(_value), do: nil + + defp normalize_proxy_headers(headers) when is_map(headers) do + %{ + enabled: normalize_boolean(fetch_value(headers, :enabled), false), + required: normalize_boolean(fetch_value(headers, :required), false), + verify_header: + normalize_optional_string(fetch_value(headers, :verify_header)) || + @default_proxy_headers.verify_header, + verified_values: + normalize_string_list(fetch_value(headers, :verified_values)) ++ + Enum.filter( + @default_proxy_headers.verified_values, + &(&1 not in normalize_string_list(fetch_value(headers, :verified_values))) + ), + cert_sha256_header: + normalize_optional_string(fetch_value(headers, :cert_sha256_header)) || + @default_proxy_headers.cert_sha256_header, + spki_sha256_header: + normalize_optional_string(fetch_value(headers, :spki_sha256_header)) || + @default_proxy_headers.spki_sha256_header, + subject_header: + normalize_optional_string(fetch_value(headers, :subject_header)) || + @default_proxy_headers.subject_header + } + end + + defp normalize_proxy_headers(_headers), do: @default_proxy_headers + + defp normalize_pins(pins) when is_list(pins) do + Enum.flat_map(pins, fn + %{type: type, value: value} -> + case normalize_pin(type, value) do + nil -> [] + pin -> [pin] + end + + %{"type" => type, "value" => value} -> + case normalize_pin(type, value) do + nil -> [] + pin -> [pin] + end + + _other -> + [] + end) + end + + defp normalize_pins(_pins), do: [] + + defp normalize_pin(type, value) do + with normalized_type when normalized_type in [:cert_sha256, :spki_sha256] <- + normalize_pin_type(type), + normalized_value when is_binary(normalized_value) and normalized_value != "" <- + normalize_optional_string(value) do + %{type: normalized_type, value: normalized_value} + else + _other -> nil + end + end + + defp normalize_pin_type(:cert_sha256), do: :cert_sha256 + defp normalize_pin_type("cert_sha256"), do: :cert_sha256 + defp normalize_pin_type(:spki_sha256), do: :spki_sha256 + defp normalize_pin_type("spki_sha256"), do: :spki_sha256 + defp normalize_pin_type(_type), do: nil + + defp maybe_put_opt(options, _key, nil), do: options + defp maybe_put_opt(options, key, value), do: Keyword.put(options, key, value) + + defp fetch_value(map, key) when is_map(map) do + cond do + Map.has_key?(map, key) -> + Map.get(map, key) + + is_atom(key) and Map.has_key?(map, Atom.to_string(key)) -> + Map.get(map, Atom.to_string(key)) + + true -> + nil + end + end + + defp normalize_optional_string(value) when is_binary(value) and value != "", do: value + defp normalize_optional_string(_value), do: nil + + defp normalize_optional_atom(value) when is_atom(value), do: value + defp normalize_optional_atom(_value), do: nil + + defp normalize_boolean(value, _default) when is_boolean(value), do: value + defp normalize_boolean(nil, default), do: default + defp normalize_boolean(_value, default), do: default + + defp normalize_string_list(values) when is_list(values) do + Enum.filter(values, &(is_binary(&1) and &1 != "")) + end + + defp normalize_string_list(_values), do: [] + + defp system_cacerts do + if function_exported?(:public_key, :cacerts_get, 0) do + :public_key.cacerts_get() + else + [] + end + end + + defp ip_in_cidr?(ip, cidr) do + with {network, prefix_len} <- parse_cidr(cidr), + {:ok, ip_size, ip_value} <- ip_to_int(ip), + {:ok, network_size, network_value} <- ip_to_int(network), + true <- ip_size == network_size, + true <- prefix_len >= 0, + true <- prefix_len <= ip_size do + mask = network_mask(ip_size, prefix_len) + (ip_value &&& mask) == (network_value &&& mask) + else + _other -> false + end + end + + defp parse_cidr(cidr) when is_binary(cidr) do + case String.split(cidr, "/", parts: 2) do + [address, prefix_str] -> + with {prefix_len, ""} <- Integer.parse(prefix_str), + {:ok, ip} <- :inet.parse_address(String.to_charlist(address)) do + {ip, prefix_len} + else + _other -> :error + end + + [address] -> + case :inet.parse_address(String.to_charlist(address)) do + {:ok, {_, _, _, _} = ip} -> {ip, 32} + {:ok, {_, _, _, _, _, _, _, _} = ip} -> {ip, 128} + _other -> :error + end + + _other -> + :error + end + end + + defp parse_cidr(_cidr), do: :error + + defp ip_to_int({a, b, c, d}) do + {:ok, 32, (a <<< 24) + (b <<< 16) + (c <<< 8) + d} + end + + defp ip_to_int({a, b, c, d, e, f, g, h}) do + {:ok, 128, + (a <<< 112) + (b <<< 96) + (c <<< 80) + (d <<< 64) + (e <<< 48) + (f <<< 32) + (g <<< 16) + + h} + end + + defp ip_to_int(_ip), do: :error + + defp network_mask(_size, 0), do: 0 + + defp network_mask(size, prefix_len) do + all_ones = (1 <<< size) - 1 + all_ones <<< (size - prefix_len) + end +end diff --git a/test/parrhesia/web/connection_test.exs b/test/parrhesia/web/connection_test.exs index f02dfef..99265bc 100644 --- a/test/parrhesia/web/connection_test.exs +++ b/test/parrhesia/web/connection_test.exs @@ -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(%{ diff --git a/test/parrhesia/web/router_test.exs b/test/parrhesia/web/router_test.exs index 1a3e320..1564799 100644 --- a/test/parrhesia/web/router_test.exs +++ b/test/parrhesia/web/router_test.exs @@ -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" => %{}})) diff --git a/test/parrhesia/web/tls_e2e_test.exs b/test/parrhesia/web/tls_e2e_test.exs new file mode 100644 index 0000000..8f16941 --- /dev/null +++ b/test/parrhesia/web/tls_e2e_test.exs @@ -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