lib/openagents/reputation/signing_key.ex

58e6347eeb72 · 4 KB

defmodule OpenAgents.Reputation.SigningKey do
  @moduledoc """
  One admitted attestation issuer key.

  The row carries the public key only. A private key stays in runtime
  configuration (RELEASE-002), so reading every key a verifier needs never
  grants the authority to mint an attestation.
  """
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]

  @algorithms ~w(ed25519)
  @max_future_skew_seconds 300

  schema "reputation_signing_keys" do
    field :key_id, :string
    field :algorithm, :string
    field :public_key, :string
    field :issuer, :string
    field :activated_at, :utc_datetime_usec
    field :retired_at, :utc_datetime_usec
    timestamps()
  end

  @type t :: %__MODULE__{}

  def algorithms, do: @algorithms

  @doc "How far into the future a `retired_at` may sit, to absorb clock skew."
  def max_future_skew_seconds, do: @max_future_skew_seconds

  def changeset(record, attributes) do
    record
    |> cast(attributes, ~w(key_id algorithm public_key issuer activated_at retired_at)a)
    |> validate_required(~w(key_id algorithm public_key issuer activated_at)a)
    |> validate_inclusion(:algorithm, @algorithms)
    |> validate_format(:key_id, ~r/\A[0-9a-f]{64}\z/)
    |> validate_format(:public_key, ~r/\A[0-9a-f]{64}\z/)
    |> validate_length(:issuer, min: 1, max: 128)
    |> unique_constraint(:key_id)
    |> unique_constraint(:public_key)
  end

  @doc """
  Retires the key at `retired_at`, refusing a timestamp that would rewrite
  history.

  `active_at?/2` reads the half-open window `[activated_at, retired_at)`, so
  a `retired_at` at or before an attestation's `attested_at` flips that
  already-signed attestation to unverified without touching its row. The
  changeset therefore refuses a `retired_at`:

    * at or before the newest attestation the key signed
      (`:latest_attested_at`, queried by the caller), naming the conflicting
      attestation time in the error;
    * earlier than the key's own `activated_at` when it signed nothing —
      `activated_at` rather than `inserted_at`, because `activated_at` is the
      required field that opens the window `active_at?/2` reads, while
      `inserted_at` is only the row's insertion time;
    * more than #{@max_future_skew_seconds} seconds in the future, so a
      retirement records when the key actually stopped rather than a claim
      about a time that has not happened.

  There is deliberately no override: an operator who needs an earlier
  boundary is asking to unverify signatures that were already published as
  valid, and #191 exists because that must be a refusal, not an option.
  """
  def retire_changeset(record, retired_at, opts \\ []) do
    record
    |> cast(%{retired_at: retired_at}, ~w(retired_at)a)
    |> validate_required(~w(retired_at)a)
    |> validate_not_future()
    |> validate_after_signed_history(Keyword.get(opts, :latest_attested_at))
  end

  defp validate_not_future(changeset) do
    horizon = DateTime.add(DateTime.utc_now(), @max_future_skew_seconds, :second)

    validate_change(changeset, :retired_at, fn :retired_at, retired_at ->
      if DateTime.compare(retired_at, horizon) == :gt do
        [
          retired_at: "cannot sit more than #{@max_future_skew_seconds} seconds in the future"
        ]
      else
        []
      end
    end)
  end

  defp validate_after_signed_history(changeset, %DateTime{} = latest_attested_at) do
    validate_change(changeset, :retired_at, fn :retired_at, retired_at ->
      if DateTime.compare(retired_at, latest_attested_at) == :gt do
        []
      else
        [
          retired_at:
            "cannot be at or before the newest attestation this key signed " <>
              "(attested at #{DateTime.to_iso8601(latest_attested_at)})"
        ]
      end
    end)
  end

  defp validate_after_signed_history(changeset, nil) do
    activated_at = changeset.data.activated_at

    validate_change(changeset, :retired_at, fn :retired_at, retired_at ->
      if is_nil(activated_at) or DateTime.compare(retired_at, activated_at) != :lt do
        []
      else
        [
          retired_at:
            "cannot be earlier than the key's activation " <>
              "(activated at #{DateTime.to_iso8601(activated_at)})"
        ]
      end
    end)
  end

  @doc "Whether the key was admitted and not yet retired at `instant`."
  @spec active_at?(t(), DateTime.t()) :: boolean()
  def active_at?(%__MODULE__{} = key, %DateTime{} = instant) do
    DateTime.compare(instant, key.activated_at) != :lt and
      (is_nil(key.retired_at) or DateTime.compare(instant, key.retired_at) == :lt)
  end
end