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