lib/mix/tasks/pkcs11ex.import_p12.ex

defmodule Mix.Tasks.Pkcs11ex.ImportP12 do
  @shortdoc "Import a PKCS#12 bundle into a configured PKCS#11 slot"

  @moduledoc """
  Imports the private key + leaf certificate from a PKCS#12 (`.p12` / `.pfx`)
  bundle into a configured PKCS#11 slot.

  Intended for: SoftHSM provisioning in dev/CI, one-shot loading of taxpayer
  / legal-proxy certificates into write-permitted file-backed tokens, fixture
  setup in test suites. **Not** suitable for production HSMs (most reject
  software-key import by design; cloud HSMs do so categorically).

  See `docs/specs/api.md` §5.1 for the canonical contract.

  ## Usage

      mix pkcs11ex.import_p12 \\
        --in legal_proxy.p12 \\
        --slot legal_proxy \\
        --label proxy-signing-key \\
        [--cert-label proxy-cert] \\
        [--id 0x01]

  ## Required flags

    * `--in PATH` — path to the `.p12` / `.pfx` bundle.
    * `--slot SLOT_REF` — atom of a configured slot (must exist in
      `:pkcs11ex` `:slots` config, must be write-permitted).
    * `--label LABEL` — `CKA_LABEL` for the imported private key.

  ## Optional flags

    * `--cert-label LABEL` — `CKA_LABEL` for the certificate. Defaults to `--label`.
    * `--id HEX` — `CKA_ID` (hex-encoded). Auto-generated if omitted.
    * `--password-from-env VAR` — read P12 password from env var (CI).
    * `--pin-from-env VAR` — read user PIN from env var (CI).

  ## Prompts

  When `--password-from-env` / `--pin-from-env` are not used, the task
  prompts for the bundle password and the slot's user PIN with terminal
  echo disabled.

  ## Threat model — plaintext key in BEAM heap

  This task is the only path in `pkcs11ex` that handles a software
  RSA private key in cleartext. The key flows through BEAM-managed
  binaries:

    1. `openssl pkcs12 -in <bundle> -nocerts -nodes` extracts the key
       as a PEM blob into the task's stdout (captured by `System.cmd`
       into a binary).
    2. `:public_key.pem_decode/1` and `pem_entry_decode/1` produce an
       Erlang `RSAPrivateKey` record — modulus, private exponent, the
       five CRT parameters, all as integers / binaries on the BEAM heap.
    3. The components are marshaled across the NIF as binaries, then
       the NIF zeroizes its Rust-side copies (cryptoki + `Zeroizing`).

    Step 1 and step 2 BEAM heap memory cannot be wiped — the BEAM has
    no zeroization primitive for managed binaries / integers, and the
    GC may keep them around well past the task's nominal lifetime.

  **The only mitigations are:**

    * Run the task in a short-lived process; let the BEAM exit
      immediately after the import.
    * Don't run on a multi-tenant box where another process can read
      `/proc/<pid>/maps` or trigger a core dump.
    * Treat the bundle on disk as the canonical secret — the BEAM-heap
      copy is a temporary echo of it.

  Use `pkcs11ex` directly (HSM-only path) for any key that should
  never have a software copy in memory. This task is for provisioning
  fixtures, dev/CI, and importing dormant taxpayer / legal-proxy
  certificates into write-permitted file-backed tokens.

  Both the bundle password and the user PIN are passed once into the
  flow and not stored after this task returns. They transit BEAM
  memory the same way and are subject to the same caveats.
  """

  use Mix.Task

  alias Pkcs11ex.Native.RsaPrivateComponents

  @switches [
    in: :string,
    slot: :string,
    label: :string,
    cert_label: :string,
    id: :string,
    password_from_env: :string,
    pin_from_env: :string
  ]

  @impl Mix.Task
  def run(argv) do
    {opts, _args, invalid} = OptionParser.parse(argv, strict: @switches)

    if invalid != [] do
      Mix.raise("invalid options: #{inspect(invalid)}")
    end

    Mix.Task.run("app.start")

    in_path = require_opt!(opts, :in)
    slot_ref = require_opt!(opts, :slot) |> String.to_atom()
    key_label = require_opt!(opts, :label)
    cert_label = opts[:cert_label] || key_label
    id_bin = decode_id(opts[:id])

    unless File.regular?(in_path) do
      Mix.raise("bundle not found: #{in_path}")
    end

    p12_password = fetch_secret(opts, :password_from_env, "PKCS#12 bundle password: ")
    user_pin = fetch_secret(opts, :pin_from_env, "Slot #{slot_ref} user PIN: ")

    with {:ok, key_pem, cert_pem} <- extract_p12(in_path, p12_password),
         {:ok, components} <- parse_rsa_components(key_pem),
         {:ok, cert_der, subject_der} <- parse_cert(cert_pem) do
      args = [
        components: components,
        cert_der: cert_der,
        subject_der: subject_der,
        key_label: key_label,
        cert_label: cert_label,
        id: id_bin
      ]

      case Pkcs11ex.Slot.Server.import_keypair(slot_ref, args, pin: user_pin) do
        :ok ->
          Mix.shell().info(
            "Imported keypair into slot #{slot_ref}: " <>
              "key #{inspect(key_label)}, cert #{inspect(cert_label)}"
          )

        {:error, :slot_not_found} ->
          Mix.raise(
            "slot #{inspect(slot_ref)} is not running. Configure it under " <>
              ":pkcs11ex :slots and ensure the application is started."
          )

        {:error, reason} ->
          Mix.raise("import failed: #{inspect(reason)}")
      end
    else
      {:error, reason} -> Mix.raise("#{reason}")
    end
  end

  # ---------- argument plumbing ----------

  defp require_opt!(opts, key) do
    case Keyword.fetch(opts, key) do
      {:ok, value} -> value
      :error -> Mix.raise("--#{String.replace(to_string(key), "_", "-")} is required")
    end
  end

  defp decode_id(nil), do: ""

  defp decode_id("0x" <> hex), do: decode_id(hex)

  defp decode_id(hex) do
    case Base.decode16(hex, case: :mixed) do
      {:ok, bytes} -> bytes
      :error -> Mix.raise("--id must be hex (e.g., 0x01 or 01)")
    end
  end

  defp fetch_secret(opts, env_key, prompt) do
    case opts[env_key] do
      nil -> prompt_secret(prompt)
      var -> System.get_env(var) || Mix.raise("env var #{var} not set")
    end
  end

  defp prompt_secret(prompt) do
    # Terminal-echo-off prompt. Falls back to plain IO.gets if stdio isn't a
    # TTY (e.g., redirected) — operators using non-interactive contexts should
    # use --password-from-env / --pin-from-env instead.
    Mix.shell().info(prompt)

    case :io.get_password() do
      {:error, _} ->
        case Mix.shell().prompt("") |> String.trim() do
          "" -> Mix.raise("empty secret")
          val -> val
        end

      :eof ->
        Mix.raise("EOF reading secret")

      data when is_list(data) ->
        result = data |> List.to_string() |> String.trim_trailing("\n")
        if result == "", do: Mix.raise("empty secret"), else: result
    end
  end

  # ---------- PKCS#12 extraction (openssl) ----------

  defp extract_p12(path, password) do
    case System.find_executable("openssl") do
      nil ->
        {:error, "openssl CLI not on PATH"}

      openssl ->
        env = [{"PKCS11EX_P12_PWD", password}]

        with {:ok, certs_pem} <-
               run_openssl(
                 openssl,
                 ["pkcs12", "-in", path, "-password", "env:PKCS11EX_P12_PWD", "-nokeys", "-nodes"],
                 env
               ),
             {:ok, key_pem} <-
               run_openssl(
                 openssl,
                 ["pkcs12", "-in", path, "-password", "env:PKCS11EX_P12_PWD", "-nocerts", "-nodes"],
                 env
               ) do
          {:ok, key_pem, certs_pem}
        end
    end
  end

  defp run_openssl(openssl, args, env) do
    case System.cmd(openssl, args, env: env, stderr_to_stdout: true) do
      {output, 0} ->
        {:ok, output}

      {output, _} ->
        # OpenSSL's wording for "wrong P12 password" varies across
        # versions: "mac verify failure" (1.0.x), "Mac verify error"
        # (3.x). Match a case-insensitive prefix that catches both.
        cond do
          output =~ ~r/mac verify (failure|error)/i ->
            {:error, "PKCS#12 password incorrect"}

          true ->
            {:error, "openssl pkcs12 failed: #{String.slice(output, 0, 200)}"}
        end
    end
  end

  # ---------- PEM parsing ----------

  defp parse_rsa_components(pem) do
    case :public_key.pem_decode(pem) do
      [] ->
        {:error, "no PEM entries found in extracted key"}

      entries ->
        case find_rsa_private_key(entries) do
          {:ok, rsa_record} ->
            {:ok,
             %RsaPrivateComponents{
               modulus: int_to_bin(elem(rsa_record, 2)),
               public_exponent: int_to_bin(elem(rsa_record, 3)),
               private_exponent: int_to_bin(elem(rsa_record, 4)),
               prime1: int_to_bin(elem(rsa_record, 5)),
               prime2: int_to_bin(elem(rsa_record, 6)),
               exponent1: int_to_bin(elem(rsa_record, 7)),
               exponent2: int_to_bin(elem(rsa_record, 8)),
               coefficient: int_to_bin(elem(rsa_record, 9))
             }}

          :error ->
            {:error, "no RSA private key found in extracted key PEM"}
        end
    end
  end

  defp find_rsa_private_key(entries) do
    Enum.reduce_while(entries, :error, fn entry, _ ->
      case entry do
        {:RSAPrivateKey, _, _} = e ->
          {:halt, {:ok, :public_key.pem_entry_decode(e)}}

        {:PrivateKeyInfo, _, _} = e ->
          # PKCS#8 — decode then check inner type. RSAPrivateKey has 11 elements
          # (tag + 9 fields + otherPrimeInfos).
          case :public_key.pem_entry_decode(e) do
            rsa when is_tuple(rsa) and tuple_size(rsa) == 11 and elem(rsa, 0) == :RSAPrivateKey ->
              {:halt, {:ok, rsa}}

            _ ->
              {:cont, :error}
          end

        _ ->
          {:cont, :error}
      end
    end)
  end

  defp int_to_bin(n) when is_integer(n) and n >= 0,
    do: :binary.encode_unsigned(n, :big)

  defp parse_cert(pem) do
    case :public_key.pem_decode(pem) do
      [] ->
        {:error, "no PEM entries found in extracted cert"}

      entries ->
        case Enum.find(entries, fn {tag, _, _} -> tag == :Certificate end) do
          {:Certificate, der, _} ->
            plain = :public_key.pkix_decode_cert(der, :plain)
            tbs = elem(plain, 1)
            subject = elem(tbs, 6)
            subject_der = :public_key.der_encode(:Name, subject)
            {:ok, der, subject_der}

          nil ->
            {:error, "no Certificate found in extracted cert PEM"}
        end
    end
  end
end