8.3 KiB · text History bc4a319
defmodule GitGud.Packages.Signing.OpenBao do
  @moduledoc """
  Signing through [OpenBao](https://openbao.org)'s transit engine, so
  the forge never holds the private key.

  The key is created inside OpenBao as **non-exportable** — there is no
  API that returns it, not to the app and not to an operator. The forge
  sends bytes to `/v1/<mount>/sign/<key>` and gets a signature back. An
  attacker who owns the app can sign things for as long as they hold it;
  they cannot walk away with a key that keeps working afterwards, and
  every signature they ask for is in OpenBao's audit log.

  That is the property F-Droid's guidance is really after when it says
  the repo signing key shouldn't be on a public server. The key isn't.

  ## Why this is opt-in

  It is a second stateful service to run, unseal and back up. The only
  format that names it today is `fdroid`, and an instance that doesn't
  serve an F-Droid repository never contacts it. See
  `GitGud.Packages.Signing` for the configuration.

  ## Configuration

      config :git_gud, GitGud.Packages.Signing.OpenBao,
        address: "http://openbao.openbao.svc:8200",
        token: System.get_env("OPENBAO_TOKEN"),
        mount: "transit",
        key_type: "rsa-4096",
        key_prefix: "gitgud"

  Auth comes from `GitGud.Packages.Signing.OpenBao.Session`, which
  either logs in with the pod's ServiceAccount JWT (`:role` set, a
  short-lived token it renews itself) or carries the static token from
  a Kubernetes Secret, renewing that before its period lapses. Either
  way this module just asks for the current token.

  ## Key naming

  `<key_prefix>-<format>-<namespace handle>`, e.g. `gitgud-fdroid-neiam`.
  Flat, because transit keys have no hierarchy, and readable, because
  the audit log is the point.

  ## The signature wire format

  Transit returns `vault:v<n>:<base64>`. The version prefix is stripped
  here — the caller wants signature bytes, and a CMS structure has
  nowhere to put a key version. Rotation therefore needs care: a
  re-signed index is signed by whatever version is current, and
  `key_version` would have to be threaded through to pin it.
  """
  @behaviour GitGud.Packages.Signing

  alias GitGud.Repositories.Storage

  @default_mount "transit"
  @default_key_type "rsa-4096"
  @default_prefix "gitgud"

  @impl true
  def ensure_key(scope, format) do
    name = key_name(scope, format)

    # Transit's key creation is idempotent: creating one that exists is
    # a no-op rather than an error, so there is no read-then-create race
    # to lose.
    with {:ok, _} <- create_key(name),
         {:ok, pem} <- read_public_key(name) do
      {:ok, %{public_pem: pem}}
    end
  end

  @impl true
  def public_key(scope, format), do: scope |> key_name(format) |> read_public_key()

  @impl true
  def sign(scope, format, data, opts) when is_binary(data) do
    name = key_name(scope, format)

    body = %{
      "input" => Base.encode64(data),
      "hash_algorithm" => hash_algorithm(Keyword.get(opts, :digest, :sha256)),
      "signature_algorithm" => signature_algorithm(Keyword.get(opts, :scheme, :pkcs1v15)),
      # The raw bytes go up and OpenBao hashes them. Sending a digest
      # with `prehashed: true` also works, but `hash_algorithm: "none"`
      # does not: transit documents that combination as producing a
      # PKCSv1_5_NoOID signature, and CMS wants the DigestInfo-wrapped
      # PKCSv1_5_DERnull form.
      "prehashed" => false
    }

    with {:ok, %{"data" => %{"signature" => signature}}} <-
           request(:post, "/v1/#{mount()}/sign/#{name}", body) do
      decode_signature(signature)
    end
  end

  # ── transit calls ────────────────────────────────────────────────────

  defp create_key(name) do
    request(:post, "/v1/#{mount()}/keys/#{name}", %{
      "type" => config(:key_type, @default_key_type),
      # The whole point. Without this an operator could pull the key out
      # and the guarantee this module claims would be a comment.
      "exportable" => false,
      "allow_plaintext_backup" => false
    })
  end

  # `GET /v1/<mount>/keys/<name>` returns every version's public half
  # keyed by version number; the latest is the one to publish.
  defp read_public_key(name) do
    with {:ok, %{"data" => data}} <- request(:get, "/v1/#{mount()}/keys/#{name}", nil) do
      latest = data["latest_version"]
      keys = data["keys"] || %{}

      case keys[to_string(latest)] do
        %{"public_key" => pem} when is_binary(pem) -> {:ok, pem}
        _ -> {:error, {:openbao, "key #{name} has no public key at version #{latest}"}}
      end
    end
  end

  defp decode_signature("vault:v" <> rest) do
    case String.split(rest, ":", parts: 2) do
      [_version, encoded] -> decode_base64(encoded)
      _ -> {:error, {:openbao, "unparseable signature"}}
    end
  end

  # Not all forks keep the `vault:` prefix; accept a bare signature too
  # rather than failing on something that is perfectly usable.
  defp decode_signature(other) when is_binary(other), do: decode_base64(other)

  defp decode_base64(encoded) do
    case Base.decode64(encoded) do
      {:ok, signature} -> {:ok, signature}
      :error -> {:error, {:openbao, "signature was not base64"}}
    end
  end

  # ── http ─────────────────────────────────────────────────────────────

  defp request(method, path, body) do
    case address() do
      nil ->
        {:error, {:openbao, "no address configured — set :address on #{inspect(__MODULE__)}"}}

      address ->
        req =
          Req.new(
            method: method,
            url: address <> path,
            json: body,
            headers: [{"x-vault-token", token() || ""}],
            receive_timeout: 15_000,
            retry: :safe_transient
          )
          |> Req.merge(Application.get_env(:git_gud, :openbao_req_options, []))

        run(req, path)
    end
  end

  defp run(req, path) do
    case Req.request(req) do
      {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
        {:ok, body || %{}}

      # Creating a key that already exists, and a few other transit
      # no-ops, answer 204 with no body.
      {:ok, %Req.Response{status: 204}} ->
        {:ok, %{}}

      {:ok, %Req.Response{status: 403}} ->
        {:error, {:openbao, "denied by policy on #{path} — check the token's capabilities"}}

      {:ok, %Req.Response{status: 404}} ->
        {:error, {:openbao, "no such path #{path} — is the transit engine mounted?"}}

      {:ok, %Req.Response{status: 503}} ->
        {:error, {:openbao, "sealed or unavailable"}}

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, {:openbao, "HTTP #{status}: #{errors(body)}"}}

      {:error, reason} ->
        {:error, {:openbao, "unreachable: #{inspect(reason)}"}}
    end
  end

  defp errors(%{"errors" => [_ | _] = errors}), do: Enum.join(errors, "; ")
  defp errors(body) when is_binary(body), do: body
  defp errors(body), do: inspect(body)

  # ── naming + config ──────────────────────────────────────────────────

  @doc """
  The transit key name for a namespace and format. Public because the
  policy an operator writes has to name the same paths.
  """
  @spec key_name(struct(), String.t()) :: String.t()
  def key_name(scope, format) do
    handle = Storage.owner_handle(scope)
    "#{config(:key_prefix, @default_prefix)}-#{format}-#{handle}"
  end

  defp hash_algorithm(:sha1), do: "sha1"
  defp hash_algorithm(_sha256), do: "sha2-256"

  defp signature_algorithm(:pss), do: "pss"
  defp signature_algorithm(_pkcs1v15), do: "pkcs1v15"

  defp mount, do: config(:mount, @default_mount)
  defp address, do: config(:address, nil) |> trim_slash()
  # Not `config(:token, …)`: the session owns acquisition and renewal,
  # and with Kubernetes auth there is no configured token to read.
  defp token, do: GitGud.Packages.Signing.OpenBao.Session.token()

  defp trim_slash(nil), do: nil
  defp trim_slash(url), do: String.trim_trailing(url, "/")

  defp config(key, default) do
    Application.get_env(:git_gud, __MODULE__, []) |> Keyword.get(key, default)
  end
end