From 19f5ad5411416fa804b4978dd29aef6d18168d29 Mon Sep 17 00:00:00 2001 From: Bram Verburg Date: Mon, 2 Jul 2018 14:36:52 +0300 Subject: [PATCH] add certificates, extensions, templates --- README.md | 38 ++- lib/x509/asn1.ex | 2 + lib/x509/certificate.ex | 352 +++++++++++++++++++++++ lib/x509/certificate/extension.ex | 276 ++++++++++++++++++ lib/x509/certificate/template.ex | 179 ++++++++++++ lib/x509/certificate/validity.ex | 86 ++++++ lib/x509/csr.ex | 20 +- mix.exs | 9 +- test/data/README.md | 9 + test/data/selfsigned_prime256v1.pem | 12 + test/data/selfsigned_rsa.pem | 20 ++ test/integration/openssl_test.exs | 76 +++++ test/x509/certificate/extension_test.exs | 4 + test/x509/certificate/template_test.exs | 4 + test/x509/certificate/validity_test.exs | 4 + test/x509/certificate_test.exs | 5 + 16 files changed, 1079 insertions(+), 17 deletions(-) create mode 100644 lib/x509/certificate.ex create mode 100644 lib/x509/certificate/extension.ex create mode 100644 lib/x509/certificate/template.ex create mode 100644 lib/x509/certificate/validity.ex create mode 100644 test/data/selfsigned_prime256v1.pem create mode 100644 test/data/selfsigned_rsa.pem create mode 100644 test/x509/certificate/extension_test.exs create mode 100644 test/x509/certificate/template_test.exs create mode 100644 test/x509/certificate/validity_test.exs create mode 100644 test/x509/certificate_test.exs diff --git a/README.md b/README.md index 44bc7ed..86f1291 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,40 @@ Elixir package for working with certificates, CSRs and key pairs. Requires Erlang/OTP 20.1 or later. +## Usage + +Generate a self-signed CA certificate and private key: + +```elixir +iex> ca_key = X509.PrivateKey.new_ec(:secp256r1) +{:ECPrivateKey, ...} +iex> ca = X509.Certificate.self_signed(ca_key, +...> "/C=US/ST=CA/L=San Fransisco/O=Acme/CN=ECDSA Root CA", +...> template: :root_ca +...>) +{:Certificate, ...} +``` + +Use a CA certificate to issue a server certificate : + +```elixir +iex> my_key = X509.PrivateKey.new_ec(:secp256r1) +{:ECPrivateKey, ...} +iex> my_cert = my_key |> +...> X509.PublicKey.derive() |> +...> X509.Certificate.new( +...> "/C=US/ST=CA/L=San Fransisco/O=Acme/CN=Sample", +...> ca, ca_key, +...> extensions: [ +...> subject_alt_name: X509.Certificate.Extension.subject_alt_name(["example.org", "www.example.org"]) +...> ] +...> ) +{:Certificate, ...} +``` + ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `x509` to your list of dependencies in `mix.exs`: +Add `x509` to your list of dependencies in `mix.exs`: ```elixir def deps do @@ -19,6 +49,4 @@ def deps do end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at [https://hexdocs.pm/x509](https://hexdocs.pm/x509). +Documentation can be found at [https://hexdocs.pm/x509](https://hexdocs.pm/x509). diff --git a/lib/x509/asn1.ex b/lib/x509/asn1.ex index 97645e1..38b07ae 100644 --- a/lib/x509/asn1.ex +++ b/lib/x509/asn1.ex @@ -34,7 +34,9 @@ defmodule X509.ASN1 do certification_request_signature_algorithm: :CertificationRequest_signatureAlgorithm, # Certificates + certificate: :Certificate, otp_certificate: :OTPCertificate, + tbs_certificate: :TBSCertificate, otp_tbs_certificate: :OTPTBSCertificate, signature_algorithm: :SignatureAlgorithm, validity: :Validity, diff --git a/lib/x509/certificate.ex b/lib/x509/certificate.ex new file mode 100644 index 0000000..513ea48 --- /dev/null +++ b/lib/x509/certificate.ex @@ -0,0 +1,352 @@ +defmodule X509.Certificate do + @moduledoc """ + Module for issuing and working with X.509 certificates. + + For conversion to and from PEM or DER format, use the generic functions in + the `X509` module. + """ + + import X509.ASN1, except: [extension: 2] + + alias X509.{PublicKey, RDNSequence} + alias X509.Certificate.{Template, Validity, Extension} + + @typedoc """ + `:Certificate` record , as used in Erlang's `:public_key` module + """ + @opaque t :: X509.ASN1.record(:certificate) + + @version :v3 + + @doc """ + Issues a new certificate. + """ + @spec new( + X509.PublicKey.t(), + String.t() | X509.RDNSequence.t(), + t(), + X509.PrivateKey.t(), + Keyword.t() + ) :: t() + def new(public_key, subject_rdn, issuer, issuer_key, opts \\ []) do + template = + opts + |> Keyword.get(:template, :server) + |> Template.new(opts) + |> update_ski(public_key) + |> update_aki(issuer) + + algorithm = + template + |> Map.get(:hash) + |> sign_type(issuer_key) + + issuer_rdn = + case issuer do + certificate(tbsCertificate: tbs) -> + tbs + |> otp_tbs_certificate(:issuer) + |> :pubkey_cert_records.transform(:decode) + + otp_certificate(tbsCertificate: tbs) -> + otp_tbs_certificate(tbs, :issuer) + end + + public_key + |> new_otp_tbs_certificate(subject_rdn, issuer_rdn, algorithm, template) + |> :public_key.pkix_sign(issuer_key) + |> X509.from_der(:Certificate) + end + + @doc """ + Generates a new self-signed certificate. + """ + @spec self_signed( + X509.PrivateKey.t(), + String.t() | X509.RDNSequence.t(), + Keyword.t() + ) :: t() + def self_signed(private_key, subject_rdn, opts \\ []) do + public_key = PublicKey.derive(private_key) + + template = + opts + |> Keyword.get(:template, :server) + |> Template.new(opts) + |> update_ski(public_key) + |> update_aki(public_key) + + algorithm = + template + |> Map.get(:hash) + |> sign_type(private_key) + + public_key + |> new_otp_tbs_certificate(subject_rdn, subject_rdn, algorithm, template) + |> :public_key.pkix_sign(private_key) + |> X509.from_der(:Certificate) + end + + @doc """ + Returns the Subject field of a certificate. + """ + @spec subject(t()) :: X509.RDNSequence.t() + def subject(certificate(tbsCertificate: tbs)) do + tbs_certificate(tbs, :subject) + end + + def subject(otp_certificate(tbsCertificate: tbs)) do + otp_tbs_certificate(tbs, :subject) + end + + @doc """ + Returns the Issuer field of a certificate. + """ + @spec issuer(t()) :: X509.RDNSequence.t() + def issuer(certificate(tbsCertificate: tbs)) do + tbs_certificate(tbs, :issuer) + end + + def issuer(otp_certificate(tbsCertificate: tbs)) do + otp_tbs_certificate(tbs, :issuer) + end + + @doc """ + Returns the Validity of a certificate. + """ + @spec validity(t()) :: X509.Certificate.Validity.t() + def validity(certificate(tbsCertificate: tbs)) do + tbs_certificate(tbs, :validity) + end + + def validity(otp_certificate(tbsCertificate: tbs)) do + otp_tbs_certificate(tbs, :validity) + end + + @doc """ + Returns the public key embedded in a certificate. + """ + @spec public_key(t()) :: X509.PublicKey.t() + def public_key(certificate(tbsCertificate: tbs)) do + tbs + |> tbs_certificate(:subjectPublicKeyInfo) + |> PublicKey.unwrap() + end + + def public_key(otp_certificate(tbsCertificate: tbs)) do + tbs + |> otp_tbs_certificate(:subjectPublicKeyInfo) + |> PublicKey.unwrap() + end + + @doc """ + Returns the list of extensions included in a certificate. + """ + @spec extensions(t()) :: [X509.Certificate.Extension.t()] + def extensions(certificate(tbsCertificate: tbs)) do + tbs_certificate(tbs, :extensions) + end + + def extensions(otp_certificate(tbsCertificate: tbs)) do + otp_tbs_certificate(tbs, :extensions) + end + + @doc """ + Looks up the value of a specific extension in a certificate. + + The desired extension can be specified as an atom or an OID value. Returns + `nil` if the specified extension is not present in the certificate. + """ + @spec extension(t(), X509.Certificate.Extension.extension_id() | :public_key.oid()) :: + X509.Certificate.Extension.t() | nil + def extension(cert, extension_id) do + cert + |> extensions() + |> Extension.find(extension_id) + end + + defp new_otp_tbs_certificate(public_key, subject_rdn, issuer_rdn, algorithm, template) do + otp_tbs_certificate( + version: @version, + serialNumber: Map.get(template, :serial) || random_serial(8), + signature: algorithm, + issuer: + case issuer_rdn do + {:rdnSequence, _} -> issuer_rdn + name when is_binary(name) -> RDNSequence.new(name, :otp) + end, + validity: + case template.validity do + validity() = val -> val + days -> Validity.days_from_now(days) + end, + subject: + case subject_rdn do + {:rdnSequence, _} -> subject_rdn + name when is_binary(name) -> RDNSequence.new(name, :otp) + end, + subjectPublicKeyInfo: PublicKey.wrap(public_key, :OTPSubjectPublicKeyInfo), + extensions: + template.extensions + |> Keyword.values() + |> Enum.reject(&(&1 == false)) + ) + end + + # If the template includes the Subject Key Identifier extension, sets the + # value based on the given public key value + defp update_ski(template, public_key) do + Map.update!(template, :extensions, fn extentions -> + Keyword.update(extentions, :subject_key_identifier, false, fn + true -> Extension.subject_key_identifier(public_key) + false -> false + end) + end) + end + + # If the template includes the Authority Key Identifier extension, sets the + # value based on the issuer's SKI value (for plain certificate) + defp update_aki(template, certificate() = issuer) do + aki = + case extension(issuer, oid(:"id-ce-subjectKeyIdentifier")) do + nil -> + nil + + plain_ski -> + plain_ski + |> :pubkey_cert_records.transform(:decode) + |> extension(:extnValue) + end + + update_aki(template, aki) + end + + # If the template includes the Authority Key Identifier extension, sets the + # value based on the issuer's SKI value (for OTP certificate) + defp update_aki(template, otp_certificate() = issuer) do + aki = + case extension(issuer, oid(:"id-ce-subjectKeyIdentifier")) do + nil -> nil + extension(extnValue: id) -> id + end + + update_aki(template, aki) + end + + # If the template includes the Authority Key Identifier extension, sets it to + # the specified binary value + defp update_aki(template, aki) when is_binary(aki) do + Map.update!(template, :extensions, fn extensions -> + Keyword.update(extensions, :authority_key_identifier, false, fn + true -> Extension.authority_key_identifier(aki) + false -> false + end) + end) + end + + # No Authority Key Identifier value is available; disables the extension in + # the template + defp update_aki(template, nil) do + Map.update!(template, :extensions, fn extensions -> + Keyword.put(extensions, :authority_key_identifier, false) + end) + end + + # If the template includes the Authority Key Identifier extension, sets the + # value based on the given public key value + defp update_aki(template, public_key) do + Map.update!(template, :extensions, fn extensions -> + Keyword.update(extensions, :authority_key_identifier, false, fn + true -> Extension.authority_key_identifier(public_key) + false -> false + end) + end) + end + + # Returns a random serial number as an integer + defp random_serial(size) do + <> = :crypto.strong_rand_bytes(size) + i + end + + # Returns a :SignatureAlgorithm record for the given public key type and hash + # algorithm; this is essentially the reverse of + # `:public_key.pkix_sign_types/1` + defp sign_type(hash, rsa_private_key()) do + sign_type(hash, :rsa) + end + + defp sign_type(hash, ec_private_key()) do + sign_type(hash, :ecdsa) + end + + defp sign_type(:md5, :rsa) do + signature_algorithm( + algorithm: oid(:md5WithRSAEncryption), + parameters: null() + ) + end + + defp sign_type(:sha, :rsa) do + signature_algorithm( + algorithm: oid(:sha1WithRSAEncryption), + parameters: null() + ) + end + + defp sign_type(:sha224, :rsa) do + signature_algorithm( + algorithm: oid(:sha224WithRSAEncryption), + parameters: null() + ) + end + + defp sign_type(:sha256, :rsa) do + signature_algorithm( + algorithm: oid(:sha256WithRSAEncryption), + parameters: null() + ) + end + + defp sign_type(:sha384, :rsa) do + signature_algorithm( + algorithm: oid(:sha384WithRSAEncryption), + parameters: null() + ) + end + + defp sign_type(:sha512, :rsa) do + signature_algorithm( + algorithm: oid(:sha512WithRSAEncryption), + parameters: null() + ) + end + + defp sign_type(hash, :rsa) do + raise ArgumentError, "Unsupported hashing algorithm for RSA signing: #{inspect(hash)}" + end + + defp sign_type(:sha, :ecdsa) do + signature_algorithm(algorithm: oid(:"ecdsa-with-SHA1")) + end + + defp sign_type(:sha224, :ecdsa) do + signature_algorithm(algorithm: oid(:"ecdsa-with-SHA224")) + end + + defp sign_type(:sha256, :ecdsa) do + signature_algorithm(algorithm: oid(:"ecdsa-with-SHA256")) + end + + defp sign_type(:sha384, :ecdsa) do + signature_algorithm(algorithm: oid(:"ecdsa-with-SHA384")) + end + + defp sign_type(:sha512, :ecdsa) do + signature_algorithm(algorithm: oid(:"ecdsa-with-SHA512")) + end + + defp sign_type(hash, :ecdsa) do + raise ArgumentError, "Unsupported hashing algorithm for ECDSA signing: #{inspect(hash)}" + end +end diff --git a/lib/x509/certificate/extension.ex b/lib/x509/certificate/extension.ex new file mode 100644 index 0000000..1bbe932 --- /dev/null +++ b/lib/x509/certificate/extension.ex @@ -0,0 +1,276 @@ +defmodule X509.Certificate.Extension do + @moduledoc """ + Convenience functions for creating `:Extension` records for use in + certificates. + """ + + import X509.ASN1, except: [basic_constraints: 2, authority_key_identifier: 1] + + @typedoc "`:Extension` record, as used in Erlang's `:public_key` module" + @opaque t :: X509.ASN1.record(:extension) + + @type extension_id :: + :basic_constraints + | :key_usage + | :ext_key_usage + | :subject_key_identifier + | :authority_key_identifier + | :subject_alt_name + + @typedoc "Supported values in the key usage extension" + @type key_usage_value :: + :digitalSignature + | :nonRepudiation + | :keyEncipherment + | :dataEncipherment + | :keyAgreement + | :keyCertSign + | :cRLSign + | :encipherOnly + | :decipherOnly + + @typedoc """ + An entry for use in the subject alternate name extension. Strings are mapped + to DNSName values, tuples must contain values supported by Erlang's + `:public_key` module + """ + @type san_value :: String.t() | {atom(), charlist()} + + @doc """ + The basic constraints extension identifies whether the subject of the + certificate is a CA and the maximum depth of valid certification + paths that include this certificate. + + This extension is always marked as critical for CA certificates, and + non-criticial when CA is set to false. + + Examples: + + iex> X509.Certificate.Extension.basic_constraints(false) + {:Extension, {2, 5, 29, 19}, false, + {:BasicConstraints, false, :asn1_NOVALUE}} + + iex> X509.Certificate.Extension.basic_constraints(true, 0) + {:Extension, {2, 5, 29, 19}, true, {:BasicConstraints, true, 0}} + """ + @spec basic_constraints(boolean, integer | :asn1_NOVALUE) :: t() + def basic_constraints(ca, path_len_constraint \\ :asn1_NOVALUE) + + def basic_constraints(false, :asn1_NOVALUE) do + extension( + extnID: oid(:"id-ce-basicConstraints"), + critical: false, + extnValue: X509.ASN1.basic_constraints(cA: false, pathLenConstraint: :asn1_NOVALUE) + ) + end + + def basic_constraints(true, path_len_constraint) do + extension( + extnID: oid(:"id-ce-basicConstraints"), + critical: true, + extnValue: X509.ASN1.basic_constraints(cA: true, pathLenConstraint: path_len_constraint) + ) + end + + @doc """ + The key usage extension defines the purpose (e.g., encipherment, + signature, certificate signing) of the key contained in the + certificate. + + Each of the key usage values must be one of the atoms recognized by Erlang's + `:public_key` module, though this is not verified by this function. + + This extension is always marked as critical. + + Example: + + iex> X509.Certificate.Extension.key_usage([:digitalSignature, :keyEncipherment]) + {:Extension, {2, 5, 29, 15}, true, [:digitalSignature, :keyEncipherment]} + """ + @spec key_usage([key_usage_value()]) :: t() + def key_usage(list) do + extension( + extnID: oid(:"id-ce-keyUsage"), + critical: true, + extnValue: list + ) + end + + @doc """ + This extension indicates one or more purposes for which the certified + public key may be used, in addition to or in place of the basic + purposes indicated in the key usage extension. In general, this + extension will appear only in end entity certificates. + + Each of the values in the list must be an OID, either in raw tuple format or + as an atom representing a well-known OID. Typical examples include: + + * `:serverAuth` - TLS WWW server authentication + * `:clientAuth` - TLS WWW client authentication + * `:codeSigning` - Signing of downloadable executable code + * `:emailProtection` - Email protection + * `:timeStamping` - Binding the hash of an object to a time + * `:ocspSigning` - Signing OCSP responses + + This extension is marked as non-critical. + + Example: + + iex> X509.Certificate.Extension.ext_key_usage([:serverAuth, :clientAuth]) + {:Extension, {2, 5, 29, 37}, false, + [{1, 3, 6, 1, 5, 5, 7, 3, 1}, {1, 3, 6, 1, 5, 5, 7, 3, 2}]} + """ + @spec ext_key_usage([:atom | :public_key.oid()]) :: t() + def ext_key_usage(list) do + extension( + extnID: oid(:"id-ce-extKeyUsage"), + critical: false, + extnValue: Enum.map(list, &ext_key_usage_oid/1) + ) + end + + defp ext_key_usage_oid(:any), do: oid(:anyExtendedKeyUsage) + defp ext_key_usage_oid(:serverAuth), do: oid(:"id-kp-serverAuth") + defp ext_key_usage_oid(:clientAuth), do: oid(:"id-kp-clientAuth") + defp ext_key_usage_oid(:codeSigning), do: oid(:"id-kp-codeSigning") + defp ext_key_usage_oid(:emailProtection), do: oid(:"id-kp-emailProtection") + defp ext_key_usage_oid(:timeStamping), do: oid(:"id-kp-timeStamping") + defp ext_key_usage_oid(:ocspSigning), do: oid(:"id-kp-OCSPSigning") + defp ext_key_usage_oid(:OCSPSigning), do: oid(:"id-kp-OCSPSigning") + + defp ext_key_usage_oid(oid) when is_tuple(oid), do: oid + + @doc """ + The subject key identifier extension provides a means of identifying + certificates that contain a particular public key. + + The value should be a public key record or a pre-calculated binary SHA-1 + value. + + This extension is marked as non-critical. + + Example: + + iex> X509.Certificate.Extension.subject_key_identifier({:RSAPublicKey, 55, 3}) + {:Extension, {2, 5, 29, 14}, false, + <<187, 230, 143, 92, 27, 37, 166, 93, 176, 137, 154, 111, 62, 152, + 215, 114, 3, 214, 71, 170>>} + """ + @spec subject_key_identifier(X509.PublicKey.t() | binary()) :: t() + def subject_key_identifier(rsa_public_key() = public_key) do + :crypto.hash(:sha, X509.PublicKey.to_der(public_key)) + |> subject_key_identifier() + end + + def subject_key_identifier({ec_point(), _parameters} = public_key) do + :crypto.hash(:sha, X509.PublicKey.to_der(public_key)) + |> authority_key_identifier() + end + + def subject_key_identifier(id) when is_binary(id) do + extension( + extnID: oid(:"id-ce-subjectKeyIdentifier"), + critical: false, + extnValue: id + ) + end + + @doc """ + The authority key identifier extension provides a means of identifying the + public key corresponding to the private key used to sign a certificate. + + The value should be a public key record. It is possible to pass a + pre-calculated SHA-1 value, though it is preferred to let the function + calculate the correct value over the original public key. + + This extension is marked as non-critical. + + Example: + + iex> X509.Certificate.Extension.authority_key_identifier({:RSAPublicKey, 55, 3}) + {:Extension, {2, 5, 29, 35}, false, + {:AuthorityKeyIdentifier, + <<187, 230, 143, 92, 27, 37, 166, 93, 176, 137, 154, 111, 62, 152, + 215, 114, 3, 214, 71, 170>>, :asn1_NOVALUE, :asn1_NOVALUE}} + """ + @spec authority_key_identifier(X509.PublicKey.t() | binary()) :: t() + def authority_key_identifier(rsa_public_key() = public_key) do + :crypto.hash(:sha, X509.PublicKey.to_der(public_key)) + |> authority_key_identifier() + end + + def authority_key_identifier({ec_point(), _parameters} = public_key) do + :crypto.hash(:sha, X509.PublicKey.to_der(public_key)) + |> authority_key_identifier() + end + + def authority_key_identifier(id) when is_binary(id) do + extension( + extnID: oid(:"id-ce-authorityKeyIdentifier"), + critical: false, + extnValue: X509.ASN1.authority_key_identifier(keyIdentifier: id) + ) + end + + @doc """ + The subject alternative name extension allows identities to be bound + to the subject of the certificate. These identities may be included + in addition to or in place of the identity in the subject field of + the certificate. Defined options include an Internet electronic mail + address, a DNS name, an IP address, and a Uniform Resource Identifier + (URI). + + Typically the subject alternative name extension is used to define the + DNS domains or hostnames for which a certificate is valid, so this + function maps string values to DNSName entries. Values of other types + can be passed in a type/value tuples as supported by Erlang's `:public_key` + module, if required. Note that Erlang will typically require the value + to be a character list. + + This extension is marked as non-critical. + + Example: + + iex> X509.Certificate.Extension.subject_alt_name(["www.example.com", "example.com"]) + {:Extension, {2, 5, 29, 17}, false, + [dNSName: 'www.example.com', dNSName: 'example.com']} + + iex> X509.Certificate.Extension.subject_alt_name(emailAddress: 'user@example.com') + {:Extension, {2, 5, 29, 17}, false, + [emailAddress: 'user@example.com']} + """ + @spec subject_alt_name([san_value()]) :: t() + def subject_alt_name(value) do + extension( + extnID: oid(:"id-ce-subjectAltName"), + critical: false, + extnValue: Enum.map(value, &san_entry/1) + ) + end + + # Prepare an entry for use in SubjectAlternateName: strings are mapped to + # DNSName entries, and {type, value} tuples are returned as-is + defp san_entry(dns_name) when is_binary(dns_name) do + {:dNSName, to_charlist(dns_name)} + end + + defp san_entry({_type, _value} = entry), do: entry + + @doc """ + Looks up the value of a specific extension in a list. + + The desired extension can be specified as an atom or an OID value. Returns + `nil` if the specified extension is not present in the certificate. + """ + @spec find([t()], extension_id() | :public_key.oid()) :: t() | nil + def find(list, :basic_constraints), do: find(list, oid(:"id-ce-basicConstraints")) + def find(list, :key_usage), do: find(list, oid(:"id-ce-keyUsage")) + def find(list, :ext_key_usage), do: find(list, oid(:"id-ce-extKeyUsage")) + def find(list, :subject_key_identifier), do: find(list, oid(:"id-ce-subjectKeyIdentifier")) + def find(list, :authority_key_identifier), do: find(list, oid(:"id-ce-authorityKeyIdentifier")) + def find(list, :subject_alt_name), do: find(list, oid(:"id-ce-subjectAltName")) + + def find(list, extension_oid) do + Enum.find(list, &match?(extension(extnID: ^extension_oid), &1)) + end +end diff --git a/lib/x509/certificate/template.ex b/lib/x509/certificate/template.ex new file mode 100644 index 0000000..69cdd8c --- /dev/null +++ b/lib/x509/certificate/template.ex @@ -0,0 +1,179 @@ +defmodule X509.Certificate.Template do + @moduledoc """ + Certificate templates. + """ + + import X509.Certificate.Extension + + defstruct serial: nil, validity: 365, hash: :sha256, extensions: [] + + @type t :: %__MODULE__{ + serial: pos_integer() | nil, + validity: pos_integer() | X509.Certificate.Validity.t(), + hash: atom(), + extensions: [{atom(), X509.Certificate.Extension.t() | boolean()}] + } + @type named_template :: :root_ca | :ca | :server + + @doc """ + Returns a template, optionally customized with user-provided validity, hash + and extensions options. + + The base template can be selected from a list of built-in named templates, + or as a custom template. The following named templates are supported: + + * `:root_ca` - intended for a self-signed root CA. + + The default path length constraint is set to 1, meaning the root CA can + be used to issue intermediate CAs, and those CAs can only sign end + certificates. The value can be overridden by passing a custom value + for the `:basic_constraints` extension. + + The default validity is 25 years. + + * `:ca` - intended for intermediate CA certificates. + + The default path length constraint is set to 0, meaning the CA can only + sign end certificates. The value can be overridden by passing a custom + value for the `:basic_constraints` extension (assuming the issuing CA + allows it). + + The Extended Key Usage extension is set to TLS server & client. Many + (but not all) TLS implementations will interpret this as a constraint + on the type of certificates the CA is allowed to issue. This constraint + can be removed by setting `:ext_key_usage` to `false`, or by overriding + the value to set the desired constraints. + + The default validity is 10 years. + + * `:server` - intended for end-certificates. + + The Extended Key Usage extension is set to TLS server & client. For other + types of end-certificates, set the `:ext_key_usage` extension to the + desired value. It may be necessary to update the `:key_usage` value as + well. + + The default validity is 1 year, plus a 30 day grace period. + + The `extensions` attribute of a template is a keyword list of extension + name/value pairs, where the value should typically be an + `X509.Certificate.Extension` record. The `subject_key_identifier` and + `authority_key_identifier` extensions may simply be set to `true`: the + actual values will be calculated during the certificate signing process. + + ## Options: + + * `:hash` - the hash algorithm to use when signing the certificate + * `:serial` - the serial number of the certificate; if set to `nil`, a + random serial number will be assigned + * `:validity` - override the validity period; can be specified as the + number of days (integer) or a `X509.Certificate.Validity` value + * `:extensions` - a keyword list of extentions to be merged into the + template's defaults; set an extension value to `false` to exclude that + extension from the certificate + + ## Examples: + + iex> X509.Certificate.Template.new(:root_ca, + ...> hash: :sha512, serial: 1, + ...> extensions: [authority_key_identifier: false] + ...> ) + %X509.Certificate.Template{ + extensions: [ + basic_constraints: {:Extension, {2, 5, 29, 19}, true, + {:BasicConstraints, true, 1}}, + key_usage: {:Extension, {2, 5, 29, 15}, true, + [:digitalSignature, :keyCertSign, :cRLSign]}, + subject_key_identifier: true, + authority_key_identifier: false + ], + hash: :sha512, + serial: 1, + validity: 9131 + } + + iex> X509.Certificate.Template.new(:server, extensions: [ + ...> ext_key_usage: X509.Certificate.Extension.ext_key_usage([:codeSigning]) + ...> ]) + %X509.Certificate.Template{ + extensions: [ + basic_constraints: {:Extension, {2, 5, 29, 19}, false, + {:BasicConstraints, false, :asn1_NOVALUE}}, + key_usage: {:Extension, {2, 5, 29, 15}, true, + [:digitalSignature, :keyEncipherment]}, + subject_key_identifier: true, + authority_key_identifier: true, + ext_key_usage: {:Extension, {2, 5, 29, 37}, false, + [{1, 3, 6, 1, 5, 5, 7, 3, 3}]} + ], + hash: :sha256, + serial: nil, + validity: 395 + } + + """ + @spec new(named_template() | t(), Keyword.t()) :: t() + def new(template, opts \\ []) + + def new(:root_ca, opts) do + %__MODULE__{ + # 25 years + validity: round(25 * 365.2425), + hash: :sha256, + extensions: [ + basic_constraints: basic_constraints(true, 1), + key_usage: key_usage([:digitalSignature, :keyCertSign, :cRLSign]), + subject_key_identifier: true, + authority_key_identifier: true + ] + } + |> new(opts) + end + + def new(:ca, opts) do + %__MODULE__{ + # 10 years + validity: round(10 * 365.2425), + hash: :sha256, + extensions: [ + basic_constraints: basic_constraints(true, 0), + key_usage: key_usage([:digitalSignature, :keyCertSign, :cRLSign]), + ext_key_usage: ext_key_usage([:serverAuth, :clientAuth]), + subject_key_identifier: true, + authority_key_identifier: true + ] + } + |> new(opts) + end + + def new(:server, opts) do + %__MODULE__{ + # 1 year, plus a 30 days grace period + validity: 365 + 30, + hash: :sha256, + extensions: [ + basic_constraints: basic_constraints(false), + key_usage: key_usage([:digitalSignature, :keyEncipherment]), + ext_key_usage: ext_key_usage([:serverAuth, :clientAuth]), + subject_key_identifier: true, + authority_key_identifier: true + ] + } + |> new(opts) + end + + def new(template, opts) do + override = + opts + |> Keyword.take([:hash, :serial, :validity]) + |> Enum.into(%{}) + + extensions = + template.extensions + |> Keyword.merge(Keyword.get(opts, :extensions, [])) + + template + |> Map.merge(override) + |> Map.put(:extensions, extensions) + end +end diff --git a/lib/x509/certificate/validity.ex b/lib/x509/certificate/validity.ex new file mode 100644 index 0000000..1c6e4dc --- /dev/null +++ b/lib/x509/certificate/validity.ex @@ -0,0 +1,86 @@ +defmodule X509.Certificate.Validity do + @moduledoc """ + Convenience functions for creating `:Validity` records for use in + certificates. The `:Validity` record represents the X.509 Validity + type, defining the validity of a certificate in terms of `notBefore` + and `notAfter` timestamps. + """ + + import X509.ASN1 + + @typedoc "X.509 Time type (UTCTime or GeneralizedTime)" + @type time :: {:utcTime | :generalizedTime, charlist()} + + @typedoc "`:Validity` record, as used in Erlang's `:public_key` module" + @opaque t :: X509.ASN1.record(:validity) + + @default_backdate_seconds 5 * 60 + @seconds_per_day 24 * 60 * 60 + + @doc """ + Creates a new `:Validity` record with the given start and end timestamps + in DateTime format. + + ## Examples: + + iex> {:ok, not_before, 0} = DateTime.from_iso8601("2018-01-01T00:00:00Z") + iex> {:ok, not_after, 0} = DateTime.from_iso8601("2018-12-31T23:59:59Z") + iex> X509.Certificate.Validity.new(not_before, not_after) + {:Validity, {:utcTime, '180101000000Z'}, {:utcTime, '181231235959Z'}} + + iex> {:ok, not_before, 0} = DateTime.from_iso8601("2051-01-01T00:00:00Z") + iex> {:ok, not_after, 0} = DateTime.from_iso8601("2051-12-31T23:59:59Z") + iex> X509.Certificate.Validity.new(not_before, not_after) + {:Validity, {:generalizedTime, '20510101000000Z'}, + {:generalizedTime, '20511231235959Z'}} + """ + @spec new(DateTime.t(), DateTime.t()) :: t() + def new(%DateTime{} = not_before, %DateTime{} = not_after) do + validity( + notBefore: to_asn1(not_before), + notAfter: to_asn1(not_after) + ) + end + + @doc """ + Creates a new `:Validity` record with an `notAfter` value a given number of + days in the future. The `notBefore` value can be backdated (by default + #{@default_backdate_seconds} seconds) to avoid newly issued certificates + from being rejected by peers due to poorly synchronized clocks. + + For CA certificates, consider using `new/2` instead, with a `not_before` + value that does not reveal the exact time when the keypair was generated. + This minimizes information leakage about the state of the RNG. + """ + @spec days_from_now(pos_integer(), non_neg_integer()) :: t() + def days_from_now(days, backdate_seconds \\ @default_backdate_seconds) do + not_before = + DateTime.utc_now() + |> shift(-backdate_seconds) + + not_after = shift(not_before, days * @seconds_per_day) + new(not_before, not_after) + end + + # Shifts a DateTime value by a number of seconds (positive or negative) + defp shift(datetime, seconds) do + datetime + |> DateTime.to_unix() + |> Kernel.+(seconds) + |> DateTime.from_unix!() + end + + # Converts a DateTime value to ASN.1 UTCTime (for years prior to 2050) or + # GeneralizedTime (for years starting with 2050) + defp to_asn1(%DateTime{year: year} = datetime) when year < 2050 do + iso = DateTime.to_iso8601(datetime, :basic) + [_, date, time] = Regex.run(~r/^\d\d(\d{6})T(\d{6})Z$/, iso) + {:utcTime, '#{date}#{time}Z'} + end + + defp to_asn1(datetime) do + iso = DateTime.to_iso8601(datetime, :basic) + [_, date, time] = Regex.run(~r/^(\d{8})T(\d{6})Z$/, iso) + {:generalizedTime, '#{date}#{time}Z'} + end +end diff --git a/lib/x509/csr.ex b/lib/x509/csr.ex index bce6c64..50d63e8 100644 --- a/lib/x509/csr.ex +++ b/lib/x509/csr.ex @@ -38,17 +38,7 @@ defmodule X509.CSR do def new(private_key, subject, opts \\ []) do hash = Keyword.get(opts, :hash, :sha256) - algorithm = - case private_key do - rsa_private_key() -> - sign_type(hash, :rsa) - - ec_private_key() -> - sign_type(hash, :ecdsa) - - private_key -> - raise ArgumentError, "Invalid private key: #{inspect(private_key)}" - end + algorithm = sign_type(hash, private_key) # Convert subject to RDNSequence, if necessary subject_rdn_sequence = @@ -122,6 +112,14 @@ defmodule X509.CSR do # Returns a :CertificationRequest_signatureAlgorithm record for the given # public key type and hash algorithm; this is essentially the reverse # of `:public_key.pkix_sign_types/1` + defp sign_type(hash, rsa_private_key()) do + sign_type(hash, :rsa) + end + + defp sign_type(hash, ec_private_key()) do + sign_type(hash, :ecdsa) + end + defp sign_type(:md5, :rsa) do certification_request_signature_algorithm( algorithm: oid(:md5WithRSAEncryption), diff --git a/mix.exs b/mix.exs index 59c982e..2506688 100644 --- a/mix.exs +++ b/mix.exs @@ -11,7 +11,7 @@ defmodule X509.MixProject do name: "X509", description: description(), package: package(), - docs: [main: X509], + docs: docs(), source_url: "https://github.com/voltone/x509" ] end @@ -43,4 +43,11 @@ defmodule X509.MixProject do links: %{"GitHub" => "https://github.com/voltone/x509"} ] end + + defp docs do + [ + main: "readme", + extras: ["README.md"] + ] + end end diff --git a/test/data/README.md b/test/data/README.md index ab0d2ee..36d3d30 100644 --- a/test/data/README.md +++ b/test/data/README.md @@ -67,3 +67,12 @@ Generating DER output: openssl req -in csr_rsa.pem -out csr_rsa.der -outform der openssl req -in csr_prime256v1.pem -out csr_prime256v1.der -outform der ``` + +## Certificates + +Generating PEM output: + +```bash +openssl req -new -key rsa.pem -days 365 -x509 -subj "/C=US/ST=NT/L=Springfield/O=ACME Inc." -out selfsigned_rsa.pem +openssl req -new -key prime256v1.pem -days 365 -x509 -subj "/C=US/ST=NT/L=Springfield/O=ACME Inc." -out selfsigned_prime256v1.pem +``` diff --git a/test/data/selfsigned_prime256v1.pem b/test/data/selfsigned_prime256v1.pem new file mode 100644 index 0000000..d6cffd0 --- /dev/null +++ b/test/data/selfsigned_prime256v1.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBzzCCAXWgAwIBAgIJAN8u3sxFLdMNMAoGCCqGSM49BAMCMEQxCzAJBgNVBAYT +AlVTMQswCQYDVQQIDAJOVDEUMBIGA1UEBwwLU3ByaW5nZmllbGQxEjAQBgNVBAoM +CUFDTUUgSW5jLjAeFw0xODA3MDIwODQ2NDBaFw0xOTA3MDIwODQ2NDBaMEQxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJOVDEUMBIGA1UEBwwLU3ByaW5nZmllbGQxEjAQ +BgNVBAoMCUFDTUUgSW5jLjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABE7BYN40 +yzqTcgv71JHnN0aGV7Q6rAZrmXvZqeEa3HoWuZi0WZ3P1nkDHxKzwwwfRT32N8BR +vfPwg1gEZjVe8PKjUDBOMB0GA1UdDgQWBBRuiDhYHHcngIlbhqh183s4cbmeLTAf +BgNVHSMEGDAWgBRuiDhYHHcngIlbhqh183s4cbmeLTAMBgNVHRMEBTADAQH/MAoG +CCqGSM49BAMCA0gAMEUCIH9seuUNX20Y13p5t8VzkgRcxYlFdHfctAqOxK0VrZ5o +AiEAtGvLoBEAHIMXTZ1sxYLdt8yZOSsf4BvlBSxHIAu6yps= +-----END CERTIFICATE----- diff --git a/test/data/selfsigned_rsa.pem b/test/data/selfsigned_rsa.pem new file mode 100644 index 0000000..8fb4655 --- /dev/null +++ b/test/data/selfsigned_rsa.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWzCCAkOgAwIBAgIJAMkQAnfEwQMXMA0GCSqGSIb3DQEBCwUAMEQxCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJOVDEUMBIGA1UEBwwLU3ByaW5nZmllbGQxEjAQBgNV +BAoMCUFDTUUgSW5jLjAeFw0xODA3MDIwODQwMTlaFw0xOTA3MDIwODQwMTlaMEQx +CzAJBgNVBAYTAlVTMQswCQYDVQQIDAJOVDEUMBIGA1UEBwwLU3ByaW5nZmllbGQx +EjAQBgNVBAoMCUFDTUUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALRycCT/4fLFJpJxhBSp369dlW7RDNnHcN68VAuY+fhxRSULevSjuVBEu+if +KwlzKDRw+pcnz7+VOQEuQxdx1vD42YA+W7zKFSAzByblfE76rWz6eMo1dUVzi0JB +c2J9aIf4kkTM6Ryb1uzH/G3IjIHQtOv5tepGfgzYjUoccNLL6Sr4q/QbGGlAcrTd +AfU5PoNlgcmSUYfcdiJ/1lNiPBqg3vaXltNHdWS1rZlijsn9c9x4q8rSfn5ENwmY +O4mlll/jSLzrM0D9dnkz5+FsoAd/IxR/ze1brOr3CCksMi8TdQtIq9aj0qDsHk3L +O7EvmKO8YX4RNvyNN1glWVcWImUCAwEAAaNQME4wHQYDVR0OBBYEFD3axjtleRba +n9/BiqSvmWMQE9F3MB8GA1UdIwQYMBaAFD3axjtleRban9/BiqSvmWMQE9F3MAwG +A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADC9q30URe6B92rFRyFOlr2+ +Jth12oZF5yVYCt+ukNIAB97gVW3X6WX6uiZA+i3UohCcjwfmttptxyLQbL3Kn4w4 +4NR9xoos/w8OTswbLhPCmZjfGXEXKI45ymZZ4fj+l6wbr+OA1/SnWjM+S3gu3aLY +oFk/WE+HneXNLoVcPHzMHAxz1pkwCDnehrqUfiROzooKQV3YyTRCfRruWlLGRqmW +tVSczho3w/0OdLexZmRB2l/6cbfU91YCf9IqPx+06+HUdGjkRIOqPnHXGHJ+ZHG3 +knduhxdyun/srtbekGDjA7J7nPiwssQdhbRLQl9JVu0YQ2QsYVVlWiDqLLcQGW4= +-----END CERTIFICATE----- diff --git a/test/integration/openssl_test.exs b/test/integration/openssl_test.exs index c2d5d20..14e13a0 100644 --- a/test/integration/openssl_test.exs +++ b/test/integration/openssl_test.exs @@ -78,6 +78,44 @@ defmodule X509.OpenSSLTest do assert openssl_out =~ "Public Key Algorithm: id-ecPublicKey" assert openssl_out =~ "Signature Algorithm: ecdsa-with-SHA256" end + + test "OpenSSL can read certificates (RSA)" do + pem_file = + X509.PrivateKey.new_rsa(2048) + |> X509.Certificate.self_signed( + "/C=US/ST=NT/L=Springfield/O=ACME Inc.", + extensions: [ + subject_alt_name: + X509.Certificate.Extension.subject_alt_name(["acme.com", "www.acme.com"]) + ] + ) + |> write_tmp_pem() + + openssl_out = openssl(["x509", "-in", pem_file, "-text", "-noout"]) + assert openssl_out =~ "Subject: C=US, ST=NT, L=Springfield, O=ACME Inc." + assert openssl_out =~ "Public Key Algorithm: rsaEncryption" + assert openssl_out =~ "Signature Algorithm: sha256WithRSAEncryption" + assert openssl_out =~ "DNS:acme.com, DNS:www.acme.com" + end + + test "OpenSSL can read certificates (ECDSA)" do + pem_file = + X509.PrivateKey.new_ec(:secp256r1) + |> X509.Certificate.self_signed( + "/C=US/ST=NT/L=Springfield/O=ACME Inc.", + extensions: [ + subject_alte_name: + X509.Certificate.Extension.subject_alt_name(["acme.com", "www.acme.com"]) + ] + ) + |> write_tmp_pem() + + openssl_out = openssl(["x509", "-in", pem_file, "-text", "-noout"]) + assert openssl_out =~ "Subject: C=US, ST=NT, L=Springfield, O=ACME Inc." + assert openssl_out =~ "Public Key Algorithm: id-ecPublicKey" + assert openssl_out =~ "Signature Algorithm: ecdsa-with-SHA256" + assert openssl_out =~ "DNS:acme.com, DNS:www.acme.com" + end end describe "DER encode" do @@ -150,6 +188,44 @@ defmodule X509.OpenSSLTest do assert openssl_out =~ "Public Key Algorithm: id-ecPublicKey" assert openssl_out =~ "Signature Algorithm: ecdsa-with-SHA256" end + + test "OpenSSL can read certificates (RSA)" do + pem_file = + X509.PrivateKey.new_rsa(2048) + |> X509.Certificate.self_signed( + "/C=US/ST=NT/L=Springfield/O=ACME Inc.", + extensions: [ + subject_alt_name: + X509.Certificate.Extension.subject_alt_name(["acme.com", "www.acme.com"]) + ] + ) + |> write_tmp_der() + + openssl_out = openssl(["x509", "-in", pem_file, "-inform", "der", "-text", "-noout"]) + assert openssl_out =~ "Subject: C=US, ST=NT, L=Springfield, O=ACME Inc." + assert openssl_out =~ "Public Key Algorithm: rsaEncryption" + assert openssl_out =~ "Signature Algorithm: sha256WithRSAEncryption" + assert openssl_out =~ "DNS:acme.com, DNS:www.acme.com" + end + + test "OpenSSL can read certificates (ECDSA)" do + pem_file = + X509.PrivateKey.new_ec(:secp256r1) + |> X509.Certificate.self_signed( + "/C=US/ST=NT/L=Springfield/O=ACME Inc.", + extensions: [ + subject_alt_name: + X509.Certificate.Extension.subject_alt_name(["acme.com", "www.acme.com"]) + ] + ) + |> write_tmp_der() + + openssl_out = openssl(["x509", "-in", pem_file, "-inform", "der", "-text", "-noout"]) + assert openssl_out =~ "Subject: C=US, ST=NT, L=Springfield, O=ACME Inc." + assert openssl_out =~ "Public Key Algorithm: id-ecPublicKey" + assert openssl_out =~ "Signature Algorithm: ecdsa-with-SHA256" + assert openssl_out =~ "DNS:acme.com, DNS:www.acme.com" + end end defp openssl(args) do diff --git a/test/x509/certificate/extension_test.exs b/test/x509/certificate/extension_test.exs new file mode 100644 index 0000000..5749490 --- /dev/null +++ b/test/x509/certificate/extension_test.exs @@ -0,0 +1,4 @@ +defmodule X509.Certificate.ExtensionTest do + use ExUnit.Case + doctest X509.Certificate.Extension +end diff --git a/test/x509/certificate/template_test.exs b/test/x509/certificate/template_test.exs new file mode 100644 index 0000000..3c96dff --- /dev/null +++ b/test/x509/certificate/template_test.exs @@ -0,0 +1,4 @@ +defmodule X509.Certificate.TemplateTest do + use ExUnit.Case + doctest X509.Certificate.Template +end diff --git a/test/x509/certificate/validity_test.exs b/test/x509/certificate/validity_test.exs new file mode 100644 index 0000000..a4a818a --- /dev/null +++ b/test/x509/certificate/validity_test.exs @@ -0,0 +1,4 @@ +defmodule X509.Certificate.ValidityTest do + use ExUnit.Case + doctest X509.Certificate.Validity +end diff --git a/test/x509/certificate_test.exs b/test/x509/certificate_test.exs new file mode 100644 index 0000000..ed661dd --- /dev/null +++ b/test/x509/certificate_test.exs @@ -0,0 +1,5 @@ +defmodule X509.CertificateTest do + use ExUnit.Case + + doctest X509.Certificate +end