lib/wax/metadata.ex

defmodule Wax.Metadata do
  require Logger
  use GenServer

  @fido_alliance_root_cer_der """
                              -----BEGIN CERTIFICATE-----
                              MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4G
                              A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNp
                              Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4
                              MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEG
                              A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
                              hvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8
                              RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsT
                              gHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmm
                              KPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zd
                              QQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZ
                              XriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAw
                              DgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+o
                              LkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZU
                              RUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMp
                              jjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK
                              6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQX
                              mcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecs
                              Mx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpH
                              WD9f
                              -----END CERTIFICATE-----
                              """
                              |> X509.Certificate.from_pem!()
                              |> X509.Certificate.to_der()

  @mdsv3_key {__MODULE__, :mdsv3}
  @local_key {__MODULE__, :local}

  @typedoc """
  A metadata statement

  For instance:

  ```
  %{
    "aaguid" => "2c0df832-92de-4be1-8412-88a8f074df4a",
    "attachmentHint" => ["external", "wireless", "nfc"],
    "attestationRootCertificates" => ["MIIB2DCCAX6gAwIBAgIQGBUrQbdDrm20FZnDsX2CBTAKBggqhkjOPQQDAjBLMQswCQYDVQQGEwJVUzEdMBsGA1UECgwURmVpdGlhbiBUZWNobm9sb2dpZXMxHTAbBgNVBAMMFEZlaXRpYW4gRklETyBSb290IENBMCAXDTE4MDQwMTAwMDAwMFoYDzIwNDgwMzMxMjM1OTU5WjBLMQswCQYDVQQGEwJVUzEdMBsGA1UECgwURmVpdGlhbiBUZWNobm9sb2dpZXMxHTAbBgNVBAMMFEZlaXRpYW4gRklETyBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsFYEEhiJuqqnMgQjSiivBjV7DGCTf4XBBH/B7uvZsKxXShF0L8uDISWUvcExixRs6gB3oldSrjox6L8T94NOzqNCMEAwHQYDVR0OBBYEFEu9hyYRrRyJzwRYvnDSCIxrFiO3MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0gAMEUCIDHSb2mbNDAUNXvpPU0oWKeNye0fQ2l9D01AR2+sLZdhAiEAo3wz684IFMVsCCRmuJqxH6FQRESNqezuo1E+KkGxWuM=",
     "MIIBfjCCASWgAwIBAgIBATAKBggqhkjOPQQDAjAXMRUwEwYDVQQDDAxGVCBGSURPIDAyMDAwIBcNMTYwNTAxMDAwMDAwWhgPMjA1MDA1MDEwMDAwMDBaMBcxFTATBgNVBAMMDEZUIEZJRE8gMDIwMDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNBmrRqVOxztTJVN19vtdqcL7tKQeol2nnM2/yYgvksZnr50SKbVgIEkzHQVOu80LVEE3lVheO1HjggxAlT6o4WjYDBeMB0GA1UdDgQWBBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAfBgNVHSMEGDAWgBRJFWQt1bvG3jM6XgmV/IcjNtO/CzAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNHADBEAiAwfPqgIWIUB+QBBaVGsdHy0s5RMxlkzpSX/zSyTZmUpQIgB2wJ6nZRM8oX/nA43Rh6SJovM2XwCCH//+LirBAbB0M=",
     "MIIB2DCCAX6gAwIBAgIQFZ97ws2JGPEoa5NI+p8z1jAKBggqhkjOPQQDAjBLMQswCQYDVQQGEwJDTjEdMBsGA1UECgwURmVpdGlhbiBUZWNobm9sb2dpZXMxHTAbBgNVBAMMFEZlaXRpYW4gRklETyBSb290IENBMCAXDTE4MDQwMTAwMDAwMFoYDzIwNDgwMzMxMjM1OTU5WjBLMQswCQYDVQQGEwJDTjEdMBsGA1UECgwURmVpdGlhbiBUZWNobm9sb2dpZXMxHTAbBgNVBAMMFEZlaXRpYW4gRklETyBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnfAKbjvMX1Ey1b6k+WQQdNVMt9JgGWyJ3PvM4BSK5XqTfo++0oAj/4tnwyIL0HFBR9St+ktjqSXDfjiXAurs86NCMEAwHQYDVR0OBBYEFNGhmE2Bf8O5a/YHZ71QEv6QRfFUMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0gAMEUCIQC3sT1lBjGeF+xKTpzV1KYU2ckahTd4mLJyzYOhaHv4igIgD2JYkfyH5Q4Bpo8rroO0It7oYjF2kgy/eSZ3U9Glaqw="],
    "attestationTypes" => ["basic_full"],
    "authenticationAlgorithms" => ["secp256r1_ecdsa_sha256_raw"],
    "authenticatorGetInfo" => %{
      "aaguid" => "2c0df83292de4be1841288a8f074df4a",
      "algorithms" => [%{"alg" => -7, "type" => "public-key"}],
      "extensions" => ["credProtect", "hmac-secret"],
      "maxCredentialCountInList" => 6,
      "maxCredentialIdLength" => 96,
      ...
    },
    "authenticatorVersion" => 1,
    "cryptoStrength" => 128,
    "description" => "Feitian FIDO Smart Card",
    "icon" => "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAAUCAMAAAAtBkrlAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAABHZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDE0IDc5LjE1Njc5NywgMjAxNC8wOC8yMC0wOTo1MzowMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE0IChNYWNpbnRvc2gpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAxNi0xMi0zMFQxNDozMzowOCswODowMCIgeG1wOk1vZGlmeURhdGU9IjIwMTYtMTItMzBUMDc6MzE6NTkrMDg6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTYtMTItMzBUMDc6MzE6NTkrMDg6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6SGlzdG9yeT0iMjAxNi0xMi0zMFQxNTozMDoyNyswODowMCYjeDk75paH5Lu2IOacquagh+mimC0xIOW3suaJk+W8gCYjeEE7IiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjJFNzFCRkZDQzY3RjExRTY5NzhEQTlDQkI2NDYzRjkwIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjJFNzFCRkZEQzY3RjExRTY5NzhEQTlDQkI2NDYzRjkwIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MkU3MUJGRkFDNjdGMTFFNjk3OERBOUNCQjY0NjNGOTAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MkU3MUJGRkJDNjdGMTFFNjk3OERBOUNCQjY0NjNGOTAiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz477JXFAAAAYFBMVEX///8EVqIXZavG2OoqcLG2zOOkwt0BSJtqlcXV4u+autlWhbzk7PUAMY9HcrKjtNbq8feAl8aBoszz9vpdjsGGqtF3n8uTsNSZpc6JsNT5+v0xYKnu8Pff5/L48fg/friczJgYAAADAElEQVR42kRUCZbDIAjFXZOY1TatNc39bzksSYc3r4ME4fMBAaD6zl8y/9TOget8d5jfN78bwM/dDCRpR521zXfojHJ05IIyhBAUSVAONdGzBYt2f7KFrfkJaAkHh9FZhcDXHRkTKo9MLihGaavImnV3qyEX0Eprgz/4DwUD7kCHRnd8QFN43Go4UVmDDgza4w27oizdA2+cK+uuUpjjo2+xwc/42W50x5LGYeDBsR0HVIx5x8iF60CblbTEEkFr27bNDBUVSq1OKVPbE62b3EH8FqBg5OOOEuc2t8ZJiqMOuGp+cKjg7wVGceozqN4pxgVPQkjFYgbVJKDUhDCjYrawP5q4ETgC9fIMRHtitpQcCvJOELcbMsQgnciRkljpyQjvG44jqBUETFiBi1PEIyekOzsW+Ty5cLHos5R+dMS1LtSSxf3gQHczR2CI4gMNpW4IRA1QMa6tJ4+C6uHuGE8mNDIyFqg/OP/MMUueS6Iq8S90dAeBJSEy/qKkK+BNwz8cYY4jb5J6u4iWCI2B1Z56LW5kEc4hkdMpsvUC5585SX0QubcgNqyfgDFEcTt+40/0S5Nx0waCw3OKkcObA5In0AYp01pjjw2n626UDjtHwa28iHuTKqtrv+reW41NZ6iGlr7uuLJCfkFtctcG04sgm1eNS+ZaDnpaTErGoyX5JK2iMz8xs0nOwWGcPDN49qaCd4bzJozDZm/aBK+EozLw+XhNBiYwHf0siOu1XPkG/zKwvqYKcfSwDEcH/oUe07es/WQ8rIyg2DOXj8tjkZduDB/b8hzDllMMOCS5BEnd534f8ti3UZc4kMs3xLyafMSsJhdG8XPqjNk5tAgO25feKChnVdDj/J0FMkOsU/xMBv0wFhYeEGfVH13fuDU0yDFLa4fc7RnWHBfuTFV2tEmNwadc7ac3UY2jfBl7HT36fe34iQO5mNCFFBW07KjPgqhOLU01vZ8PueZ2JClFZN8jkUs69uka9ePp6+EfL4AF5+NywSbirHtcB8Ml/gkwAEjkK64KjHPeAAAAAElFTkSuQmCC",
    "keyProtection" => [...],
    # ...
  }
  ```
  """
  @type statement :: %{optional(String.t()) => any()}

  # client API

  @doc false
  def start_link(_) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  @doc """
  Returns the metadata associated to an AAGUID

  The `aaguid` parameter is the raw form of the AAGUID, for example
  `<<44, 13, 248, 50, 146, 222, 75, 225, 132, 18, 136, 168, 240, 116, 223, 74>>`
  and not the base-16 encoded form such as
  `"2c0df832-92de-4be1-8412-88a8f074df4a"`.

  If the metadata is not found, `{:error, %Wax.MetadataStatementNotFoundError{}}`
  is returned.

  If a challenge is passed as the second parameter, this function verifies that
  the status of the authenticator is accepted (by default, non-certified and
  revoked authenticator are refused). If the authenticator status is not accepted,
  `{:error, %Wax.AuthenticatorStatusNotAcceptableError{}}` is returned.
  """
  @spec get_by_aaguid(binary(), Wax.Challenge.t() | nil) ::
          {:ok, statement()} | {:error, Exception.t()}
  def get_by_aaguid(aaguid_bin, challenge \\ nil) do
    ensure_loaded()

    <<
      a::binary-size(8),
      b::binary-size(4),
      c::binary-size(4),
      d::binary-size(4),
      e::binary-size(12)
    >> = Base.encode16(aaguid_bin, case: :lower)

    aaguid_str = a <> "-" <> b <> "-" <> c <> "-" <> d <> "-" <> e

    [:persistent_term.get(@mdsv3_key, []), :persistent_term.get(@local_key, [])]
    |> List.flatten()
    |> Enum.find(fn
      %{"aaguid" => ^aaguid_str} ->
        true

      _ ->
        false
    end)
    |> check_metadata_validity_and_return(challenge)
  end

  @doc """
  Returns the metadata associated to an attestation certificate key identifier (ACKI)

  The `acki` parameter is the raw form of the ACKI, for example
  `<<138, 39, 205, 218, 234, 197, 118, 90, 141, 238, 146, 165, 237, 73, 131, 217, 56, 165, 234, 105>>`
  and not the base-16 encoded form such as
  `"8a27cddaeac5765a8dee92a5ed4983d938a5ea69"`.

  If the metadata is not found, `{:error, %Wax.MetadataStatementNotFoundError{}}`
  is returned.

  If a challenge is passed as the second parameter, this function verifies that
  the status of the authenticator is accepted (by default, non-certified and
  revoked authenticator are refused). If the authenticator status is not accepted,
  `{:error, %Wax.AuthenticatorStatusNotAcceptableError{}}` is returned.
  """
  @spec get_by_acki(binary(), Wax.Challenge.t() | nil) ::
          {:ok, statement()} | {:error, Exception.t()}
  def get_by_acki(acki_bin, challenge \\ nil) do
    ensure_loaded()

    acki_str = Base.encode16(acki_bin, case: :lower)

    [:persistent_term.get(@mdsv3_key, []), :persistent_term.get(@local_key, [])]
    |> List.flatten()
    |> Enum.find(fn
      %{"attestationCertificateKeyIdentifiers" => ackis} ->
        acki_str in ackis

      _ ->
        false
    end)
    |> check_metadata_validity_and_return(challenge)
  end

  # from MDSv3
  defp check_metadata_validity_and_return(
         %{"statusReports" => _} = metadata,
         %Wax.Challenge{} = challenge
       ) do
    # TODO: handle invalidation at the batch key level
    # https://groups.google.com/u/1/a/fidoalliance.org/g/fido-dev/c/4SJQEtQZm9s

    maybe_latest_status =
      (metadata["statusReports"] || [])
      |> Enum.reject(&(&1["status"] == "UPDATE_AVAILABLE"))
      |> Enum.sort(&compare_status_report_dates/2)
      |> List.last()
      |> case do
        %{"status" => status} ->
          status

        nil ->
          nil
      end

    if maybe_latest_status in challenge.acceptable_authenticator_statuses do
      {:ok, metadata["metadataStatement"]}
    else
      {:error, %Wax.AuthenticatorStatusNotAcceptableError{status: maybe_latest_status}}
    end
  end

  defp check_metadata_validity_and_return(%{"statusReports" => _} = metadata, nil) do
    {:ok, metadata["metadataStatement"]}
  end

  # from local loaded file
  defp check_metadata_validity_and_return(%{} = metadata, _challenge) do
    {:ok, metadata}
  end

  defp check_metadata_validity_and_return(nil, _challenge) do
    {:error, %Wax.MetadataStatementNotFoundError{}}
  end

  defp ensure_loaded() do
    GenServer.call(__MODULE__, :ping, :infinity)
  end

  defp compare_status_report_dates(status_1, status_2) do
    status_1_date = status_1["effectiveDate"]
    status_2_date = status_2["effectiveDate"]

    # Entry with no date is considered most recent
    cond do
      is_nil(status_1_date) and is_nil(status_2_date) -> true
      is_nil(status_1_date) -> false
      is_nil(status_2_date) -> true
      true -> status_1_date <= status_2_date
    end
  end

  # server callbacks

  @impl true
  def init(_state) do
    {:ok, %{last_modified: nil, version_number: -1}, {:continue, :update_metadata}}
  end

  @impl true
  def handle_continue(:update_metadata, state) do
    load_from_dir()
    state = update_metadata(state)

    schedule_update()

    Process.send(self(), :update_from_file, [])

    {:noreply, state}
  end

  @impl true
  def handle_call(:ping, _from, state) do
    {:reply, :pong, state}
  end

  @impl true
  def handle_info(:update_metadata, state) do
    state = update_metadata(state)

    schedule_update()

    {:noreply, state}
  end

  def handle_info(_reason, state) do
    {:noreply, state}
  end

  defp schedule_update() do
    Process.send_after(
      self(),
      :update_metadata,
      Application.get_env(:wax_, :metadata_update_interval, 3600) * 1000
    )
  end

  defp update_metadata(state) do
    if Application.get_env(:wax_, :update_metadata) do
      do_update_metadata(state)
    else
      state
    end
  end

  defp do_update_metadata(state) do
    certs = :public_key.cacerts_get()

    ssl_opts = [
      cacerts: certs,
      verify: :verify_peer,
      customize_hostname_check: [
        match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
      ],
      crl_check: :best_effort,
      crl_cache: {:ssl_crl_cache, {:internal, [http: 1000]}}
    ]

    headers =
      if state[:last_modified] do
        [{~c"if-modified-since", state[:last_modified]}]
      else
        []
      end

    :httpc.request(:get, {"https://mds.fidoalliance.org", headers}, [ssl: ssl_opts], [])
    |> case do
      {:ok, {{_, 200, _}, headers, body}} ->
        version_number =
          body
          |> :erlang.list_to_binary()
          |> process_and_save_metadata(state)

        last_modified = last_modified(headers)

        %{state | last_modified: last_modified, version_number: version_number}

      {:ok, {{_, 304, _}, _headers, _body}} ->
        state

      error ->
        Logger.info("#{__MODULE__}: failed to download MDSv3 metadata, error: #{inspect(error)}")

        state
    end
  end

  defp process_and_save_metadata(jws, state) do
    case Wax.Utils.JWS.verify_with_x5c(jws, @fido_alliance_root_cer_der) do
      {:ok, metadata} ->
        if metadata["no"] > state.version_number do
          :persistent_term.put(@mdsv3_key, metadata["entries"])
        end

        metadata["no"]

      {:error, reason} ->
        Logger.info("Failed to verify FIDO MDSV3 metadata (reason: #{inspect(reason)})")

        -1
    end
  end

  defp last_modified([]) do
    nil
  end

  defp last_modified([{header_name, header_value} | rest]) do
    header_name
    |> :erlang.list_to_binary()
    |> String.downcase()
    |> case do
      "last-modified" ->
        header_value

      _ ->
        last_modified(rest)
    end
  end

  @doc """
  Forces reload of metadata statements from configured directory
  """
  @spec load_from_dir() :: [statement()]
  def load_from_dir() do
    files =
      case Application.get_env(:wax_, :metadata_dir, nil) do
        nil ->
          []

        app when is_atom(app) ->
          app
          |> :code.priv_dir()
          |> List.to_string()
          |> Kernel.<>("/fido2_metadata/*")
          |> Path.wildcard()

        path when is_binary(path) ->
          Path.wildcard(path <> "/*")
      end

    statements = for file <- files, do: file |> File.read!() |> Jason.decode!()

    :persistent_term.put(@local_key, statements)

    statements
  end
end