lib/openagents/reputation/claim.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Reputation.Claim do
  @moduledoc """
  The canonical attestation claim: the exact object a signature covers.

  A claim binds the issuer key, the subject, the accepted outcome, the
  repository scope, the revision, the artifact digest, the verifier policy,
  the confidence, the evidence, and a nonce. Changing any of those changes the
  claim digest, so one attestation cannot be presented for another issue,
  revision, verifier, or actor.

  Signing and verification use Ed25519 through `:crypto`, over the canonical
  JSON encoding from `OpenAgents.Provenance.Canonical`.
  """

  alias OpenAgents.Provenance.Canonical

  @schema "openagents.reputation.attestation.v1"
  @algorithm "ed25519"

  @type claim :: %{optional(String.t()) => term()}

  def schema, do: @schema
  def algorithm, do: @algorithm

  @doc "A fresh Ed25519 keypair. The private key is never persisted."
  @spec generate_keypair() :: %{key_id: String.t(), public_key: String.t(), private_key: binary()}
  def generate_keypair do
    {public_key, private_key} = :crypto.generate_key(:eddsa, :ed25519)

    %{
      key_id: key_id(public_key),
      public_key: Base.encode16(public_key, case: :lower),
      private_key: private_key
    }
  end

  @doc "The public key that `private_key` signs for, hex encoded."
  @spec public_key_for(binary()) :: String.t()
  def public_key_for(private_key) when is_binary(private_key) do
    {public_key, _private_key} = :crypto.generate_key(:eddsa, :ed25519, private_key)
    Base.encode16(public_key, case: :lower)
  end

  @doc "The key identifier for a raw or hex encoded public key."
  @spec key_id(binary()) :: String.t()
  def key_id(public_key) when is_binary(public_key) do
    public_key |> decode_key() |> Canonical.sha256()
  end

  @doc """
  The canonical claim for `attributes`.

  A `private` transparency tier keeps references out of the signed object:
  the claim carries each reference's kind, digest, and observation time, and
  the outcome's digest without its receipt reference. The published claim
  stays complete and verifiable and discloses nothing about the work.
  """
  @spec build(map()) :: claim()
  def build(attributes) do
    %{
      "schema" => @schema,
      "event_type" => attributes.event_type,
      "issuer" => %{
        "key_id" => attributes.issuer_key_id,
        "algorithm" => @algorithm,
        "public_key" => attributes.issuer_public_key
      },
      "subject" => %{"actor_id" => attributes.subject_id},
      "outcome" => %{
        "kind" => attributes.outcome_kind,
        "ref" => if(disclosed?(attributes.transparency_tier), do: attributes.outcome_ref),
        "digest" => attributes.outcome_digest,
        "state" => attributes.outcome_state
      },
      "scope" => %{
        "repository" => attributes.repository,
        "repository_id" => attributes.repository_id,
        "issue_number" => attributes.issue_number,
        "revision" => attributes.revision,
        "artifact_digest" => attributes.artifact_digest
      },
      "verifier" => %{
        "policy_id" => attributes.policy_id,
        "policy_version" => attributes.policy_version,
        "policy_digest" => attributes.policy_digest
      },
      "confidence_ppm" => attributes.confidence_ppm,
      "transparency_tier" => attributes.transparency_tier,
      "evidence" => evidence(attributes.evidence, attributes.transparency_tier),
      "attested_at" => DateTime.to_iso8601(attributes.attested_at),
      "nonce" => attributes.nonce,
      "supersedes" => attributes[:supersedes_digest]
    }
  end

  @doc "The canonical digest of `claim`."
  @spec digest(claim()) :: {:ok, String.t()} | {:error, term()}
  def digest(claim), do: Canonical.digest(claim)

  @doc "Signs the canonical encoding of `claim` with an Ed25519 private key."
  @spec sign(claim(), binary()) :: {:ok, String.t()} | {:error, term()}
  def sign(claim, private_key) when is_binary(private_key) do
    with {:ok, encoded} <- Canonical.encode(claim) do
      signature = :crypto.sign(:eddsa, :none, encoded, [private_key, :ed25519])
      {:ok, Base.encode16(signature, case: :lower)}
    end
  end

  @doc "Whether `signature` covers `claim` under `public_key`."
  @spec valid_signature?(claim(), String.t(), String.t()) :: boolean()
  def valid_signature?(claim, signature, public_key) do
    with {:ok, encoded} <- Canonical.encode(claim),
         {:ok, raw_signature} <- Base.decode16(signature, case: :mixed) do
      :crypto.verify(:eddsa, :none, encoded, raw_signature, [decode_key(public_key), :ed25519])
    else
      _other -> false
    end
  rescue
    ErlangError -> false
  end

  @doc "A fresh claim nonce."
  @spec nonce() :: String.t()
  def nonce, do: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)

  defp evidence(entries, tier) do
    entries
    |> Enum.map(&entry(&1, tier))
    |> Enum.sort_by(& &1["digest"])
  end

  defp entry(entry, tier) do
    entry = Map.new(entry, fn {key, value} -> {to_string(key), value} end)
    disclosed? = disclosed?(tier)

    %{
      "kind" => entry["kind"],
      "ref" => if(disclosed?, do: entry["ref"]),
      "digest" => entry["digest"],
      "observed_at" => entry["observed_at"],
      "disclosed" => disclosed?
    }
  end

  defp disclosed?(tier), do: tier != "private"

  defp decode_key(key) when byte_size(key) == 64 do
    case Base.decode16(key, case: :mixed) do
      {:ok, raw} -> raw
      :error -> key
    end
  end

  defp decode_key(key), do: key
end