lib/pkcs11ex.ex

defmodule Pkcs11ex do
  @moduledoc """
  Hardware-backed digital signatures for Elixir, via PKCS#11.

  This module hosts the **Layer 2** signing primitives — format-agnostic
  `sign_bytes`, `verify_bytes`, `digest`, and `digest_stream`. Format adapters
  (`SignCore.JWS`, `SignCore.PDF`, `SignCore.XML`) build on top.

  See `docs/specs/specs.md` for architecture and `docs/specs/api.md` for the
  full public API specification.

  ## Surface

  Two paths into a sign:

    * **Signer-ref (`:signer`, recommended)** — `signer: {slot_ref, key_ref}`
      resolves through the running `Pkcs11ex.Slot.Server` for that slot,
      which holds the persistent session, applies the configured
      `pin_callback`, and routes through the single-session-pinned model
      from `specs.md` §5.2. Atom shorthand: `signer: :key_ref` resolves
      against `default_slot` from `Pkcs11ex.Application.config/0`.

    * **Explicit (legacy / one-shot)** — pass `module:`, `slot_id:`,
      `key_label:`, and (for token slots) `pin:` directly. Opens a fresh
      session per call. Useful for scripts and tests; production should
      prefer `:signer`.
  """

  alias SignCore.Algorithm
  alias Pkcs11ex.Native

  @type sign_opts :: [
          module: Native.module_resource(),
          slot_id: non_neg_integer(),
          pin: binary() | nil,
          key_label: String.t(),
          alg: atom(),
          encoding_context: :jose | :der
        ]

  @type verify_opts :: [
          module: Native.module_resource(),
          slot_id: non_neg_integer(),
          key_label: String.t(),
          alg: atom(),
          encoding_context: :jose | :der
        ]

  @doc """
  Sign `data` with a hardware-backed key.

  ## Required options
    * `:module` — a `Pkcs11ex.Native` module resource (from `Native.module_load/1`).
    * `:slot_id` — `CK_SLOT_ID` (non-negative integer).
    * `:key_label` — `CKA_LABEL` of the private key on the slot.
    * `:alg` — algorithm atom (e.g., `:PS256`). Must be in the configured
      `:allowed_algs` allowlist; any value outside the allowlist is rejected
      before the NIF is called.

  ## Optional options
    * `:pin` — User PIN for token slots; omit for cloud HSMs that don't require login.
    * `:encoding_context` — `:jose` or `:der`; defaults to `:der`. Controls the
      wire-format encoding of the returned signature for algorithms whose
      encoding differs by context (e.g., ES256 — irrelevant for PS256).
  """
  @spec sign_bytes(iodata(), sign_opts()) :: {:ok, binary()} | {:error, term()}
  def sign_bytes(data, opts) when is_list(opts) do
    with {:ok, alg} <- fetch_alg(opts),
         :ok <- check_alg_allowed(alg),
         {:ok, adapter} <- Algorithm.lookup(alg),
         {:ok, mechanism} <- mechanism_string(adapter, :sign),
         {:ok, raw} <- dispatch_sign(opts, adapter, mechanism, IO.iodata_to_binary(data)),
         ctx <- Keyword.get(opts, :encoding_context, :der),
         {:ok, encoded} <- adapter.encode_signature(raw, ctx) do
      {:ok, encoded}
    end
  end

  @doc """
  Verify a `signature` over `data` using a hardware-backed public key.

  Required options: `:module`, `:slot_id`, `:key_label`, `:alg`.
  Optional: `:encoding_context` (default `:der`).
  """
  @spec verify_bytes(iodata(), binary(), verify_opts()) :: :ok | {:error, term()}
  def verify_bytes(data, signature, opts) when is_binary(signature) and is_list(opts) do
    with {:ok, alg} <- fetch_alg(opts),
         :ok <- check_alg_allowed(alg),
         {:ok, adapter} <- Algorithm.lookup(alg),
         {:ok, mechanism} <- mechanism_string(adapter, :verify),
         ctx <- Keyword.get(opts, :encoding_context, :der),
         {:ok, raw} <- adapter.decode_signature(signature, ctx),
         {:ok, true} <- run_verify(opts, mechanism, IO.iodata_to_binary(data), raw) do
      :ok
    else
      {:error, _} = err -> err
    end
  end

  @doc """
  Returns the version reported by the native bridge.

  Smoke test for the Rustler NIF wiring; returns the `Cargo.toml` package
  version of `pkcs11ex_nif`.
  """
  @spec native_version() :: String.t()
  def native_version, do: Native.version()

  # ---------- Internals ----------

  defp fetch_alg(opts) do
    case Keyword.fetch(opts, :alg) do
      {:ok, alg} when is_atom(alg) -> {:ok, alg}
      _ -> {:error, :missing_alg}
    end
  end

  defp check_alg_allowed(alg) do
    allowed = Application.get_env(:pkcs11ex, :allowed_algs, [:PS256])

    cond do
      alg == :none -> {:error, :disallowed_alg}
      alg in allowed -> :ok
      true -> {:error, :disallowed_alg}
    end
  end

  defp mechanism_string(adapter, :sign), do: {:ok, Atom.to_string(adapter.signing_mechanism())}
  defp mechanism_string(adapter, :verify), do: {:ok, Atom.to_string(adapter.verifying_mechanism())}

  # Choose between the persistent-session signer-ref path (preferred) and the
  # legacy explicit-opts path (per-call session). Signer-ref wins when present.
  defp dispatch_sign(opts, adapter, mechanism, data) do
    cond do
      Keyword.has_key?(opts, :signer) ->
        sign_via_signer(opts, adapter, data)

      Keyword.has_key?(opts, :module) ->
        run_sign(opts, mechanism, data)

      true ->
        {:error, :no_signer_specified}
    end
  end

  defp sign_via_signer(opts, adapter, data) do
    with {:ok, %{slot_ref: slot_ref, key_label: key_label}} <- resolve_signer(opts) do
      sign_opts = Keyword.take(opts, [:pin])

      Pkcs11ex.Slot.sign(slot_ref, key_label, adapter.signing_mechanism(), data, sign_opts)
    end
  end

  defp resolve_signer(opts) do
    case Keyword.fetch!(opts, :signer) do
      {slot_ref, key_ref} when is_atom(slot_ref) and is_atom(key_ref) ->
        resolve_signer_pair(slot_ref, key_ref)

      key_ref when is_atom(key_ref) ->
        case default_slot() do
          nil -> {:error, :no_default_slot}
          slot_ref -> resolve_signer_pair(slot_ref, key_ref)
        end

      other ->
        {:error, {:invalid_signer, other}}
    end
  end

  defp resolve_signer_pair(slot_ref, key_ref) do
    with {:ok, slot_config} <- Pkcs11ex.Slot.Server.get_config(slot_ref),
         keys = slot_config[:keys] || [],
         {:ok, key_entry} <- fetch_key(keys, key_ref),
         {:ok, key_label} <- key_label_from_entry(key_entry) do
      {:ok, %{slot_ref: slot_ref, key_ref: key_ref, key_label: key_label}}
    end
  end

  defp fetch_key(keys, key_ref) do
    case Keyword.fetch(keys, key_ref) do
      {:ok, entry} -> {:ok, entry}
      :error -> {:error, {:key_not_found, key_ref}}
    end
  end

  defp key_label_from_entry(entry) do
    case {entry[:label], entry[:id]} do
      {label, _} when is_binary(label) -> {:ok, label}
      {_, id} when is_binary(id) -> {:ok, id}
      _ -> {:error, :key_has_no_label_or_id}
    end
  end

  defp default_slot do
    Pkcs11ex.Application.config().default_slot
  rescue
    # Application not started — for example in plain-script use of the
    # legacy path without the supervisor. Atom-shorthand :signer requires
    # config; tuple form does not.
    _ -> nil
  end

  defp run_sign(opts, mechanism, data) do
    module = Keyword.fetch!(opts, :module)
    slot_id = Keyword.fetch!(opts, :slot_id)
    pin = Keyword.get(opts, :pin, "") || ""
    key_label = Keyword.fetch!(opts, :key_label)

    # NIF returns `Result<Vec<u8>, Error>`; Rustler 0.37 encodes Vec<u8>
    # as an Erlang list, hence the IO.iodata_to_binary normalisation here.
    case Native.sign(module, slot_id, pin, mechanism, key_label, data) do
      {:ok, sig} -> {:ok, IO.iodata_to_binary(sig)}
      {:error, _} = err -> err
    end
  end

  defp run_verify(opts, mechanism, data, signature) do
    module = Keyword.fetch!(opts, :module)
    slot_id = Keyword.fetch!(opts, :slot_id)
    key_label = Keyword.fetch!(opts, :key_label)

    case Native.verify(module, slot_id, mechanism, key_label, data, signature) do
      {:ok, true} -> {:ok, true}
      {:error, _} = err -> err
    end
  end
end