5.8 KiB · text History b868907
defmodule GitGud.Packages.Signing do
  @moduledoc """
  Where a registry's signing key lives, and who performs the signature.

  Until now there was one answer: `GitGud.Packages.SigningKey` holds an
  AES-GCM-encrypted private key in Postgres, and the app decrypts it to
  sign. That is fine for apt — an OpenPGP key signing an index nobody
  installs from without `apt-key` already trusting it — and it is the
  default here still.

  It is a worse answer for Android. F-Droid's own guidance is blunt:

  > the repo signing keys should not ever be on a public server

  A forge that serves its own index cannot follow that literally, but it
  can stop *holding* the key. This behaviour is the seam that allows it:
  `sign/4` takes bytes and returns a signature, and an implementation is
  free to never possess key material at all.

  ## Backends

    * `GitGud.Packages.Signing.Local` — the existing behaviour. Private
      key in Postgres, encrypted at rest, decrypted to sign.
    * `GitGud.Packages.Signing.OpenBao` — the key is generated inside
      OpenBao's transit engine as non-exportable and never leaves it.
      The forge sends bytes and gets a signature back, so compromising
      the app yields the ability to sign *while the compromise lasts*
      rather than a key that outlives it.

  ## Configuration

  The default is `Local`, so nothing changes for an existing
  deployment. A format opts in:

      config :git_gud, GitGud.Packages.Signing,
        default: GitGud.Packages.Signing.Local,
        formats: %{"fdroid" => GitGud.Packages.Signing.OpenBao}

  **OpenBao is only ever required by the formats that name it.** An
  instance that doesn't serve the F-Droid registry never talks to it,
  never needs it deployed, and fails no health check for its absence.
  That is deliberate: it is one more thing to run, and it should be
  one more thing to run only for the people who need the property it
  provides.

  ## Scope

  A `scope` is the namespace a key belongs to — a `%User{}` or
  `%Organization{}`, the same unit `Packages.signing_key/2` keys on. One
  key per (namespace, format), minted on first use.
  """

  alias GitGud.Packages.Signing.Local
  alias GitGud.Repositories.Repository

  @typedoc "The namespace a key belongs to."
  @type scope :: struct()

  @typedoc """
  Options for `sign/4`:

    * `:digest` — `:sha256` (default) or `:sha1`. F-Droid's index-v1 is
      `SHA1withRSA` and index-v2 is `SHA256withRSA`, so both are real.
    * `:scheme` — `:pkcs1v15` (default) or `:pss`. CMS wants v1.5.
  """
  @type sign_opts :: [digest: :sha256 | :sha1, scheme: :pkcs1v15 | :pss]

  @doc """
  Ensure a key exists for `scope` and `format`, returning its public
  half as PEM. Idempotent — this is what mints one on first use.
  """
  @callback ensure_key(scope(), String.t()) ::
              {:ok, %{public_pem: String.t()}} | {:error, term()}

  @doc "The public half of an existing key, without creating one."
  @callback public_key(scope(), String.t()) :: {:ok, String.t()} | {:error, term()}

  @doc """
  Sign `data` — the raw bytes, not a digest. Hashing is the backend's
  business, because OpenBao would rather do it itself than be handed
  something it has to take on trust.
  """
  @callback sign(scope(), String.t(), binary(), sign_opts()) ::
              {:ok, binary()} | {:error, term()}

  @doc """
  The backend for `format`.

  A format with no entry gets `:default`, and `:default` with no entry
  gets `Local` — so adding a format never silently changes where an
  existing key lives.
  """
  @spec backend(String.t()) :: module()
  def backend(format) when is_binary(format) do
    config = Application.get_env(:git_gud, __MODULE__, [])
    formats = Keyword.get(config, :formats, %{})

    Map.get(formats, format) || Keyword.get(config, :default, Local)
  end

  @spec ensure_key(scope(), String.t()) :: {:ok, %{public_pem: String.t()}} | {:error, term()}
  def ensure_key(scope, format),
    do: backend(format).ensure_key(namespace(scope), format)

  @spec public_key(scope(), String.t()) :: {:ok, String.t()} | {:error, term()}
  def public_key(scope, format),
    do: backend(format).public_key(namespace(scope), format)

  @spec sign(scope(), String.t(), binary(), sign_opts()) :: {:ok, binary()} | {:error, term()}
  def sign(scope, format, data, opts \\ []),
    do: backend(format).sign(namespace(scope), format, data, opts)

  @doc """
  The namespace a scope's key belongs to.

  A repository resolves to the user or organization that owns it, so
  `pkg/fdroid/acme/tools` and `pkg/fdroid/acme/_all` are signed by the
  same key — one fingerprint per publisher, which is what a phone
  pins. `Packages.signing_key/2` has always resolved this way; this
  makes every backend see a namespace rather than each having to
  remember to.
  """
  @spec namespace(scope()) :: scope()
  def namespace(%Repository{} = repo), do: GitGud.Packages.namespace(repo)
  def namespace(scope), do: scope

  @doc """
  The atom `:crypto` and `:public_key` know a digest by.

  They spell SHA-1 `:sha`, with no `1`. This behaviour's option is
  `:sha1`, because that is what the formats using it call it —
  F-Droid's index-v1 is `SHA1withRSA` — and because `:sha` reads like a
  typo next to `:sha256`. One translation here beats every call site
  remembering, and getting it wrong is a crash at signing time rather
  than a compile error.
  """
  @spec erlang_digest(:sha256 | :sha1) :: :sha256 | :sha
  def erlang_digest(:sha1), do: :sha
  def erlang_digest(other), do: other

  @doc """
  Whether `format` signs through something other than the local key
  store. The packages UI says so on the page, because "this index is
  signed by a key this server cannot read" is worth stating out loud.
  """
  @spec external?(String.t()) :: boolean()
  def external?(format), do: backend(format) != Local
end