Add shared auth and identity APIs
This commit is contained in:
@@ -4,20 +4,21 @@ defmodule Parrhesia.API.Admin do
|
||||
"""
|
||||
|
||||
alias Parrhesia.API.ACL
|
||||
alias Parrhesia.API.Identity
|
||||
alias Parrhesia.Storage
|
||||
|
||||
@supported_acl_methods ~w(acl_grant acl_revoke acl_list)
|
||||
@supported_identity_methods ~w(identity_ensure identity_get identity_import identity_rotate)
|
||||
|
||||
@spec execute(String.t() | atom(), map(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def execute(method, params, opts \\ [])
|
||||
|
||||
def execute(method, params, _opts) when is_map(params) do
|
||||
case normalize_method_name(method) do
|
||||
"acl_grant" -> acl_grant(params)
|
||||
"acl_revoke" -> acl_revoke(params)
|
||||
"acl_list" -> acl_list(params)
|
||||
"supportedmethods" -> {:ok, %{"methods" => supported_methods()}}
|
||||
other_method -> Storage.admin().execute(%{}, other_method, params)
|
||||
method_name = normalize_method_name(method)
|
||||
|
||||
case execute_builtin(method_name, params) do
|
||||
{:continue, other_method} -> Storage.admin().execute(%{}, other_method, params)
|
||||
result -> result
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,11 +69,34 @@ defmodule Parrhesia.API.Admin do
|
||||
_other -> []
|
||||
end
|
||||
|
||||
(storage_supported ++ @supported_acl_methods)
|
||||
(storage_supported ++ @supported_acl_methods ++ @supported_identity_methods)
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp identity_get(_params), do: Identity.get()
|
||||
|
||||
defp identity_ensure(_params), do: Identity.ensure()
|
||||
|
||||
defp identity_rotate(_params), do: Identity.rotate()
|
||||
|
||||
defp identity_import(params) do
|
||||
Identity.import(params)
|
||||
end
|
||||
|
||||
defp execute_builtin("acl_grant", params), do: acl_grant(params)
|
||||
defp execute_builtin("acl_revoke", params), do: acl_revoke(params)
|
||||
defp execute_builtin("acl_list", params), do: acl_list(params)
|
||||
defp execute_builtin("identity_get", params), do: identity_get(params)
|
||||
defp execute_builtin("identity_ensure", params), do: identity_ensure(params)
|
||||
defp execute_builtin("identity_import", params), do: identity_import(params)
|
||||
defp execute_builtin("identity_rotate", params), do: identity_rotate(params)
|
||||
|
||||
defp execute_builtin("supportedmethods", _params),
|
||||
do: {:ok, %{"methods" => supported_methods()}}
|
||||
|
||||
defp execute_builtin(other_method, _params), do: {:continue, other_method}
|
||||
|
||||
defp maybe_put_opt(opts, _key, nil), do: opts
|
||||
defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
|
||||
|
||||
48
lib/parrhesia/api/auth.ex
Normal file
48
lib/parrhesia/api/auth.ex
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Parrhesia.API.Auth do
|
||||
@moduledoc """
|
||||
Shared auth and event validation helpers.
|
||||
"""
|
||||
|
||||
alias Parrhesia.API.Auth.Context
|
||||
alias Parrhesia.API.RequestContext
|
||||
alias Parrhesia.Auth.Nip98
|
||||
alias Parrhesia.Protocol.EventValidator
|
||||
|
||||
@spec validate_event(map()) :: :ok | {:error, term()}
|
||||
def validate_event(event), do: EventValidator.validate(event)
|
||||
|
||||
@spec compute_event_id(map()) :: String.t()
|
||||
def compute_event_id(event), do: EventValidator.compute_id(event)
|
||||
|
||||
@spec validate_nip98(String.t() | nil, String.t(), String.t()) ::
|
||||
{:ok, Context.t()} | {:error, term()}
|
||||
def validate_nip98(authorization, method, url) do
|
||||
validate_nip98(authorization, method, url, [])
|
||||
end
|
||||
|
||||
@spec validate_nip98(String.t() | nil, String.t(), String.t(), keyword()) ::
|
||||
{:ok, Context.t()} | {:error, term()}
|
||||
def validate_nip98(authorization, method, url, opts)
|
||||
when is_binary(method) and is_binary(url) and is_list(opts) do
|
||||
with {:ok, auth_event} <-
|
||||
Nip98.validate_authorization_header(authorization, method, url, opts),
|
||||
pubkey when is_binary(pubkey) <- Map.get(auth_event, "pubkey") do
|
||||
{:ok,
|
||||
%Context{
|
||||
auth_event: auth_event,
|
||||
pubkey: pubkey,
|
||||
request_context: %RequestContext{
|
||||
authenticated_pubkeys: MapSet.new([pubkey]),
|
||||
caller: :http
|
||||
},
|
||||
metadata: %{
|
||||
method: method,
|
||||
url: url
|
||||
}
|
||||
}}
|
||||
else
|
||||
nil -> {:error, :invalid_event}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
19
lib/parrhesia/api/auth/context.ex
Normal file
19
lib/parrhesia/api/auth/context.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Parrhesia.API.Auth.Context do
|
||||
@moduledoc """
|
||||
Authenticated request details returned by shared auth helpers.
|
||||
"""
|
||||
|
||||
alias Parrhesia.API.RequestContext
|
||||
|
||||
defstruct auth_event: nil,
|
||||
pubkey: nil,
|
||||
request_context: %RequestContext{},
|
||||
metadata: %{}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
auth_event: map() | nil,
|
||||
pubkey: String.t() | nil,
|
||||
request_context: RequestContext.t(),
|
||||
metadata: map()
|
||||
}
|
||||
end
|
||||
243
lib/parrhesia/api/identity.ex
Normal file
243
lib/parrhesia/api/identity.ex
Normal file
@@ -0,0 +1,243 @@
|
||||
defmodule Parrhesia.API.Identity do
|
||||
@moduledoc """
|
||||
Server-auth identity management.
|
||||
"""
|
||||
|
||||
alias Parrhesia.API.Auth
|
||||
|
||||
@type identity_metadata :: %{
|
||||
pubkey: String.t(),
|
||||
source: :configured | :persisted | :generated | :imported
|
||||
}
|
||||
|
||||
@spec get(keyword()) :: {:ok, identity_metadata()} | {:error, term()}
|
||||
def get(opts \\ []) do
|
||||
with {:ok, identity} <- fetch_existing_identity(opts) do
|
||||
{:ok, public_identity(identity)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec ensure(keyword()) :: {:ok, identity_metadata()} | {:error, term()}
|
||||
def ensure(opts \\ []) do
|
||||
with {:ok, identity} <- ensure_identity(opts) do
|
||||
{:ok, public_identity(identity)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec import(map(), keyword()) :: {:ok, identity_metadata()} | {:error, term()}
|
||||
def import(identity, opts \\ [])
|
||||
|
||||
def import(identity, opts) when is_map(identity) do
|
||||
with {:ok, secret_key} <- fetch_secret_key(identity),
|
||||
{:ok, normalized_identity} <- build_identity(secret_key, :imported),
|
||||
:ok <- persist_identity(normalized_identity, opts) do
|
||||
{:ok, public_identity(normalized_identity)}
|
||||
end
|
||||
end
|
||||
|
||||
def import(_identity, _opts), do: {:error, :invalid_identity}
|
||||
|
||||
@spec rotate(keyword()) :: {:ok, identity_metadata()} | {:error, term()}
|
||||
def rotate(opts \\ []) do
|
||||
with :ok <- ensure_rotation_allowed(opts),
|
||||
{:ok, identity} <- generate_identity(:generated),
|
||||
:ok <- persist_identity(identity, opts) do
|
||||
{:ok, public_identity(identity)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec sign_event(map(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||
def sign_event(event, opts \\ [])
|
||||
|
||||
def sign_event(event, opts) when is_map(event) and is_list(opts) do
|
||||
with :ok <- validate_signable_event(event),
|
||||
{:ok, identity} <- ensure_identity(opts),
|
||||
signed_event <- attach_signature(event, identity) do
|
||||
{:ok, signed_event}
|
||||
end
|
||||
end
|
||||
|
||||
def sign_event(_event, _opts), do: {:error, :invalid_event}
|
||||
|
||||
def default_path do
|
||||
Path.join([default_data_dir(), "server_identity.json"])
|
||||
end
|
||||
|
||||
defp ensure_identity(opts) do
|
||||
case fetch_existing_identity(opts) do
|
||||
{:ok, identity} ->
|
||||
{:ok, identity}
|
||||
|
||||
{:error, :identity_not_found} ->
|
||||
with {:ok, identity} <- generate_identity(:generated),
|
||||
:ok <- persist_identity(identity, opts) do
|
||||
{:ok, identity}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_existing_identity(opts) do
|
||||
if configured_private_key = configured_private_key(opts) do
|
||||
build_identity(configured_private_key, :configured)
|
||||
else
|
||||
read_persisted_identity(opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_rotation_allowed(opts) do
|
||||
if configured_private_key(opts) do
|
||||
{:error, :configured_identity_cannot_rotate}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_signable_event(event) do
|
||||
signable =
|
||||
is_integer(Map.get(event, "created_at")) and
|
||||
is_integer(Map.get(event, "kind")) and
|
||||
is_list(Map.get(event, "tags")) and
|
||||
is_binary(Map.get(event, "content", ""))
|
||||
|
||||
if signable, do: :ok, else: {:error, :invalid_event}
|
||||
end
|
||||
|
||||
defp attach_signature(event, identity) do
|
||||
unsigned_event =
|
||||
event
|
||||
|> Map.put("pubkey", identity.pubkey)
|
||||
|> Map.put("sig", String.duplicate("0", 128))
|
||||
|
||||
event_id =
|
||||
unsigned_event
|
||||
|> Auth.compute_event_id()
|
||||
|
||||
signature =
|
||||
event_id
|
||||
|> Base.decode16!(case: :lower)
|
||||
|> Secp256k1.schnorr_sign(identity.secret_key)
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
unsigned_event
|
||||
|> Map.put("id", event_id)
|
||||
|> Map.put("sig", signature)
|
||||
end
|
||||
|
||||
defp read_persisted_identity(opts) do
|
||||
path = identity_path(opts)
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, payload} ->
|
||||
with {:ok, decoded} <- JSON.decode(payload),
|
||||
{:ok, secret_key} <- fetch_secret_key(decoded),
|
||||
{:ok, identity} <- build_identity(secret_key, :persisted) do
|
||||
{:ok, identity}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
|
||||
{:error, :enoent} ->
|
||||
{:error, :identity_not_found}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp persist_identity(identity, opts) do
|
||||
path = identity_path(opts)
|
||||
temp_path = path <> ".tmp"
|
||||
|
||||
with :ok <- File.mkdir_p(Path.dirname(path)),
|
||||
:ok <- File.write(temp_path, JSON.encode!(persisted_identity(identity))),
|
||||
:ok <- File.rename(temp_path, path) do
|
||||
:ok
|
||||
else
|
||||
{:error, reason} ->
|
||||
_ = File.rm(temp_path)
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp persisted_identity(identity) do
|
||||
%{
|
||||
"secret_key" => Base.encode16(identity.secret_key, case: :lower),
|
||||
"pubkey" => identity.pubkey
|
||||
}
|
||||
end
|
||||
|
||||
defp generate_identity(source) do
|
||||
{secret_key, pubkey} = Secp256k1.keypair(:xonly)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
secret_key: secret_key,
|
||||
pubkey: Base.encode16(pubkey, case: :lower),
|
||||
source: source
|
||||
}}
|
||||
rescue
|
||||
_error -> {:error, :identity_generation_failed}
|
||||
end
|
||||
|
||||
defp build_identity(secret_key_hex, source) when is_binary(secret_key_hex) do
|
||||
with {:ok, secret_key} <- decode_secret_key(secret_key_hex),
|
||||
pubkey <- Secp256k1.pubkey(secret_key, :xonly) do
|
||||
{:ok,
|
||||
%{
|
||||
secret_key: secret_key,
|
||||
pubkey: Base.encode16(pubkey, case: :lower),
|
||||
source: source
|
||||
}}
|
||||
end
|
||||
rescue
|
||||
_error -> {:error, :invalid_secret_key}
|
||||
end
|
||||
|
||||
defp decode_secret_key(secret_key_hex) when is_binary(secret_key_hex) do
|
||||
normalized = String.downcase(secret_key_hex)
|
||||
|
||||
case Base.decode16(normalized, case: :lower) do
|
||||
{:ok, <<_::256>> = secret_key} -> {:ok, secret_key}
|
||||
_other -> {:error, :invalid_secret_key}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_secret_key(identity) when is_map(identity) do
|
||||
case Map.get(identity, :secret_key) || Map.get(identity, "secret_key") do
|
||||
secret_key when is_binary(secret_key) -> {:ok, secret_key}
|
||||
_other -> {:error, :invalid_identity}
|
||||
end
|
||||
end
|
||||
|
||||
defp configured_private_key(opts) do
|
||||
opts[:private_key] || opts[:configured_private_key] || config_value(:private_key)
|
||||
end
|
||||
|
||||
defp identity_path(opts) do
|
||||
opts[:path] || config_value(:path) || default_path()
|
||||
end
|
||||
|
||||
defp public_identity(identity) do
|
||||
%{
|
||||
pubkey: identity.pubkey,
|
||||
source: identity.source
|
||||
}
|
||||
end
|
||||
|
||||
defp config_value(key) do
|
||||
:parrhesia
|
||||
|> Application.get_env(:identity, [])
|
||||
|> Keyword.get(key)
|
||||
end
|
||||
|
||||
defp default_data_dir do
|
||||
base_dir =
|
||||
System.get_env("XDG_DATA_HOME") ||
|
||||
Path.join(System.user_home!(), ".local/share")
|
||||
|
||||
Path.join(base_dir, "parrhesia")
|
||||
end
|
||||
end
|
||||
25
lib/parrhesia/api/identity/manager.ex
Normal file
25
lib/parrhesia/api/identity/manager.ex
Normal file
@@ -0,0 +1,25 @@
|
||||
defmodule Parrhesia.API.Identity.Manager do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
|
||||
alias Parrhesia.API.Identity
|
||||
|
||||
require Logger
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
case Identity.ensure() do
|
||||
{:ok, _identity} ->
|
||||
{:ok, %{}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("failed to ensure server identity: #{inspect(reason)}")
|
||||
{:ok, %{}}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,13 +9,20 @@ defmodule Parrhesia.Auth.Nip98 do
|
||||
|
||||
@spec validate_authorization_header(String.t() | nil, String.t(), String.t()) ::
|
||||
{:ok, map()} | {:error, atom()}
|
||||
def validate_authorization_header(nil, _method, _url), do: {:error, :missing_authorization}
|
||||
def validate_authorization_header(authorization, method, url) do
|
||||
validate_authorization_header(authorization, method, url, [])
|
||||
end
|
||||
|
||||
def validate_authorization_header("Nostr " <> encoded_event, method, url)
|
||||
when is_binary(method) and is_binary(url) do
|
||||
@spec validate_authorization_header(String.t() | nil, String.t(), String.t(), keyword()) ::
|
||||
{:ok, map()} | {:error, atom()}
|
||||
def validate_authorization_header(nil, _method, _url, _opts),
|
||||
do: {:error, :missing_authorization}
|
||||
|
||||
def validate_authorization_header("Nostr " <> encoded_event, method, url, opts)
|
||||
when is_binary(method) and is_binary(url) and is_list(opts) do
|
||||
with {:ok, event_json} <- decode_base64(encoded_event),
|
||||
{:ok, event} <- JSON.decode(event_json),
|
||||
:ok <- validate_event_shape(event),
|
||||
:ok <- validate_event_shape(event, opts),
|
||||
:ok <- validate_http_binding(event, method, url) do
|
||||
{:ok, event}
|
||||
else
|
||||
@@ -24,7 +31,8 @@ defmodule Parrhesia.Auth.Nip98 do
|
||||
end
|
||||
end
|
||||
|
||||
def validate_authorization_header(_header, _method, _url), do: {:error, :invalid_authorization}
|
||||
def validate_authorization_header(_header, _method, _url, _opts),
|
||||
do: {:error, :invalid_authorization}
|
||||
|
||||
defp decode_base64(encoded_event) do
|
||||
case Base.decode64(encoded_event) do
|
||||
@@ -33,33 +41,35 @@ defmodule Parrhesia.Auth.Nip98 do
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_event_shape(event) when is_map(event) do
|
||||
defp validate_event_shape(event, opts) when is_map(event) do
|
||||
with :ok <- EventValidator.validate(event),
|
||||
:ok <- validate_kind(event),
|
||||
:ok <- validate_fresh_created_at(event) do
|
||||
:ok <- validate_fresh_created_at(event, opts) do
|
||||
:ok
|
||||
else
|
||||
:ok -> :ok
|
||||
{:error, :stale_event} -> {:error, :stale_event}
|
||||
{:error, _reason} -> {:error, :invalid_event}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_event_shape(_event), do: {:error, :invalid_event}
|
||||
defp validate_event_shape(_event, _opts), do: {:error, :invalid_event}
|
||||
|
||||
defp validate_kind(%{"kind" => 27_235}), do: :ok
|
||||
defp validate_kind(_event), do: {:error, :invalid_event}
|
||||
|
||||
defp validate_fresh_created_at(%{"created_at" => created_at}) when is_integer(created_at) do
|
||||
defp validate_fresh_created_at(%{"created_at" => created_at}, opts)
|
||||
when is_integer(created_at) do
|
||||
now = System.system_time(:second)
|
||||
max_age_seconds = Keyword.get(opts, :max_age_seconds, @max_age_seconds)
|
||||
|
||||
if abs(now - created_at) <= @max_age_seconds do
|
||||
if abs(now - created_at) <= max_age_seconds do
|
||||
:ok
|
||||
else
|
||||
{:error, :stale_event}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_fresh_created_at(_event), do: {:error, :invalid_event}
|
||||
defp validate_fresh_created_at(_event, _opts), do: {:error, :invalid_event}
|
||||
|
||||
defp validate_http_binding(event, method, url) do
|
||||
tags = Map.get(event, "tags", [])
|
||||
|
||||
@@ -12,7 +12,8 @@ defmodule Parrhesia.Auth.Supervisor do
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
{Parrhesia.Auth.Challenges, name: Parrhesia.Auth.Challenges}
|
||||
{Parrhesia.Auth.Challenges, name: Parrhesia.Auth.Challenges},
|
||||
{Parrhesia.API.Identity.Manager, []}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
|
||||
@@ -6,7 +6,7 @@ defmodule Parrhesia.Web.Management do
|
||||
import Plug.Conn
|
||||
|
||||
alias Parrhesia.API.Admin
|
||||
alias Parrhesia.Auth.Nip98
|
||||
alias Parrhesia.API.Auth
|
||||
|
||||
@spec handle(Plug.Conn.t()) :: Plug.Conn.t()
|
||||
def handle(conn) do
|
||||
@@ -14,10 +14,10 @@ defmodule Parrhesia.Web.Management do
|
||||
method = conn.method
|
||||
authorization = get_req_header(conn, "authorization") |> List.first()
|
||||
|
||||
with {:ok, auth_event} <- Nip98.validate_authorization_header(authorization, method, full_url),
|
||||
with {:ok, auth_context} <- Auth.validate_nip98(authorization, method, full_url),
|
||||
{:ok, payload} <- parse_payload(conn.body_params),
|
||||
{:ok, result} <- execute_method(payload),
|
||||
:ok <- append_audit_log(auth_event, payload, result) do
|
||||
:ok <- append_audit_log(auth_context, payload, result) do
|
||||
send_json(conn, 200, %{"ok" => true, "result" => result})
|
||||
else
|
||||
{:error, :missing_authorization} ->
|
||||
@@ -62,10 +62,10 @@ defmodule Parrhesia.Web.Management do
|
||||
Admin.execute(payload.method, payload.params)
|
||||
end
|
||||
|
||||
defp append_audit_log(auth_event, payload, result) do
|
||||
defp append_audit_log(auth_context, payload, result) do
|
||||
Parrhesia.Storage.admin().append_audit_log(%{}, %{
|
||||
method: payload.method,
|
||||
actor_pubkey: Map.get(auth_event, "pubkey"),
|
||||
actor_pubkey: auth_context.pubkey,
|
||||
params: payload.params,
|
||||
result: normalize_result(result)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user