Add shared auth and identity APIs
This commit is contained in:
@@ -5,6 +5,10 @@ config :postgrex, :json_library, JSON
|
|||||||
config :parrhesia,
|
config :parrhesia,
|
||||||
moderation_cache_enabled: true,
|
moderation_cache_enabled: true,
|
||||||
relay_url: "ws://localhost:4413/relay",
|
relay_url: "ws://localhost:4413/relay",
|
||||||
|
identity: [
|
||||||
|
path: nil,
|
||||||
|
private_key: nil
|
||||||
|
],
|
||||||
limits: [
|
limits: [
|
||||||
max_frame_bytes: 1_048_576,
|
max_frame_bytes: 1_048_576,
|
||||||
max_event_bytes: 262_144,
|
max_event_bytes: 262_144,
|
||||||
|
|||||||
@@ -451,6 +451,10 @@ if config_env() == :prod do
|
|||||||
|
|
||||||
config :parrhesia,
|
config :parrhesia,
|
||||||
relay_url: string_env.("PARRHESIA_RELAY_URL", relay_url_default),
|
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:
|
moderation_cache_enabled:
|
||||||
bool_env.("PARRHESIA_MODERATION_CACHE_ENABLED", moderation_cache_enabled_default),
|
bool_env.("PARRHESIA_MODERATION_CACHE_ENABLED", moderation_cache_enabled_default),
|
||||||
enable_expiration_worker:
|
enable_expiration_worker:
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ config :parrhesia, Parrhesia.Web.Endpoint,
|
|||||||
config :parrhesia,
|
config :parrhesia,
|
||||||
enable_expiration_worker: false,
|
enable_expiration_worker: false,
|
||||||
moderation_cache_enabled: false,
|
moderation_cache_enabled: false,
|
||||||
|
identity: [
|
||||||
|
path: Path.join(System.tmp_dir!(), "parrhesia_test_identity.json"),
|
||||||
|
private_key: nil
|
||||||
|
],
|
||||||
features: [verify_event_signatures: false]
|
features: [verify_event_signatures: false]
|
||||||
|
|
||||||
pg_host = System.get_env("PGHOST")
|
pg_host = System.get_env("PGHOST")
|
||||||
|
|||||||
@@ -4,20 +4,21 @@ defmodule Parrhesia.API.Admin do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias Parrhesia.API.ACL
|
alias Parrhesia.API.ACL
|
||||||
|
alias Parrhesia.API.Identity
|
||||||
alias Parrhesia.Storage
|
alias Parrhesia.Storage
|
||||||
|
|
||||||
@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)
|
||||||
|
|
||||||
@spec execute(String.t() | atom(), map(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec execute(String.t() | atom(), map(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
def execute(method, params, opts \\ [])
|
def execute(method, params, opts \\ [])
|
||||||
|
|
||||||
def execute(method, params, _opts) when is_map(params) do
|
def execute(method, params, _opts) when is_map(params) do
|
||||||
case normalize_method_name(method) do
|
method_name = normalize_method_name(method)
|
||||||
"acl_grant" -> acl_grant(params)
|
|
||||||
"acl_revoke" -> acl_revoke(params)
|
case execute_builtin(method_name, params) do
|
||||||
"acl_list" -> acl_list(params)
|
{:continue, other_method} -> Storage.admin().execute(%{}, other_method, params)
|
||||||
"supportedmethods" -> {:ok, %{"methods" => supported_methods()}}
|
result -> result
|
||||||
other_method -> Storage.admin().execute(%{}, other_method, params)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,11 +69,34 @@ defmodule Parrhesia.API.Admin do
|
|||||||
_other -> []
|
_other -> []
|
||||||
end
|
end
|
||||||
|
|
||||||
(storage_supported ++ @supported_acl_methods)
|
(storage_supported ++ @supported_acl_methods ++ @supported_identity_methods)
|
||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
|> Enum.sort()
|
|> Enum.sort()
|
||||||
end
|
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, 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)
|
||||||
|
|
||||||
|
|||||||
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()) ::
|
@spec validate_authorization_header(String.t() | nil, String.t(), String.t()) ::
|
||||||
{:ok, map()} | {:error, atom()}
|
{: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)
|
@spec validate_authorization_header(String.t() | nil, String.t(), String.t(), keyword()) ::
|
||||||
when is_binary(method) and is_binary(url) do
|
{: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),
|
with {:ok, event_json} <- decode_base64(encoded_event),
|
||||||
{:ok, event} <- JSON.decode(event_json),
|
{: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 <- validate_http_binding(event, method, url) do
|
||||||
{:ok, event}
|
{:ok, event}
|
||||||
else
|
else
|
||||||
@@ -24,7 +31,8 @@ defmodule Parrhesia.Auth.Nip98 do
|
|||||||
end
|
end
|
||||||
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
|
defp decode_base64(encoded_event) do
|
||||||
case Base.decode64(encoded_event) do
|
case Base.decode64(encoded_event) do
|
||||||
@@ -33,33 +41,35 @@ defmodule Parrhesia.Auth.Nip98 do
|
|||||||
end
|
end
|
||||||
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),
|
with :ok <- EventValidator.validate(event),
|
||||||
:ok <- validate_kind(event),
|
:ok <- validate_kind(event),
|
||||||
:ok <- validate_fresh_created_at(event) do
|
:ok <- validate_fresh_created_at(event, opts) do
|
||||||
:ok
|
:ok
|
||||||
else
|
else
|
||||||
:ok -> :ok
|
{:error, :stale_event} -> {:error, :stale_event}
|
||||||
{:error, _reason} -> {:error, :invalid_event}
|
{:error, _reason} -> {:error, :invalid_event}
|
||||||
end
|
end
|
||||||
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(%{"kind" => 27_235}), do: :ok
|
||||||
defp validate_kind(_event), do: {:error, :invalid_event}
|
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)
|
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
|
:ok
|
||||||
else
|
else
|
||||||
{:error, :stale_event}
|
{:error, :stale_event}
|
||||||
end
|
end
|
||||||
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
|
defp validate_http_binding(event, method, url) do
|
||||||
tags = Map.get(event, "tags", [])
|
tags = Map.get(event, "tags", [])
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ defmodule Parrhesia.Auth.Supervisor do
|
|||||||
@impl true
|
@impl true
|
||||||
def init(_init_arg) do
|
def init(_init_arg) do
|
||||||
children = [
|
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)
|
Supervisor.init(children, strategy: :one_for_one)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ defmodule Parrhesia.Web.Management do
|
|||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
||||||
alias Parrhesia.API.Admin
|
alias Parrhesia.API.Admin
|
||||||
alias Parrhesia.Auth.Nip98
|
alias Parrhesia.API.Auth
|
||||||
|
|
||||||
@spec handle(Plug.Conn.t()) :: Plug.Conn.t()
|
@spec handle(Plug.Conn.t()) :: Plug.Conn.t()
|
||||||
def handle(conn) do
|
def handle(conn) do
|
||||||
@@ -14,10 +14,10 @@ defmodule Parrhesia.Web.Management do
|
|||||||
method = conn.method
|
method = conn.method
|
||||||
authorization = get_req_header(conn, "authorization") |> List.first()
|
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, payload} <- parse_payload(conn.body_params),
|
||||||
{:ok, result} <- execute_method(payload),
|
{: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})
|
send_json(conn, 200, %{"ok" => true, "result" => result})
|
||||||
else
|
else
|
||||||
{:error, :missing_authorization} ->
|
{:error, :missing_authorization} ->
|
||||||
@@ -62,10 +62,10 @@ defmodule Parrhesia.Web.Management do
|
|||||||
Admin.execute(payload.method, payload.params)
|
Admin.execute(payload.method, payload.params)
|
||||||
end
|
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(%{}, %{
|
Parrhesia.Storage.admin().append_audit_log(%{}, %{
|
||||||
method: payload.method,
|
method: payload.method,
|
||||||
actor_pubkey: Map.get(auth_event, "pubkey"),
|
actor_pubkey: auth_context.pubkey,
|
||||||
params: payload.params,
|
params: payload.params,
|
||||||
result: normalize_result(result)
|
result: normalize_result(result)
|
||||||
})
|
})
|
||||||
|
|||||||
62
test/parrhesia/api/auth_test.exs
Normal file
62
test/parrhesia/api/auth_test.exs
Normal 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
|
||||||
75
test/parrhesia/api/identity_test.exs
Normal file
75
test/parrhesia/api/identity_test.exs
Normal 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
|
||||||
@@ -19,6 +19,7 @@ defmodule Parrhesia.ApplicationTest do
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
assert is_pid(Process.whereis(Parrhesia.Auth.Challenges))
|
assert is_pid(Process.whereis(Parrhesia.Auth.Challenges))
|
||||||
|
assert is_pid(Process.whereis(Parrhesia.API.Identity.Manager))
|
||||||
|
|
||||||
if negentropy_enabled?() do
|
if negentropy_enabled?() do
|
||||||
assert is_pid(Process.whereis(Parrhesia.Negentropy.Sessions))
|
assert is_pid(Process.whereis(Parrhesia.Negentropy.Sessions))
|
||||||
|
|||||||
@@ -25,7 +25,18 @@ defmodule Parrhesia.Auth.Nip98Test do
|
|||||||
Nip98.validate_authorization_header(header, "POST", "http://example.com/other")
|
Nip98.validate_authorization_header(header, "POST", "http://example.com/other")
|
||||||
end
|
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)
|
now = System.system_time(:second)
|
||||||
|
|
||||||
base = %{
|
base = %{
|
||||||
@@ -37,6 +48,7 @@ defmodule Parrhesia.Auth.Nip98Test do
|
|||||||
"sig" => String.duplicate("b", 128)
|
"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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -216,6 +216,37 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
assert principal == String.duplicate("c", 64)
|
assert principal == String.duplicate("c", 64)
|
||||||
end
|
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
|
defp nip98_event(method, url) do
|
||||||
now = System.system_time(:second)
|
now = System.system_time(:second)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user