lib/x509/crl.ex

defmodule X509.CRL do
  @moduledoc """
  Module for generating and parsing Certificate Revocation Lists (CRLs).

  The corresponding ASN.1 data type, used in Erlang's `:public_key` module, is
  called `:CertificateList`.

  Please note that maintaining a CRL typically requires keeping state: the list
  of revoked certificates, along with their revocation date and expiry date
  (when they can be removed from the CRL), as well as the CRLs sequence number
  and the date/time of the next update. This module offers a purely functional
  interface for generating CRLs based on state kept by the caller.

  Delta CRLs are not currently supported.
  """

  import X509.ASN1, except: [extension: 2]

  alias X509.{Certificate, SignatureAlgorithm}
  alias X509.CRL.{Entry, Extension}

  @typedoc """
  `:CertificateList` record, as used in Erlang's `:public_key` module
  """
  @opaque t :: X509.ASN1.record(:CertificateList)

  @seconds_per_day 24 * 60 * 60
  @next_update_days 30

  @doc """
  Returns a new `:CertificateList` record for the specified CRL entries.

  The first argument is a, possibly empty, list of CRL entries. Use
  `X509.CRL.Entry.new/3` to create a CRL entry for a given certificate.

  The second and third argument are the issuing certificate and the associated
  private key. The certificate must include the `:cRLSign` key usage.

  ## Options:

  * `:hash` - the hashing algorithm to use when signing the CRL (default:
    `:sha256`)
  * `:this_update` - a `DateTime` struct specifying the timestamp of the CRL
    update (default: the current time)
  * `:next_update` - a `DateTime` struct specifying the timestamp of next
    scheduled CRL update (default: see `:next_update_in_days`)
  * `:next_update_in_days` - if no `:next_update` timestamp is specified, this
    parameter defines the number of days in the future the next CRL update is
    expected (default: #{@next_update_days})
  * `:extensions` - a keyword list of extension names and values; by default
    the `authority_key_identifier` extension will be included, with a value
    derived from the issuer's `subject_key_identifier` (if present); to disable
    this extension, specify `authority_key_identifier: false`; other extension
    values will be included in the CRL as-is
  """
  # @doc since: "0.5.0"
  @spec new([Entry.t()], Certificate.t(), X509.PrivateKey.t(), Keyword.t()) :: t()
  def new(revoked, issuer, issuer_key, opts \\ []) do
    hash = Keyword.get(opts, :hash, :sha256)
    algorithm = SignatureAlgorithm.new(hash, issuer_key, :AlgorithmIdentifier)

    this_update =
      opts
      |> Keyword.get(:this_update, DateTime.utc_now())
      |> X509.DateTime.new()

    next_update =
      case Keyword.get(opts, :next_update) do
        nil ->
          days = Keyword.get(opts, :next_update_in_days, @next_update_days)
          X509.DateTime.new(days * @seconds_per_day)

        date ->
          X509.DateTime.new(date)
      end

    crl_extensions =
      opts
      |> Keyword.get(:extensions, [])
      |> Keyword.put_new(:authority_key_identifier, true)
      |> Keyword.update!(:authority_key_identifier, &update_aki(&1, issuer))
      |> Keyword.values()
      |> Enum.reject(&(&1 == false))
      |> Enum.map(&encode_extension/1)

    # FIXME: avoid calls to undocumented functions in :public_key app
    tbs =
      tbs_cert_list(
        version: :v2,
        signature: algorithm,
        issuer: issuer |> Certificate.subject() |> :pubkey_cert_records.transform(:encode),
        thisUpdate: this_update,
        nextUpdate: next_update,
        revokedCertificates: revoked_certificates(revoked),
        crlExtensions: crl_extensions
      )

    tbs_der = :public_key.der_encode(:TBSCertList, tbs)

    certificate_list(
      tbsCertList: tbs,
      signatureAlgorithm: algorithm,
      signature: :public_key.sign(tbs_der, hash, issuer_key)
    )
  end

  @doc """
  Verifies whether a CRL matches the given issuer certificate and has a valid
  signature.
  """
  # @doc since: "0.5.0"
  @spec valid?(t(), X509.Certificate.t()) :: boolean()
  def valid?(crl, issuer) do
    :public_key.pkix_is_issuer(crl, issuer) and :public_key.pkix_crl_verify(crl, issuer)
  end

  @doc """
  Returns the list of CRL entries included in a CRL.
  """
  # @doc since: "0.5.0"
  @spec list(t()) :: [X509.CRL.Entry.t()]
  def list(certificate_list(tbsCertList: tbs)) do
    case tbs_cert_list(tbs, :revokedCertificates) do
      :asn1_NOVALUE -> []
      list -> list
    end
  end

  @doc """
  Returns the Issuer field of the CRL.
  """
  # @doc since: "0.5.0"
  @spec issuer(t()) :: X509.RDNSequence.t()
  def issuer(certificate_list(tbsCertList: tbs)) do
    tbs
    |> tbs_cert_list(:issuer)
  end

  @doc """
  Returns the date and time when the CRL was issued.
  """
  # @doc since: "0.5.0"
  @spec this_update(t()) :: DateTime.t()
  def this_update(certificate_list(tbsCertList: tbs)) do
    tbs
    |> tbs_cert_list(:thisUpdate)
    |> X509.DateTime.to_datetime()
  end

  @doc """
  Returns the date and time when the next CRL update is expected.
  """
  # @doc since: "0.5.0"
  @spec next_update(t()) :: DateTime.t()
  def next_update(certificate_list(tbsCertList: tbs)) do
    tbs
    |> tbs_cert_list(:nextUpdate)
    |> X509.DateTime.to_datetime()
  end

  @doc """
  Converts a CRL to DER (binary) format.
  """
  # @doc since: "0.5.0"
  @spec to_der(t()) :: binary()
  def to_der(certificate_list() = crl) do
    :public_key.der_encode(:CertificateList, crl)
  end

  @doc """
  Converts a CRL to PEM format.
  """
  # @doc since: "0.5.0"
  @spec to_pem(t()) :: String.t()
  def to_pem(certificate_list() = crl) do
    :public_key.pem_entry_encode(:CertificateList, crl)
    |> List.wrap()
    |> :public_key.pem_encode()
  end

  @doc """
  Attempts to parse a CRL in DER (binary) format. Raises in case of failure.
  """
  # @doc since: "0.5.0"
  @spec from_der!(binary()) :: t() | no_return()
  def from_der!(der) do
    :public_key.der_decode(:CertificateList, der)
  end

  @doc """
  Parses a CRL in DER (binary) format.

  Returns an `:ok` tuple in case of success, or an `:error` tuple in case of
  failure. Possible error reasons are:

    * `:malformed` - the data could not be decoded as a CRL
  """
  # @doc since: "0.5.0"
  @spec from_der(binary()) :: {:ok, t()} | {:error, :malformed}
  def from_der(der) do
    {:ok, from_der!(der)}
  rescue
    MatchError -> {:error, :malformed}
  end

  @doc """
  Attempts to parse a CRL in PEM format. Raises in case of failure.

  Processes the first PEM entry of type X509 CRL found in the input.
  """
  # @doc since: "0.5.0"
  @spec from_pem!(String.t()) :: t() | no_return()
  def from_pem!(pem) do
    {:ok, csr} = from_pem(pem)
    csr
  end

  @doc """
  Parses a CRL in PEM format.

  Processes the first PEM entry of type X509 CRL found in the input. Returns an
  `:ok` tuple in case of success, or an `:error` tuple in case of failure.
  Possible error reasons are:

    * `:not_found` - no PEM entry of type X509 CRL was found
    * `:malformed` - the entry could not be decoded as a CRL
  """
  # @doc since: "0.5.0"
  @spec from_pem(String.t()) :: {:ok, t()} | {:error, :malformed | :not_found}
  def from_pem(pem) do
    pem
    |> :public_key.pem_decode()
    |> Enum.find(&match?({:CertificateList, _, :not_encrypted}, &1))
    |> case do
      nil -> {:error, :not_found}
      {:CertificateList, der, :not_encrypted} -> from_der(der)
    end
  end

  @doc """
  Returns the list of extensions included in a CRL.
  """
  # @doc since: "0.5.0"
  @spec extensions(t()) :: [X509.CRL.Extension.t()]
  def extensions(certificate_list(tbsCertList: tbs)) do
    tbs_cert_list(tbs, :crlExtensions)
  end

  @doc """
  Looks up the value of a specific extension in a CRL.

  The desired extension can be specified as an atom or an OID value. Returns
  `nil` if the specified extension is not present in the CRL.
  """
  # @doc since: "0.5.0"
  @spec extension(
          t(),
          X509.CRL.Extension.extension_id()
          | :public_key.oid()
        ) :: X509.CRL.Extension.t() | nil
  def extension(crl, extension_id) do
    crl
    |> extensions()
    |> Extension.find(extension_id)
  end

  defp revoked_certificates([]), do: :asn1_NOVALUE
  defp revoked_certificates(list) when is_list(list), do: list

  defp update_aki(false, _), do: false

  defp update_aki(true, issuer) do
    case X509.Certificate.extension(issuer, :subject_key_identifier) do
      nil ->
        false

      extension(extnValue: id) ->
        X509.Certificate.Extension.authority_key_identifier(id)
    end
  end

  defp update_aki(id, _) when is_binary(id) do
    X509.Certificate.Extension.authority_key_identifier(id)
  end

  defp update_aki(extension(extnID: oid(:"id-ce-authorityKeyIdentifier")) = aki, _) do
    aki
  end

  # Certificate extension generated by x509.Certificate.Extension are intended
  # for use in OTPCertificate records, and are therefore not DER encoded; in
  # CertificateList records (CRLs) the extension value must be DER encoded,
  # line in Certificate records
  defp encode_extension(extension(extnValue: der) = ext) when is_binary(der), do: ext

  defp encode_extension(extension(extnValue: record) = ext) when is_tuple(record) do
    type = elem(record, 0)
    der = :public_key.der_encode(type, record)
    X509.ASN1.extension(ext, extnValue: der)
  end
end