299 lines
8.2 KiB
Elixir
299 lines
8.2 KiB
Elixir
defmodule Parrhesia.API.Identity do
|
|
@moduledoc """
|
|
Server-auth identity management.
|
|
|
|
Parrhesia uses a single server identity for flows that need the relay to sign events or
|
|
prove control of a pubkey.
|
|
|
|
Identity resolution follows this order:
|
|
|
|
1. `opts[:private_key]` or `opts[:configured_private_key]`
|
|
2. `Application.get_env(:parrhesia, :identity)`
|
|
3. the persisted file on disk
|
|
|
|
Supported options across this module:
|
|
|
|
- `:path` - overrides the identity file path
|
|
- `:private_key` / `:configured_private_key` - uses an explicit hex secret key
|
|
|
|
A configured private key is treated as read-only input and therefore cannot be rotated.
|
|
"""
|
|
|
|
alias Parrhesia.API.Auth
|
|
|
|
@typedoc """
|
|
Public identity metadata returned to callers.
|
|
"""
|
|
@type identity_metadata :: %{
|
|
pubkey: String.t(),
|
|
source: :configured | :persisted | :generated | :imported
|
|
}
|
|
|
|
@doc """
|
|
Returns the current server identity metadata.
|
|
|
|
This does not generate a new identity. If no configured or persisted identity exists, it
|
|
returns `{:error, :identity_not_found}`.
|
|
"""
|
|
@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
|
|
|
|
@doc """
|
|
Returns the current identity, generating and persisting one when necessary.
|
|
"""
|
|
@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
|
|
|
|
@doc """
|
|
Imports an explicit secret key and persists it as the server identity.
|
|
|
|
The input map must contain `:secret_key` or `"secret_key"` as a 64-character lowercase or
|
|
uppercase hex string.
|
|
"""
|
|
@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}
|
|
|
|
@doc """
|
|
Generates and persists a fresh server identity.
|
|
|
|
Rotation is rejected with `{:error, :configured_identity_cannot_rotate}` when the active
|
|
identity comes from configuration rather than the persisted file.
|
|
"""
|
|
@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
|
|
|
|
@doc """
|
|
Signs an event with the current server identity.
|
|
|
|
The incoming event must already include the fields required to compute a Nostr id:
|
|
|
|
- `"created_at"`
|
|
- `"kind"`
|
|
- `"tags"`
|
|
- `"content"`
|
|
|
|
On success the returned event includes `"pubkey"`, `"id"`, and `"sig"`.
|
|
"""
|
|
@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}
|
|
|
|
@doc """
|
|
Returns the default filesystem path for the persisted server identity.
|
|
"""
|
|
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
|