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

@@ -5,6 +5,10 @@ config :postgrex, :json_library, JSON
config :parrhesia,
moderation_cache_enabled: true,
relay_url: "ws://localhost:4413/relay",
identity: [
path: nil,
private_key: nil
],
limits: [
max_frame_bytes: 1_048_576,
max_event_bytes: 262_144,

View File

@@ -451,6 +451,10 @@ if config_env() == :prod do
config :parrhesia,
relay_url: string_env.("PARRHESIA_RELAY_URL", relay_url_default),
identity: [
path: string_env.("PARRHESIA_IDENTITY_PATH", nil),
private_key: string_env.("PARRHESIA_IDENTITY_PRIVATE_KEY", nil)
],
moderation_cache_enabled:
bool_env.("PARRHESIA_MODERATION_CACHE_ENABLED", moderation_cache_enabled_default),
enable_expiration_worker:

View File

@@ -15,6 +15,10 @@ config :parrhesia, Parrhesia.Web.Endpoint,
config :parrhesia,
enable_expiration_worker: false,
moderation_cache_enabled: false,
identity: [
path: Path.join(System.tmp_dir!(), "parrhesia_test_identity.json"),
private_key: nil
],
features: [verify_event_signatures: false]
pg_host = System.get_env("PGHOST")

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

View File

@@ -0,0 +1,62 @@
defmodule Parrhesia.API.AuthTest do
use ExUnit.Case, async: true
alias Parrhesia.API.Auth
alias Parrhesia.Protocol.EventValidator
test "validate_event delegates to event validation" do
assert {:error, :invalid_shape} = Auth.validate_event(%{})
end
test "compute_event_id matches the protocol event validator" do
event = %{
"pubkey" => String.duplicate("a", 64),
"created_at" => System.system_time(:second),
"kind" => 1,
"tags" => [],
"content" => "hello",
"sig" => String.duplicate("b", 128)
}
assert Auth.compute_event_id(event) == EventValidator.compute_id(event)
end
test "validate_nip98 returns shared auth context" do
url = "http://example.com/management"
event = nip98_event("POST", url)
header = "Nostr " <> Base.encode64(JSON.encode!(event))
assert {:ok, auth_context} = Auth.validate_nip98(header, "POST", url)
assert auth_context.pubkey == event["pubkey"]
assert auth_context.auth_event["id"] == event["id"]
assert auth_context.request_context.caller == :http
assert MapSet.member?(auth_context.request_context.authenticated_pubkeys, event["pubkey"])
assert auth_context.metadata == %{method: "POST", url: url}
end
test "validate_nip98 accepts custom freshness window" do
url = "http://example.com/management"
event = nip98_event("POST", url, %{"created_at" => System.system_time(:second) - 120})
header = "Nostr " <> Base.encode64(JSON.encode!(event))
assert {:error, :stale_event} = Auth.validate_nip98(header, "POST", url)
assert {:ok, _context} = Auth.validate_nip98(header, "POST", url, max_age_seconds: 180)
end
defp nip98_event(method, url, overrides \\ %{}) do
now = System.system_time(:second)
base = %{
"pubkey" => String.duplicate("a", 64),
"created_at" => now,
"kind" => 27_235,
"tags" => [["method", method], ["u", url]],
"content" => "",
"sig" => String.duplicate("b", 128)
}
base
|> Map.merge(overrides)
|> Map.put("id", EventValidator.compute_id(Map.merge(base, overrides)))
end
end

View File

@@ -0,0 +1,75 @@
defmodule Parrhesia.API.IdentityTest do
use ExUnit.Case, async: false
alias Parrhesia.API.Auth
alias Parrhesia.API.Identity
test "ensure generates and persists a server identity" do
path = unique_identity_path()
assert {:error, :identity_not_found} = Identity.get(path: path)
assert {:ok, %{pubkey: pubkey, source: :generated}} = Identity.ensure(path: path)
assert File.exists?(path)
assert {:ok, %{pubkey: ^pubkey, source: :persisted}} = Identity.get(path: path)
assert {:ok, %{pubkey: ^pubkey, source: :persisted}} = Identity.ensure(path: path)
end
test "import persists an explicit secret key and sign_event uses it" do
path = unique_identity_path()
secret_key = String.duplicate("1", 64)
expected_pubkey =
secret_key
|> Base.decode16!(case: :lower)
|> Secp256k1.pubkey(:xonly)
|> Base.encode16(case: :lower)
assert {:ok, %{pubkey: ^expected_pubkey, source: :imported}} =
Identity.import(%{secret_key: secret_key}, path: path)
assert {:ok, %{pubkey: ^expected_pubkey, source: :persisted}} = Identity.get(path: path)
event = %{
"created_at" => System.system_time(:second),
"kind" => 22_242,
"tags" => [],
"content" => "identity-auth"
}
assert {:ok, signed_event} = Identity.sign_event(event, path: path)
assert signed_event["pubkey"] == expected_pubkey
assert signed_event["id"] == Auth.compute_event_id(signed_event)
signature = Base.decode16!(signed_event["sig"], case: :lower)
event_id = Base.decode16!(signed_event["id"], case: :lower)
pubkey = Base.decode16!(signed_event["pubkey"], case: :lower)
assert Secp256k1.schnorr_valid?(signature, event_id, pubkey)
end
test "rotate rejects configured identities and sign_event validates shape" do
path = unique_identity_path()
secret_key = String.duplicate("2", 64)
assert {:error, :configured_identity_cannot_rotate} =
Identity.rotate(path: path, configured_private_key: secret_key)
assert {:error, :invalid_event} = Identity.sign_event(%{"kind" => 1}, path: path)
end
defp unique_identity_path do
path =
Path.join(
System.tmp_dir!(),
"parrhesia_identity_#{System.unique_integer([:positive, :monotonic])}.json"
)
on_exit(fn ->
_ = File.rm(path)
end)
path
end
end

View File

@@ -19,6 +19,7 @@ defmodule Parrhesia.ApplicationTest do
end)
assert is_pid(Process.whereis(Parrhesia.Auth.Challenges))
assert is_pid(Process.whereis(Parrhesia.API.Identity.Manager))
if negentropy_enabled?() do
assert is_pid(Process.whereis(Parrhesia.Negentropy.Sessions))

View File

@@ -25,7 +25,18 @@ defmodule Parrhesia.Auth.Nip98Test do
Nip98.validate_authorization_header(header, "POST", "http://example.com/other")
end
defp nip98_event(method, url) do
test "supports overriding the freshness window" do
url = "http://example.com/management"
event = nip98_event("POST", url, %{"created_at" => System.system_time(:second) - 120})
header = "Nostr " <> Base.encode64(JSON.encode!(event))
assert {:error, :stale_event} = Nip98.validate_authorization_header(header, "POST", url)
assert {:ok, _event} =
Nip98.validate_authorization_header(header, "POST", url, max_age_seconds: 180)
end
defp nip98_event(method, url, overrides \\ %{}) do
now = System.system_time(:second)
base = %{
@@ -37,6 +48,7 @@ defmodule Parrhesia.Auth.Nip98Test do
"sig" => String.duplicate("b", 128)
}
Map.put(base, "id", EventValidator.compute_id(base))
event = Map.merge(base, overrides)
Map.put(event, "id", EventValidator.compute_id(event))
end
end

View File

@@ -216,6 +216,37 @@ defmodule Parrhesia.Web.RouterTest do
assert principal == String.duplicate("c", 64)
end
test "POST /management supports identity methods" do
management_url = "http://www.example.com/management"
auth_event = nip98_event("POST", management_url)
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
conn =
conn(
:post,
"/management",
JSON.encode!(%{
"method" => "identity_ensure",
"params" => %{}
})
)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", authorization)
|> Router.call([])
assert conn.status == 200
assert %{
"ok" => true,
"result" => %{
"pubkey" => pubkey
}
} = JSON.decode!(conn.resp_body)
assert is_binary(pubkey)
assert byte_size(pubkey) == 64
end
defp nip98_event(method, url) do
now = System.system_time(:second)