From 769177a63ee100df0b03e3d566e7aa65cd041294 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Mon, 16 Mar 2026 21:07:26 +0100 Subject: [PATCH] Add shared auth and identity APIs --- config/config.exs | 4 + config/runtime.exs | 4 + config/test.exs | 4 + lib/parrhesia/api/admin.ex | 38 +++- lib/parrhesia/api/auth.ex | 48 +++++ lib/parrhesia/api/auth/context.ex | 19 ++ lib/parrhesia/api/identity.ex | 243 ++++++++++++++++++++++++++ lib/parrhesia/api/identity/manager.ex | 25 +++ lib/parrhesia/auth/nip98.ex | 34 ++-- lib/parrhesia/auth/supervisor.ex | 3 +- lib/parrhesia/web/management.ex | 10 +- test/parrhesia/api/auth_test.exs | 62 +++++++ test/parrhesia/api/identity_test.exs | 75 ++++++++ test/parrhesia/application_test.exs | 1 + test/parrhesia/auth/nip98_test.exs | 16 +- test/parrhesia/web/router_test.exs | 31 ++++ 16 files changed, 590 insertions(+), 27 deletions(-) create mode 100644 lib/parrhesia/api/auth.ex create mode 100644 lib/parrhesia/api/auth/context.ex create mode 100644 lib/parrhesia/api/identity.ex create mode 100644 lib/parrhesia/api/identity/manager.ex create mode 100644 test/parrhesia/api/auth_test.exs create mode 100644 test/parrhesia/api/identity_test.exs diff --git a/config/config.exs b/config/config.exs index 54795d4..4a4b546 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/config/runtime.exs b/config/runtime.exs index bb66bcb..e843311 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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: diff --git a/config/test.exs b/config/test.exs index e949673..03f17d8 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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") diff --git a/lib/parrhesia/api/admin.ex b/lib/parrhesia/api/admin.ex index 4199ee2..66ac9ad 100644 --- a/lib/parrhesia/api/admin.ex +++ b/lib/parrhesia/api/admin.ex @@ -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) diff --git a/lib/parrhesia/api/auth.ex b/lib/parrhesia/api/auth.ex new file mode 100644 index 0000000..8fbf498 --- /dev/null +++ b/lib/parrhesia/api/auth.ex @@ -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 diff --git a/lib/parrhesia/api/auth/context.ex b/lib/parrhesia/api/auth/context.ex new file mode 100644 index 0000000..9d7bdad --- /dev/null +++ b/lib/parrhesia/api/auth/context.ex @@ -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 diff --git a/lib/parrhesia/api/identity.ex b/lib/parrhesia/api/identity.ex new file mode 100644 index 0000000..c8355d2 --- /dev/null +++ b/lib/parrhesia/api/identity.ex @@ -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 diff --git a/lib/parrhesia/api/identity/manager.ex b/lib/parrhesia/api/identity/manager.ex new file mode 100644 index 0000000..6d74734 --- /dev/null +++ b/lib/parrhesia/api/identity/manager.ex @@ -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 diff --git a/lib/parrhesia/auth/nip98.ex b/lib/parrhesia/auth/nip98.ex index ba4d487..b860048 100644 --- a/lib/parrhesia/auth/nip98.ex +++ b/lib/parrhesia/auth/nip98.ex @@ -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", []) diff --git a/lib/parrhesia/auth/supervisor.ex b/lib/parrhesia/auth/supervisor.ex index afe9c90..68bd0b1 100644 --- a/lib/parrhesia/auth/supervisor.ex +++ b/lib/parrhesia/auth/supervisor.ex @@ -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) diff --git a/lib/parrhesia/web/management.ex b/lib/parrhesia/web/management.ex index 40ba4fc..efbe1d7 100644 --- a/lib/parrhesia/web/management.ex +++ b/lib/parrhesia/web/management.ex @@ -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) }) diff --git a/test/parrhesia/api/auth_test.exs b/test/parrhesia/api/auth_test.exs new file mode 100644 index 0000000..0aa8ed8 --- /dev/null +++ b/test/parrhesia/api/auth_test.exs @@ -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 diff --git a/test/parrhesia/api/identity_test.exs b/test/parrhesia/api/identity_test.exs new file mode 100644 index 0000000..966693a --- /dev/null +++ b/test/parrhesia/api/identity_test.exs @@ -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 diff --git a/test/parrhesia/application_test.exs b/test/parrhesia/application_test.exs index c729275..1dd2580 100644 --- a/test/parrhesia/application_test.exs +++ b/test/parrhesia/application_test.exs @@ -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)) diff --git a/test/parrhesia/auth/nip98_test.exs b/test/parrhesia/auth/nip98_test.exs index 9d0accf..a4a4e16 100644 --- a/test/parrhesia/auth/nip98_test.exs +++ b/test/parrhesia/auth/nip98_test.exs @@ -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 diff --git a/test/parrhesia/web/router_test.exs b/test/parrhesia/web/router_test.exs index deb47c7..4783297 100644 --- a/test/parrhesia/web/router_test.exs +++ b/test/parrhesia/web/router_test.exs @@ -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)