Add shared auth and identity APIs

This commit is contained in:
2026-03-16 21:07:26 +01:00
parent 987415d80c
commit 769177a63e
16 changed files with 590 additions and 27 deletions

View File

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

View 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

View 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

View 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

View File

@@ -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", [])

View File

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

View File

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