lib/mix/tasks/phx.gen.cert.ex

defmodule Mix.Tasks.Phx.Gen.Cert do
  @shortdoc "Generates a self-signed certificate for HTTPS testing"

  @default_path "priv/cert/selfsigned"
  @default_name "Self-signed test certificate"
  @default_hostnames ["localhost"]

  @warning """
  WARNING: only use the generated certificate for testing in a closed network
  environment, such as running a development server on `localhost`.
  For production, staging, or testing servers on the public internet, obtain a
  proper certificate, for example from [Let's Encrypt](https://letsencrypt.org).

  NOTE: when using Google Chrome, open chrome://flags/#allow-insecure-localhost
  to enable the use of self-signed certificates on `localhost`.
  """

  @moduledoc """
  Generates a self-signed certificate for HTTPS testing.

      $ mix phx.gen.cert
      $ mix phx.gen.cert my-app my-app.local my-app.internal.example.com

  Creates a private key and a self-signed certificate in PEM format. These
  files can be referenced in the `certfile` and `keyfile` parameters of an
  HTTPS Endpoint.

  #{@warning}

  ## Arguments

  The list of hostnames, if none are specified, defaults to:

    * #{Enum.join(@default_hostnames, "\n  * ")}

  Other (optional) arguments:

    * `--output` (`-o`): the path and base filename for the certificate and
      key (default: #{@default_path})
    * `--name` (`-n`): the Common Name value in certificate's subject
      (default: "#{@default_name}")

  Requires OTP 21.3 or later.
  """

  use Mix.Task
  import Mix.Generator

  @doc false
  def run(all_args) do
    if Mix.Project.umbrella?() do
      Mix.raise("mix phx.gen.cert must be invoked from within your *_web application root directory")
    end

    {opts, args} =
      OptionParser.parse!(
        all_args,
        aliases: [n: :name, o: :output],
        strict: [name: :string, output: :string]
      )

    path = opts[:output] || @default_path
    name = opts[:name] || @default_name

    hostnames =
      case args do
        [] -> @default_hostnames
        list -> list
      end

    {certificate, private_key} = certificate_and_key(2048, name, hostnames)

    keyfile = path <> "_key.pem"
    certfile = path <> ".pem"

    create_file(
      keyfile,
      :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)])
    )

    create_file(
      certfile,
      :public_key.pem_encode([{:Certificate, certificate, :not_encrypted}])
    )

    print_shell_instructions(keyfile, certfile)
  end

  @doc false
  def certificate_and_key(key_size, name, hostnames) do
    private_key =
      case generate_rsa_key(key_size, 65537) do
        {:ok, key} ->
          key

        {:error, :not_supported} ->
          Mix.raise("""
          Failed to generate an RSA key pair.

          This Mix task requires Erlang/OTP 20 or later. Please upgrade to a
          newer version, or use another tool, such as OpenSSL, to generate a
          certificate.
          """)
      end

    public_key = extract_public_key(private_key)

    certificate =
      public_key
      |> new_cert(name, hostnames)
      |> :public_key.pkix_sign(private_key)

    {certificate, private_key}
  end

  defp print_shell_instructions(keyfile, certfile) do
    app = Mix.Phoenix.otp_app()
    base = Mix.Phoenix.base()

    Mix.shell().info("""

    If you have not already done so, please update your HTTPS Endpoint
    configuration in config/dev.exs:

      config #{inspect(app)}, #{inspect(Mix.Phoenix.web_module(base))}.Endpoint,
        http: [port: 4000],
        https: [
          port: 4001,
          cipher_suite: :strong,
          certfile: "#{certfile}",
          keyfile: "#{keyfile}"
        ],
        ...

    #{@warning}
    """)
  end

  require Record

  # RSA key pairs

  Record.defrecordp(
    :rsa_private_key,
    :RSAPrivateKey,
    Record.extract(:RSAPrivateKey, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :rsa_public_key,
    :RSAPublicKey,
    Record.extract(:RSAPublicKey, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  defp generate_rsa_key(keysize, e) do
    private_key = :public_key.generate_key({:rsa, keysize, e})
    {:ok, private_key}
  rescue
    FunctionClauseError ->
      {:error, :not_supported}
  end

  defp extract_public_key(rsa_private_key(modulus: m, publicExponent: e)) do
    rsa_public_key(modulus: m, publicExponent: e)
  end

  # Certificates

  Record.defrecordp(
    :otp_tbs_certificate,
    :OTPTBSCertificate,
    Record.extract(:OTPTBSCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :signature_algorithm,
    :SignatureAlgorithm,
    Record.extract(:SignatureAlgorithm, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :validity,
    :Validity,
    Record.extract(:Validity, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :otp_subject_public_key_info,
    :OTPSubjectPublicKeyInfo,
    Record.extract(:OTPSubjectPublicKeyInfo, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :public_key_algorithm,
    :PublicKeyAlgorithm,
    Record.extract(:PublicKeyAlgorithm, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :extension,
    :Extension,
    Record.extract(:Extension, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :basic_constraints,
    :BasicConstraints,
    Record.extract(:BasicConstraints, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  Record.defrecordp(
    :attr,
    :AttributeTypeAndValue,
    Record.extract(:AttributeTypeAndValue, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
  )

  # OID values
  @rsaEncryption {1, 2, 840, 113_549, 1, 1, 1}
  @sha256WithRSAEncryption {1, 2, 840, 113_549, 1, 1, 11}

  @basicConstraints {2, 5, 29, 19}
  @keyUsage {2, 5, 29, 15}
  @extendedKeyUsage {2, 5, 29, 37}
  @subjectKeyIdentifier {2, 5, 29, 14}
  @subjectAlternativeName {2, 5, 29, 17}

  @organizationName {2, 5, 4, 10}
  @commonName {2, 5, 4, 3}

  @serverAuth {1, 3, 6, 1, 5, 5, 7, 3, 1}
  @clientAuth {1, 3, 6, 1, 5, 5, 7, 3, 2}

  defp new_cert(public_key, common_name, hostnames) do
    <<serial::unsigned-64>> = :crypto.strong_rand_bytes(8)

    # Dates must be in 'YYMMDD' format
    {{year, month, day}, _} =
      :erlang.timestamp()
      |> :calendar.now_to_datetime()

    yy = year |> Integer.to_string() |> String.slice(2, 2)
    mm = month |> Integer.to_string() |> String.pad_leading(2, "0")
    dd = day |> Integer.to_string() |> String.pad_leading(2, "0")

    not_before = yy <> mm <> dd

    yy2 = (year + 1) |> Integer.to_string() |> String.slice(2, 2)

    not_after = yy2 <> mm <> dd

    otp_tbs_certificate(
      version: :v3,
      serialNumber: serial,
      signature: signature_algorithm(algorithm: @sha256WithRSAEncryption, parameters: :NULL),
      issuer: rdn(common_name),
      validity:
        validity(
          notBefore: {:utcTime, '#{not_before}000000Z'},
          notAfter: {:utcTime, '#{not_after}000000Z'}
        ),
      subject: rdn(common_name),
      subjectPublicKeyInfo:
        otp_subject_public_key_info(
          algorithm: public_key_algorithm(algorithm: @rsaEncryption, parameters: :NULL),
          subjectPublicKey: public_key
        ),
      extensions: extensions(public_key, hostnames)
    )
  end

  defp rdn(common_name) do
    {:rdnSequence,
     [
       [attr(type: @organizationName, value: {:utf8String, "Phoenix Framework"})],
       [attr(type: @commonName, value: {:utf8String, common_name})]
     ]}
  end

  defp extensions(public_key, hostnames) do
    [
      extension(
        extnID: @basicConstraints,
        critical: true,
        extnValue: basic_constraints(cA: false)
      ),
      extension(
        extnID: @keyUsage,
        critical: true,
        extnValue: [:digitalSignature, :keyEncipherment]
      ),
      extension(
        extnID: @extendedKeyUsage,
        critical: false,
        extnValue: [@serverAuth, @clientAuth]
      ),
      extension(
        extnID: @subjectKeyIdentifier,
        critical: false,
        extnValue: key_identifier(public_key)
      ),
      extension(
        extnID: @subjectAlternativeName,
        critical: false,
        extnValue: Enum.map(hostnames, &{:dNSName, String.to_charlist(&1)})
      )
    ]
  end

  defp key_identifier(public_key) do
    :crypto.hash(:sha, :public_key.der_encode(:RSAPublicKey, public_key))
  end
end