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