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

@@ -19,6 +19,13 @@ It exposes:
- operational HTTP endpoints such as `/health`, `/ready`, and `/metrics` on listeners that enable them - 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 - 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 ## Supported NIPs
Current `supported_nips` list: 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.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 | | `: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` #### `:limits`
| Atom key | ENV | Default | | Atom key | ENV | Default |

View File

@@ -103,8 +103,10 @@ Failure model:
Ingress model: Ingress model:
- Ingress is defined through `config :parrhesia, :listeners, ...`. - 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. - 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 ## 5) Core runtime components

View File

@@ -7,9 +7,11 @@ defmodule Parrhesia.API.Admin do
alias Parrhesia.API.Identity alias Parrhesia.API.Identity
alias Parrhesia.API.Sync alias Parrhesia.API.Sync
alias Parrhesia.Storage alias Parrhesia.Storage
alias Parrhesia.Web.Endpoint
@supported_acl_methods ~w(acl_grant acl_revoke acl_list) @supported_acl_methods ~w(acl_grant acl_revoke acl_list)
@supported_identity_methods ~w(identity_ensure identity_get identity_import identity_rotate) @supported_identity_methods ~w(identity_ensure identity_get identity_import identity_rotate)
@supported_listener_methods ~w(listener_reload)
@supported_sync_methods ~w( @supported_sync_methods ~w(
sync_get_server sync_get_server
sync_health sync_health
@@ -96,7 +98,8 @@ defmodule Parrhesia.API.Admin do
end end
(storage_supported ++ (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.uniq()
|> Enum.sort() |> Enum.sort()
end end
@@ -111,6 +114,22 @@ defmodule Parrhesia.API.Admin do
Identity.import(params) Identity.import(params)
end 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_put_server(params, opts), do: Sync.put_server(params, opts)
defp sync_remove_server(params, opts) do 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_ensure", params, _opts), do: identity_ensure(params)
defp execute_builtin("identity_import", params, _opts), do: identity_import(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("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_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_remove_server", params, opts), do: sync_remove_server(params, opts)
defp execute_builtin("sync_get_server", params, opts), do: sync_get_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, nil), do: opts
defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value) 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 defp fetch_required_string(map, key) do
case fetch_value(map, key) do case fetch_value(map, key) do
value when is_binary(value) and value != "" -> {:ok, value} value when is_binary(value) and value != "" -> {:ok, value}

View File

@@ -9,6 +9,7 @@ defmodule Parrhesia.API.RequestContext do
remote_ip: nil, remote_ip: nil,
subscription_id: nil, subscription_id: nil,
peer_id: nil, peer_id: nil,
transport_identity: nil,
metadata: %{} metadata: %{}
@type t :: %__MODULE__{ @type t :: %__MODULE__{
@@ -18,6 +19,7 @@ defmodule Parrhesia.API.RequestContext do
remote_ip: String.t() | nil, remote_ip: String.t() | nil,
subscription_id: String.t() | nil, subscription_id: String.t() | nil,
peer_id: String.t() | nil, peer_id: String.t() | nil,
transport_identity: map() | nil,
metadata: map() metadata: map()
} }

View File

@@ -1,18 +1,6 @@
defmodule Parrhesia.Sync.TLS do defmodule Parrhesia.Sync.TLS do
@moduledoc false @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 :: %{ @type tls_config :: %{
mode: :required | :disabled, mode: :required | :disabled,
hostname: String.t(), hostname: String.t(),
@@ -44,10 +32,19 @@ defmodule Parrhesia.Sync.TLS do
server_name_indication: String.to_charlist(hostname), server_name_indication: String.to_charlist(hostname),
customize_hostname_check: [ customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https) 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 end
defp verify_certificate(_cert, :valid_peer, %{matched?: true} = state), do: {:valid, state} 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, {:bad_cert, reason}, _state), do: {:fail, reason}
defp verify_certificate(cert, _event, state) when is_binary(cert) do 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?}} {:valid, %{state | matched?: state.matched? or matched?}}
rescue rescue
_error -> {:fail, :invalid_certificate} _error -> {:fail, :invalid_certificate}
@@ -65,19 +76,32 @@ defmodule Parrhesia.Sync.TLS do
defp verify_certificate(_cert, _event, state), do: {:valid, state} defp verify_certificate(_cert, _event, state), do: {:valid, state}
defp spki_pin(cert_der) do 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 = :public_key.der_encode(:SubjectPublicKeyInfo, spki)
cert
|> otp_certificate(:tbsCertificate)
|> otp_tbs_certificate(:subjectPublicKeyInfo)
spki
|> :public_key.der_encode(:SubjectPublicKeyInfo)
|> then(&:crypto.hash(:sha256, &1)) |> then(&:crypto.hash(:sha256, &1))
|> Base.encode64() |> Base.encode64()
end 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 defp system_cacerts do
if function_exported?(:public_key, :cacerts_get, 0) do if function_exported?(:public_key, :cacerts_get, 0) do
:public_key.cacerts_get() :public_key.cacerts_get()

View File

@@ -17,7 +17,7 @@ defmodule Parrhesia.Sync.Transport.WebSockexClient do
transport_opts = transport_opts =
server.tls server.tls
|> TLS.websocket_options() |> 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) |> Keyword.put(:handle_initial_conn_failure, true)
WebSockex.start(server.url, __MODULE__, state, transport_opts) 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}) send(state.owner, {:sync_transport, self(), :disconnected, status})
{:ok, state} {:ok, state}
end 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 end

View File

@@ -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

View File

@@ -45,6 +45,7 @@ defmodule Parrhesia.Web.Connection do
defstruct subscriptions: %{}, defstruct subscriptions: %{},
authenticated_pubkeys: MapSet.new(), authenticated_pubkeys: MapSet.new(),
listener: nil, listener: nil,
transport_identity: nil,
max_subscriptions_per_connection: @default_max_subscriptions_per_connection, max_subscriptions_per_connection: @default_max_subscriptions_per_connection,
subscription_index: Index, subscription_index: Index,
auth_challenges: Challenges, auth_challenges: Challenges,
@@ -77,6 +78,7 @@ defmodule Parrhesia.Web.Connection do
subscriptions: %{String.t() => subscription()}, subscriptions: %{String.t() => subscription()},
authenticated_pubkeys: MapSet.t(String.t()), authenticated_pubkeys: MapSet.t(String.t()),
listener: map() | nil, listener: map() | nil,
transport_identity: map() | nil,
max_subscriptions_per_connection: pos_integer(), max_subscriptions_per_connection: pos_integer(),
subscription_index: GenServer.server() | nil, subscription_index: GenServer.server() | nil,
auth_challenges: GenServer.server() | nil, auth_challenges: GenServer.server() | nil,
@@ -105,6 +107,7 @@ defmodule Parrhesia.Web.Connection do
state = %__MODULE__{ state = %__MODULE__{
listener: Listener.from_opts(opts), listener: Listener.from_opts(opts),
transport_identity: transport_identity(opts),
max_subscriptions_per_connection: max_subscriptions_per_connection(opts), max_subscriptions_per_connection: max_subscriptions_per_connection(opts),
subscription_index: subscription_index(opts), subscription_index: subscription_index(opts),
auth_challenges: auth_challenges, auth_challenges: auth_challenges,
@@ -1475,10 +1478,18 @@ defmodule Parrhesia.Web.Connection do
caller: :websocket, caller: :websocket,
remote_ip: state.remote_ip, remote_ip: state.remote_ip,
subscription_id: subscription_id, 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 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 defp authorize_authenticated_pubkey(%{"pubkey" => pubkey}) when is_binary(pubkey) do
ConnectionPolicy.authorize_authenticated_pubkey(pubkey) ConnectionPolicy.authorize_authenticated_pubkey(pubkey)
end end

View File

@@ -7,14 +7,43 @@ defmodule Parrhesia.Web.Endpoint do
alias Parrhesia.Web.Listener alias Parrhesia.Web.Listener
def start_link(_init_arg \\ []) do def start_link(opts \\ []) do
Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 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 end
@impl true @impl true
def init(:ok) do def init(listeners) do
children = children =
Listener.all() listeners(listeners)
|> Enum.map(fn listener -> |> Enum.map(fn listener ->
%{ %{
id: {:listener, listener.id}, id: {:listener, listener.id},
@@ -24,4 +53,22 @@ defmodule Parrhesia.Web.Endpoint do
Supervisor.init(children, strategy: :one_for_one) Supervisor.init(children, strategy: :one_for_one)
end 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 end

View File

@@ -4,6 +4,7 @@ defmodule Parrhesia.Web.Listener do
import Bitwise import Bitwise
alias Parrhesia.Protocol.Filter alias Parrhesia.Protocol.Filter
alias Parrhesia.Web.TLS
@private_cidrs [ @private_cidrs [
"127.0.0.0/8", "127.0.0.0/8",
@@ -81,11 +82,38 @@ defmodule Parrhesia.Web.Listener do
listener.proxy.trusted_cidrs listener.proxy.trusted_cidrs
end 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() @spec remote_ip_allowed?(t(), tuple() | String.t() | nil) :: boolean()
def remote_ip_allowed?(listener, remote_ip) do def remote_ip_allowed?(listener, remote_ip) do
access_allowed?(listener.network, remote_ip) access_allowed?(listener.network, remote_ip)
end 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() @spec metrics_allowed?(t(), Plug.Conn.t()) :: boolean()
def metrics_allowed?(listener, conn) do def metrics_allowed?(listener, conn) do
metrics = Map.get(listener.features, :metrics, %{}) metrics = Map.get(listener.features, :metrics, %{})
@@ -97,17 +125,19 @@ defmodule Parrhesia.Web.Listener do
@spec relay_url(t(), Plug.Conn.t()) :: String.t() @spec relay_url(t(), Plug.Conn.t()) :: String.t()
def relay_url(listener, conn) do 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" ws_scheme = if scheme == :https, do: "wss", else: "ws"
port_segment = port_segment =
if default_http_port?(scheme, conn.port) do if default_http_port?(scheme, port) do
"" ""
else else
":#{conn.port}" ":#{port}"
end end
"#{ws_scheme}://#{conn.host}#{port_segment}#{conn.request_path}" "#{ws_scheme}://#{host}#{port_segment}#{conn.request_path}"
end end
@spec relay_auth_required?(t()) :: boolean() @spec relay_auth_required?(t()) :: boolean()
@@ -131,12 +161,18 @@ defmodule Parrhesia.Web.Listener do
@spec bandit_options(t()) :: keyword() @spec bandit_options(t()) :: keyword()
def bandit_options(listener) do 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, ip: listener.bind.ip,
port: listener.bind.port, port: listener.bind.port,
scheme: listener.transport.scheme, scheme: scheme,
plug: {Parrhesia.Web.ListenerPlug, listener: listener} plug: {Parrhesia.Web.ListenerPlug, listener: listener}
] ++ listener.bandit_options ] ++ TLS.bandit_options(listener.transport.tls) ++ listener.bandit_options
end end
defp normalize_listeners(listeners) when is_list(listeners) do defp normalize_listeners(listeners) when is_list(listeners) do
@@ -202,13 +238,15 @@ defmodule Parrhesia.Web.Listener do
end end
defp normalize_transport(transport) when is_map(transport) do 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), scheme: scheme,
tls: normalize_map(fetch_value(transport, :tls)) tls: TLS.normalize_config(fetch_value(transport, :tls), scheme)
} }
end 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 defp normalize_proxy(proxy) when is_map(proxy) do
%{ %{
@@ -478,7 +516,7 @@ defmodule Parrhesia.Web.Listener do
id: :public, id: :public,
enabled: true, enabled: true,
bind: %{ip: {0, 0, 0, 0}, port: 4413}, 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}, proxy: %{trusted_cidrs: [], honor_x_forwarded_for: true},
network: %{public?: false, private_networks_only?: false, allow_cidrs: [], allow_all?: true}, network: %{public?: false, private_networks_only?: false, allow_cidrs: [], allow_all?: true},
features: %{ features: %{
@@ -524,9 +562,6 @@ defmodule Parrhesia.Web.Listener do
end end
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(value, _default) when is_boolean(value), do: value
defp normalize_boolean(nil, default), do: default defp normalize_boolean(nil, default), do: default
defp normalize_boolean(_value, default), do: default defp normalize_boolean(_value, default), do: default

View File

@@ -7,6 +7,7 @@ defmodule Parrhesia.Web.Management do
alias Parrhesia.API.Admin alias Parrhesia.API.Admin
alias Parrhesia.API.Auth alias Parrhesia.API.Auth
alias Parrhesia.Web.Listener
@spec handle(Plug.Conn.t(), keyword()) :: Plug.Conn.t() @spec handle(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def handle(conn, opts \\ []) do def handle(conn, opts \\ []) do
@@ -94,14 +95,15 @@ defmodule Parrhesia.Web.Management do
end end
defp full_request_url(conn) do defp full_request_url(conn) do
scheme = Atom.to_string(conn.scheme) listener = Listener.from_conn(conn)
host = conn.host scheme = Listener.request_scheme(listener, conn)
port = conn.port host = Listener.request_host(listener, conn)
port = Listener.request_port(listener, conn)
port_suffix = port_suffix =
cond do cond do
conn.scheme == :http and port == 80 -> "" scheme == :http and port == 80 -> ""
conn.scheme == :https and port == 443 -> "" scheme == :https and port == 443 -> ""
true -> ":#{port}" true -> ":#{port}"
end end

View File

@@ -10,7 +10,7 @@ defmodule Parrhesia.Web.RemoteIp do
@spec call(Plug.Conn.t(), term()) :: Plug.Conn.t() @spec call(Plug.Conn.t(), term()) :: Plug.Conn.t()
def call(conn, _opts) do 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 case forwarded_ip(conn) do
nil -> conn nil -> conn
forwarded_ip -> %{conn | remote_ip: forwarded_ip} forwarded_ip -> %{conn | remote_ip: forwarded_ip}
@@ -70,6 +70,11 @@ defmodule Parrhesia.Web.RemoteIp do
end end
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 defp parse_x_forwarded_for(value) when is_binary(value) do
value value
|> String.split(",") |> String.split(",")

View File

@@ -39,7 +39,7 @@ defmodule Parrhesia.Web.Router do
if Listener.feature_enabled?(listener, :metrics) do if Listener.feature_enabled?(listener, :metrics) do
case authorize_listener_request(conn, listener) do case authorize_listener_request(conn, listener) do
:ok -> Metrics.handle(conn) {:ok, conn} -> Metrics.handle(conn)
{:error, :forbidden} -> send_resp(conn, 403, "forbidden") {:error, :forbidden} -> send_resp(conn, 403, "forbidden")
end end
else else
@@ -52,7 +52,7 @@ defmodule Parrhesia.Web.Router do
if Listener.feature_enabled?(listener, :admin) do if Listener.feature_enabled?(listener, :admin) do
case authorize_listener_request(conn, listener) 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") {:error, :forbidden} -> send_resp(conn, 403, "forbidden")
end end
else else
@@ -65,7 +65,7 @@ defmodule Parrhesia.Web.Router do
if Listener.feature_enabled?(listener, :nostr) do if Listener.feature_enabled?(listener, :nostr) do
case authorize_listener_request(conn, listener) do case authorize_listener_request(conn, listener) do
:ok -> {:ok, conn} ->
if accepts_nip11?(conn) do if accepts_nip11?(conn) do
body = JSON.encode!(RelayInfo.document(listener)) body = JSON.encode!(RelayInfo.document(listener))
@@ -79,7 +79,8 @@ defmodule Parrhesia.Web.Router do
%{ %{
listener: listener, listener: listener,
relay_url: Listener.relay_url(listener, conn), 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, timeout: 60_000,
max_frame_size: max_frame_bytes() max_frame_size: max_frame_bytes()
@@ -118,10 +119,12 @@ defmodule Parrhesia.Web.Router do
defp authorize_listener_request(conn, listener) do defp authorize_listener_request(conn, listener) do
with :ok <- authorize_remote_ip(conn), with :ok <- authorize_remote_ip(conn),
true <- Listener.remote_ip_allowed?(listener, conn.remote_ip) do true <- Listener.remote_ip_allowed?(listener, conn.remote_ip),
:ok {:ok, transport_identity} <- Listener.authorize_transport_request(listener, conn) do
{:ok, maybe_put_transport_identity(conn, transport_identity)}
else else
{:error, :ip_blocked} -> {:error, :forbidden} {:error, :ip_blocked} -> {:error, :forbidden}
{:error, _reason} -> {:error, :forbidden}
false -> {:error, :forbidden} false -> {:error, :forbidden}
end end
end end
@@ -137,4 +140,14 @@ defmodule Parrhesia.Web.Router do
_other -> nil _other -> nil
end end
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 end

514
lib/parrhesia/web/tls.ex Normal file
View File

@@ -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

View File

@@ -123,6 +123,18 @@ defmodule Parrhesia.Web.ConnectionTest do
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end) Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
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 test "listener can require NIP-42 for reads and writes" do
listener = listener =
listener(%{ listener(%{

View File

@@ -107,6 +107,91 @@ defmodule Parrhesia.Web.RouterTest do
assert allowed_conn.status == 200 assert allowed_conn.status == 200
end 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 test "POST /management requires authorization" do
conn = conn =
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}})) 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