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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
5.8 KiB · text
History
b868907