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 is a static token read from a Kubernetes Secret, which is how
every other credential in this deployment already arrives. The
Kubernetes auth method — the app logging in with its service-account
JWT for a short-lived token — is the hardening step after this, and
changes nothing above `request/4`.
## 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()
defp token, do: config(:token, nil)
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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
8.1 KiB · text
History
ea9cadf