Skip to main content

lib/mix/tasks/clearbank.gen.keys.ex

defmodule Mix.Tasks.Clearbank.Gen.Keys do
  @moduledoc """
  Generates an RSA 2048-bit key pair suitable for ClearBank API authentication.

  ## Usage

      mix clearbank.gen.keys

      # Custom output directory
      mix clearbank.gen.keys --dir priv/certs

      # Custom filenames
      mix clearbank.gen.keys --private private.pem --public public.pem

      # Generate CSR for upload to ClearBank Portal
      mix clearbank.gen.keys --csr --common-name "My Institution"

  ## Output files

  By default, writes to `priv/clearbank/`:

    * `private_key.pem` — keep secret, store in HSM in production
    * `public_key.pem` — extracted public key
    * `clearbank.csr` — Certificate Signing Request (if `--csr` flag used)

  ## Security

    - **Never commit `private_key.pem` to version control**
    - In production, the private key must reside in a FIPS 140-2 level 2 compliant HSM
    - In simulation, any RSA 2048-bit key pair is acceptable

  ## Next steps

  1. Upload `clearbank.csr` to the ClearBank Portal under
     **Institution > Certificates and Tokens > Generate API Token**
  2. Copy the API token (shown only once)
  3. Configure in `config/runtime.exs`:

         config :clearbank,
           api_token: System.fetch_env!("CLEARBANK_API_TOKEN"),
           private_key_path: System.fetch_env!("CLEARBANK_PRIVATE_KEY_PATH")
  """

  # Mix.Task is not included in the default Dialyzer PLT for libraries.
  # :no_behaviour_info suppresses the callback_info_missing warning that
  # Dialyzer emits when it cannot find the behaviour definition in the PLT.
  # This is the canonical fix used by Elixir core and major libraries
  # (Ecto, Phoenix) for all Mix task modules.

  use Mix.Task

  @switches [
    dir: :string,
    private: :string,
    public: :string,
    csr: :boolean,
    common_name: :string,
    force: :boolean
  ]

  @spec run([String.t()]) :: :ok
  def run(args) do
    {opts, _rest, _invalid} = OptionParser.parse(args, switches: @switches)

    dir = Keyword.get(opts, :dir, "priv/clearbank")
    private_file = Keyword.get(opts, :private, "private_key.pem")
    public_file = Keyword.get(opts, :public, "public_key.pem")
    generate_csr = Keyword.get(opts, :csr, false)
    common_name = Keyword.get(opts, :common_name, "ClearBank Institution")
    force = Keyword.get(opts, :force, false)

    private_path = Path.join(dir, private_file)
    public_path = Path.join(dir, public_file)
    csr_path = Path.join(dir, "clearbank.csr")

    File.mkdir_p!(dir)
    check_existing_key(private_path, force)

    shell_info("Generating RSA 2048-bit key pair...")

    private_key = :public_key.generate_key({:rsa, 2048, 65_537})
    {private_pem, public_pem} = encode_key_pair(private_key)

    File.write!(private_path, private_pem)
    shell_info("  ✓ Private key written to #{private_path}")

    File.write!(public_path, public_pem)
    shell_info("  ✓ Public key written to #{public_path}")

    maybe_write_csr(generate_csr, private_key, common_name, csr_path)
    write_gitignore(dir)
    print_next_steps(private_path, generate_csr, csr_path, common_name)
  end

  # ---

  defp check_existing_key(private_path, force) do
    if not force and File.exists?(private_path) do
      raise_error(
        "#{private_path} already exists. Use --force to overwrite.\n" <>
          "Overwriting a key pair in use will invalidate your ClearBank API token."
      )
    end
  end

  defp encode_key_pair(private_key) do
    private_pem =
      private_key
      |> then(&:public_key.pem_entry_encode(:RSAPrivateKey, &1))
      |> List.wrap()
      |> :public_key.pem_encode()

    {:RSAPrivateKey, _, modulus, pub_exp, _, _, _, _, _, _, _} = private_key
    public_key = {:RSAPublicKey, modulus, pub_exp}

    public_pem =
      public_key
      |> then(&:public_key.pem_entry_encode(:RSAPublicKey, &1))
      |> List.wrap()
      |> :public_key.pem_encode()

    {private_pem, public_pem}
  end

  defp maybe_write_csr(false, _private_key, _common_name, _csr_path), do: :ok

  defp maybe_write_csr(true, private_key, common_name, csr_path) do
    shell_info("Generating CSR for '#{common_name}'...")
    csr_pem = build_csr_pem(private_key, common_name)

    if csr_pem != "" do
      File.write!(csr_path, csr_pem)
      shell_info("  ✓ CSR written to #{csr_path}")
    end
  end

  defp write_gitignore(dir) do
    gitignore_path = Path.join(dir, ".gitignore")

    unless File.exists?(gitignore_path) do
      File.write!(gitignore_path, "*.pem\n*.key\n*.csr\n")
      shell_info("  ✓ .gitignore added to #{dir}")
    end
  end

  defp print_next_steps(private_path, generate_csr, csr_path, common_name) do
    shell_info("")
    shell_info("Done! Next steps:")
    shell_info("")

    if generate_csr do
      shell_info("  1. Upload #{csr_path} to the ClearBank Portal:")
      shell_info("     Institution > Certificates and Tokens > Generate API Token")
      shell_info("  2. Copy the API token (shown once only)")
    else
      shell_info(
        "  1. Generate a CSR: mix clearbank.gen.keys --csr --common-name \"#{common_name}\""
      )

      shell_info(
        "  2. Upload the CSR to: Institution > Certificates and Tokens > Generate API Token"
      )
    end

    shell_info("")
    shell_info("  Configure in config/runtime.exs:")
    shell_info("")
    shell_info("    config :clearbank,")
    shell_info("      api_token: System.fetch_env!(\"CLEARBANK_API_TOKEN\"),")
    shell_info("      private_key_path: \"#{private_path}\"")
    shell_info("")
    shell_info("  Never commit #{private_path} to version control.")
  end

  defp build_csr_pem(private_key, common_name) do
    {:RSAPrivateKey, _, modulus, pub_exp, _, _, _, _, _, _, _} = private_key
    public_key = {:RSAPublicKey, modulus, pub_exp}

    subject_rdn =
      {:rdnSequence,
       [
         [
           {:AttributeTypeAndValue, {2, 5, 4, 3},
            {:utf8String, :unicode.characters_to_binary(common_name)}}
         ]
       ]}

    pub_key_der = :public_key.der_encode(:RSAPublicKey, public_key)

    pub_key_info =
      {:SubjectPublicKeyInfo,
       {:AlgorithmIdentifier, {1, 2, 840, 113_549, 1, 1, 1}, {:asn1_OPENTYPE, <<5, 0>>}},
       pub_key_der}

    cert_req_info = {:CertificationRequestInfo, :v1, subject_rdn, pub_key_info, []}
    tbs_der = :public_key.der_encode(:CertificationRequestInfo, cert_req_info)
    signature = :public_key.sign(tbs_der, :sha256, private_key)

    csr =
      {:CertificationRequest, cert_req_info,
       {:AlgorithmIdentifier, {1, 2, 840, 113_549, 1, 1, 11}, {:asn1_OPENTYPE, <<5, 0>>}},
       signature}

    csr_der = :public_key.der_encode(:CertificationRequest, csr)

    lines =
      csr_der
      |> Base.encode64(padding: true)
      |> String.graphemes()
      |> Enum.chunk_every(64)
      |> Enum.map_join("\n", &Enum.join/1)

    "-----BEGIN CERTIFICATE REQUEST-----\n#{lines}\n-----END CERTIFICATE REQUEST-----\n"
  rescue
    e ->
      shell_error("CSR generation failed: #{inspect(e)}")

      shell_info(
        "Tip: Generate CSR manually with:\n" <>
          "  openssl req -new -key private_key.pem -out clearbank.csr"
      )

      ""
  end

  # Thin wrappers so Dialyzer sees concrete calls to IO.puts/1 in test
  # environments where Mix may not be loaded, and Mix.shell/0 in Mix env.
  # Using apply/3 avoids compile-time resolution issues when Mix is not in PLT.
  defp shell_info(msg) do
    if function_exported?(Mix, :shell, 0) do
      apply(Mix, :shell, []).info(msg)
    else
      IO.puts(msg)
    end
  end

  defp shell_error(msg) do
    if function_exported?(Mix, :shell, 0) do
      apply(Mix, :shell, []).error(msg)
    else
      IO.puts(:stderr, msg)
    end
  end

  defp raise_error(msg) do
    if function_exported?(Mix, :raise, 1) do
      apply(Mix, :raise, [msg])
    else
      raise RuntimeError, message: msg
    end
  end
end