neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Sign the F-Droid index with a key the forge cannot read
ea9cadf · Gabriel Morell · 2026-09-12 16:20
Message
{commit_body(@commit)}
Files changed
modified
config/runtime.exs
+28
−10
@@ -34,16 +34,17 @@ if config_env() == :prod do
| 34 | 34 | # `DATABASE_URL` wins when both are set. |
| 35 | 35 | database_url = |
| 36 | 36 | System.get_env("DATABASE_URL") || |
| 37 | − (case {System.get_env("POSTGRES_HOST"), System.get_env("POSTGRES_USER"), | |
| 38 | − System.get_env("POSTGRES_PASSWORD"), System.get_env("POSTGRES_DB")} do | |
| 39 | − {host, user, pass, db} when is_binary(host) and is_binary(user) and | |
| 40 | − is_binary(pass) and is_binary(db) -> | |
| 41 | − port = System.get_env("POSTGRES_PORT") || "5432" | |
| 42 | − "ecto://#{URI.encode_www_form(user)}:#{URI.encode_www_form(pass)}@#{host}:#{port}/#{db}" | |
| 43 | − | |
| 44 | − _ -> | |
| 45 | − nil | |
| 46 | − end) || | |
| 37 | + case {System.get_env("POSTGRES_HOST"), System.get_env("POSTGRES_USER"), | |
| 38 | + System.get_env("POSTGRES_PASSWORD"), System.get_env("POSTGRES_DB")} do | |
| 39 | + {host, user, pass, db} | |
| 40 | + when is_binary(host) and is_binary(user) and | |
| 41 | + is_binary(pass) and is_binary(db) -> | |
| 42 | + port = System.get_env("POSTGRES_PORT") || "5432" | |
| 43 | + "ecto://#{URI.encode_www_form(user)}:#{URI.encode_www_form(pass)}@#{host}:#{port}/#{db}" | |
| 44 | + | |
| 45 | + _ -> | |
| 46 | + nil | |
| 47 | + end || | |
| 47 | 48 | raise """ |
| 48 | 49 | No database configured. Set DATABASE_URL, or supply the |
| 49 | 50 | POSTGRES_HOST / POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
@@ -177,6 +178,23 @@ if config_env() == :prod do
| 177 | 178 | |
| 178 | 179 | config :git_gud, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") |
| 179 | 180 | |
| 181 | + # OpenBao — signing key custody for the F-Droid registry, and nothing | |
| 182 | + # else. Gated entirely on OPENBAO_ADDR being set: unset, every format | |
| 183 | + # keeps signing with the local key store and the app never opens a | |
| 184 | + # connection. See openbao/DEPLOY.md in the deployment repo for why | |
| 185 | + # the Android index key is treated differently from the rest. | |
| 186 | + if bao_addr = System.get_env("OPENBAO_ADDR") do | |
| 187 | + config :git_gud, GitGud.Packages.Signing, | |
| 188 | + formats: %{"fdroid" => GitGud.Packages.Signing.OpenBao} | |
| 189 | + | |
| 190 | + config :git_gud, GitGud.Packages.Signing.OpenBao, | |
| 191 | + address: bao_addr, | |
| 192 | + token: System.get_env("OPENBAO_TOKEN"), | |
| 193 | + mount: System.get_env("OPENBAO_TRANSIT_MOUNT", "transit"), | |
| 194 | + key_type: System.get_env("OPENBAO_KEY_TYPE", "rsa-4096"), | |
| 195 | + key_prefix: System.get_env("OPENBAO_KEY_PREFIX", "gitgud") | |
| 196 | + end | |
| 197 | + | |
| 180 | 198 | # libcluster — Kubernetes endpoints lookup against the headless |
| 181 | 199 | # service deployed alongside the app. RBAC (pods + endpoints) is |
| 182 | 200 | # granted to the `git-gud` ServiceAccount in app.yml. |
modified
docs/fdroid-registry.md
+70
−9
@@ -342,17 +342,78 @@ key. Keep them apart:
| 342 | 342 | | APK release keystore | CI secrets (`GitGud.Secrets`) | the app | |
| 343 | 343 | | Repo index key | `SigningKey`, per namespace | the index | |
| 344 | 344 | |
| 345 | −## Key custody, stated deliberately | |
| 345 | +## Key custody — OpenBao | |
| 346 | 346 | |
| 347 | 347 | F-Droid's docs say: *"the repo signing keys should not ever be on a |
| 348 | −public server."* The forge holds the index key, so this departs from | |
| 349 | −that advice — knowingly, and for the same reason the apt PGP key is | |
| 350 | −held: a registry that cannot sign its own index is not a registry. | |
| 351 | − | |
| 352 | −The mitigation is that it is the **lesser** key. It signs metadata, not | |
| 353 | −apps. An attacker with it can lie about what versions exist; they | |
| 354 | −cannot forge an app, because Android verifies the APK signature at | |
| 355 | −install against the key in `signer`, which lives in CI. | |
| 348 | +public server."* A forge that serves its own index cannot follow that | |
| 349 | +literally. It can, however, stop **holding** the key. | |
| 350 | + | |
| 351 | +`GitGud.Packages.Signing` is the seam: `sign/4` takes bytes and returns | |
| 352 | +a signature, and an implementation is free to never possess key | |
| 353 | +material. Two backends: | |
| 354 | + | |
| 355 | +| Backend | Key lives | Used by | | |
| 356 | +|---|---|---| | |
| 357 | +| `Signing.Local` | Postgres, AES-GCM via `GitGud.Secrets` | everything, by default | | |
| 358 | +| `Signing.OpenBao` | inside OpenBao transit, **non-exportable** | `fdroid`, opt-in | | |
| 359 | + | |
| 360 | +For F-Droid the key is generated inside [OpenBao][openbao]'s transit | |
| 361 | +engine with `exportable: false` and `allow_plaintext_backup: false`. | |
| 362 | +No API returns it — not to the app, not to an operator. The forge sends | |
| 363 | +bytes to `/v1/transit/sign/gitgud-fdroid-<namespace>` and gets a | |
| 364 | +signature back. | |
| 365 | + | |
| 366 | +The property that buys: compromising the forge yields the ability to | |
| 367 | +sign *for as long as the compromise lasts*, not a key that outlives it, | |
| 368 | +and every signature is in an audit log. That is what F-Droid's guidance | |
| 369 | +is actually after. | |
| 370 | + | |
| 371 | +**It is opt-in, and only `fdroid` opts in.** OpenBao is a second | |
| 372 | +stateful service to unseal and back up; an instance that doesn't serve | |
| 373 | +an F-Droid repository never contacts it, never needs it deployed, and | |
| 374 | +fails no health check for its absence. Configuration is gated on | |
| 375 | +`OPENBAO_ADDR` being set at all: | |
| 376 | + | |
| 377 | +```elixir | |
| 378 | +config :git_gud, GitGud.Packages.Signing, | |
| 379 | + formats: %{"fdroid" => GitGud.Packages.Signing.OpenBao} | |
| 380 | +``` | |
| 381 | + | |
| 382 | +Deployment is a separate Argo Application (`openbao/` in the deployment | |
| 383 | +repo) rather than part of git-gud's, so *not deploying it* is the | |
| 384 | +default and requires no edit. | |
| 385 | + | |
| 386 | +### The transit call, exactly | |
| 387 | + | |
| 388 | +``` | |
| 389 | +POST /v1/transit/sign/<key> | |
| 390 | +{ "input": "<base64 of the bytes>", | |
| 391 | + "hash_algorithm": "sha2-256", // "sha1" for index-v1 | |
| 392 | + "signature_algorithm": "pkcs1v15", // CMS wants v1.5, not PSS | |
| 393 | + "prehashed": false } | |
| 394 | +→ { "data": { "signature": "vault:v1:<base64>" } } | |
| 395 | +``` | |
| 396 | + | |
| 397 | +Two details worth not rediscovering: | |
| 398 | + | |
| 399 | +- **Send raw bytes, not a digest.** `hash_algorithm: "none"` with | |
| 400 | + `prehashed: true` is documented as yielding a `PKCSv1_5_NoOID` | |
| 401 | + signature; CMS needs the DigestInfo-wrapped `PKCSv1_5_DERnull` form, | |
| 402 | + which is what the default path produces. | |
| 403 | +- **The `vault:v<n>:` prefix is stripped.** A CMS structure has nowhere | |
| 404 | + to record a key version, so rotation needs `key_version` threaded | |
| 405 | + through deliberately if it ever matters. | |
| 406 | + | |
| 407 | +### What this does *not* cover | |
| 408 | + | |
| 409 | +The **APK** signing key is a different key with a different home: CI | |
| 410 | +secrets, used by `apksigner` at build time. OpenBao holds the *index* | |
| 411 | +key. Keeping them apart is the point — an attacker with the index key | |
| 412 | +can lie about which versions exist; they cannot forge an app, because | |
| 413 | +Android verifies the APK signature at install against the certificate | |
| 414 | +recorded in `signer`. | |
| 415 | + | |
| 416 | +[openbao]: https://openbao.org | |
| 356 | 417 | |
| 357 | 418 | ## Order |
| 358 | 419 |
added
lib/git_gud/packages/signing.ex
+117
−0
@@ -0,0 +1,117 @@
| 1 | +defmodule GitGud.Packages.Signing do | |
| 2 | + @moduledoc """ | |
| 3 | + Where a registry's signing key lives, and who performs the signature. | |
| 4 | + | |
| 5 | + Until now there was one answer: `GitGud.Packages.SigningKey` holds an | |
| 6 | + AES-GCM-encrypted private key in Postgres, and the app decrypts it to | |
| 7 | + sign. That is fine for apt — an OpenPGP key signing an index nobody | |
| 8 | + installs from without `apt-key` already trusting it — and it is the | |
| 9 | + default here still. | |
| 10 | + | |
| 11 | + It is a worse answer for Android. F-Droid's own guidance is blunt: | |
| 12 | + | |
| 13 | + > the repo signing keys should not ever be on a public server | |
| 14 | + | |
| 15 | + A forge that serves its own index cannot follow that literally, but it | |
| 16 | + can stop *holding* the key. This behaviour is the seam that allows it: | |
| 17 | + `sign/4` takes bytes and returns a signature, and an implementation is | |
| 18 | + free to never possess key material at all. | |
| 19 | + | |
| 20 | + ## Backends | |
| 21 | + | |
| 22 | + * `GitGud.Packages.Signing.Local` — the existing behaviour. Private | |
| 23 | + key in Postgres, encrypted at rest, decrypted to sign. | |
| 24 | + * `GitGud.Packages.Signing.OpenBao` — the key is generated inside | |
| 25 | + OpenBao's transit engine as non-exportable and never leaves it. | |
| 26 | + The forge sends bytes and gets a signature back, so compromising | |
| 27 | + the app yields the ability to sign *while the compromise lasts* | |
| 28 | + rather than a key that outlives it. | |
| 29 | + | |
| 30 | + ## Configuration | |
| 31 | + | |
| 32 | + The default is `Local`, so nothing changes for an existing | |
| 33 | + deployment. A format opts in: | |
| 34 | + | |
| 35 | + config :git_gud, GitGud.Packages.Signing, | |
| 36 | + default: GitGud.Packages.Signing.Local, | |
| 37 | + formats: %{"fdroid" => GitGud.Packages.Signing.OpenBao} | |
| 38 | + | |
| 39 | + **OpenBao is only ever required by the formats that name it.** An | |
| 40 | + instance that doesn't serve the F-Droid registry never talks to it, | |
| 41 | + never needs it deployed, and fails no health check for its absence. | |
| 42 | + That is deliberate: it is one more thing to run, and it should be | |
| 43 | + one more thing to run only for the people who need the property it | |
| 44 | + provides. | |
| 45 | + | |
| 46 | + ## Scope | |
| 47 | + | |
| 48 | + A `scope` is the namespace a key belongs to — a `%User{}` or | |
| 49 | + `%Organization{}`, the same unit `Packages.signing_key/2` keys on. One | |
| 50 | + key per (namespace, format), minted on first use. | |
| 51 | + """ | |
| 52 | + | |
| 53 | + alias GitGud.Packages.Signing.Local | |
| 54 | + | |
| 55 | + @typedoc "The namespace a key belongs to." | |
| 56 | + @type scope :: struct() | |
| 57 | + | |
| 58 | + @typedoc """ | |
| 59 | + Options for `sign/4`: | |
| 60 | + | |
| 61 | + * `:digest` — `:sha256` (default) or `:sha1`. F-Droid's index-v1 is | |
| 62 | + `SHA1withRSA` and index-v2 is `SHA256withRSA`, so both are real. | |
| 63 | + * `:scheme` — `:pkcs1v15` (default) or `:pss`. CMS wants v1.5. | |
| 64 | + """ | |
| 65 | + @type sign_opts :: [digest: :sha256 | :sha1, scheme: :pkcs1v15 | :pss] | |
| 66 | + | |
| 67 | + @doc """ | |
| 68 | + Ensure a key exists for `scope` and `format`, returning its public | |
| 69 | + half as PEM. Idempotent — this is what mints one on first use. | |
| 70 | + """ | |
| 71 | + @callback ensure_key(scope(), String.t()) :: | |
| 72 | + {:ok, %{public_pem: String.t()}} | {:error, term()} | |
| 73 | + | |
| 74 | + @doc "The public half of an existing key, without creating one." | |
| 75 | + @callback public_key(scope(), String.t()) :: {:ok, String.t()} | {:error, term()} | |
| 76 | + | |
| 77 | + @doc """ | |
| 78 | + Sign `data` — the raw bytes, not a digest. Hashing is the backend's | |
| 79 | + business, because OpenBao would rather do it itself than be handed | |
| 80 | + something it has to take on trust. | |
| 81 | + """ | |
| 82 | + @callback sign(scope(), String.t(), binary(), sign_opts()) :: | |
| 83 | + {:ok, binary()} | {:error, term()} | |
| 84 | + | |
| 85 | + @doc """ | |
| 86 | + The backend for `format`. | |
| 87 | + | |
| 88 | + A format with no entry gets `:default`, and `:default` with no entry | |
| 89 | + gets `Local` — so adding a format never silently changes where an | |
| 90 | + existing key lives. | |
| 91 | + """ | |
| 92 | + @spec backend(String.t()) :: module() | |
| 93 | + def backend(format) when is_binary(format) do | |
| 94 | + config = Application.get_env(:git_gud, __MODULE__, []) | |
| 95 | + formats = Keyword.get(config, :formats, %{}) | |
| 96 | + | |
| 97 | + Map.get(formats, format) || Keyword.get(config, :default, Local) | |
| 98 | + end | |
| 99 | + | |
| 100 | + @spec ensure_key(scope(), String.t()) :: {:ok, %{public_pem: String.t()}} | {:error, term()} | |
| 101 | + def ensure_key(scope, format), do: backend(format).ensure_key(scope, format) | |
| 102 | + | |
| 103 | + @spec public_key(scope(), String.t()) :: {:ok, String.t()} | {:error, term()} | |
| 104 | + def public_key(scope, format), do: backend(format).public_key(scope, format) | |
| 105 | + | |
| 106 | + @spec sign(scope(), String.t(), binary(), sign_opts()) :: {:ok, binary()} | {:error, term()} | |
| 107 | + def sign(scope, format, data, opts \\ []), | |
| 108 | + do: backend(format).sign(scope, format, data, opts) | |
| 109 | + | |
| 110 | + @doc """ | |
| 111 | + Whether `format` signs through something other than the local key | |
| 112 | + store. The packages UI says so on the page, because "this index is | |
| 113 | + signed by a key this server cannot read" is worth stating out loud. | |
| 114 | + """ | |
| 115 | + @spec external?(String.t()) :: boolean() | |
| 116 | + def external?(format), do: backend(format) != Local | |
| 117 | +end |
added
lib/git_gud/packages/signing/local.ex
+65
−0
@@ -0,0 +1,65 @@
| 1 | +defmodule GitGud.Packages.Signing.Local do | |
| 2 | + @moduledoc """ | |
| 3 | + The default signing backend: the private key lives in Postgres, | |
| 4 | + AES-GCM-encrypted through `GitGud.Secrets`, and is decrypted in the | |
| 5 | + app to sign. | |
| 6 | + | |
| 7 | + This is what every registry format did before the behaviour existed, | |
| 8 | + and it stays the default — an existing deployment changes nothing and | |
| 9 | + needs nothing new running. | |
| 10 | + | |
| 11 | + It delegates key custody to `GitGud.Packages.signing_key/2`, so apt's | |
| 12 | + OpenPGP certificate and the RSA keys every other format uses are | |
| 13 | + reached the same way they always were. Only `sign/4` is new, and it | |
| 14 | + only knows RSA: apt does its own signing through the sequoia NIF | |
| 15 | + because OpenPGP is a packet format, not a raw signature. | |
| 16 | + """ | |
| 17 | + @behaviour GitGud.Packages.Signing | |
| 18 | + | |
| 19 | + alias GitGud.Packages | |
| 20 | + | |
| 21 | + @impl true | |
| 22 | + def ensure_key(scope, format) do | |
| 23 | + with {:ok, %{public_pem: pem}} <- Packages.signing_key(scope, format) do | |
| 24 | + {:ok, %{public_pem: pem}} | |
| 25 | + end | |
| 26 | + end | |
| 27 | + | |
| 28 | + @impl true | |
| 29 | + def public_key(scope, format) do | |
| 30 | + case Packages.get_signing_key(scope, format) do | |
| 31 | + nil -> {:error, :no_key} | |
| 32 | + key -> {:ok, key.public_key} | |
| 33 | + end | |
| 34 | + end | |
| 35 | + | |
| 36 | + @impl true | |
| 37 | + def sign(scope, format, data, opts) when is_binary(data) do | |
| 38 | + digest = Keyword.get(opts, :digest, :sha256) | |
| 39 | + scheme = Keyword.get(opts, :scheme, :pkcs1v15) | |
| 40 | + | |
| 41 | + with {:ok, %{private_pem: pem}} <- Packages.signing_key(scope, format), | |
| 42 | + {:ok, key} <- decode_private(pem) do | |
| 43 | + {:ok, :public_key.sign(data, digest, key, sign_options(scheme))} | |
| 44 | + end | |
| 45 | + rescue | |
| 46 | + # `:public_key.sign/4` raises on a key it can't use — a caller | |
| 47 | + # asking an OpenPGP certificate to produce a PKCS#1 signature, for | |
| 48 | + # instance. An error tuple is the useful shape here. | |
| 49 | + e -> {:error, {:sign_failed, Exception.message(e)}} | |
| 50 | + end | |
| 51 | + | |
| 52 | + defp sign_options(:pss), | |
| 53 | + do: [rsa_padding: :rsa_pkcs1_pss_padding, rsa_pss_saltlen: -1] | |
| 54 | + | |
| 55 | + defp sign_options(_pkcs1v15), do: [] | |
| 56 | + | |
| 57 | + defp decode_private(pem) do | |
| 58 | + case :public_key.pem_decode(pem) do | |
| 59 | + [entry | _] -> {:ok, :public_key.pem_entry_decode(entry)} | |
| 60 | + [] -> {:error, :not_a_pem_key} | |
| 61 | + end | |
| 62 | + rescue | |
| 63 | + _ -> {:error, :not_a_pem_key} | |
| 64 | + end | |
| 65 | +end |
added
lib/git_gud/packages/signing/open_bao.ex
+224
−0
@@ -0,0 +1,224 @@
| 1 | +defmodule GitGud.Packages.Signing.OpenBao do | |
| 2 | + @moduledoc """ | |
| 3 | + Signing through [OpenBao](https://openbao.org)'s transit engine, so | |
| 4 | + the forge never holds the private key. | |
| 5 | + | |
| 6 | + The key is created inside OpenBao as **non-exportable** — there is no | |
| 7 | + API that returns it, not to the app and not to an operator. The forge | |
| 8 | + sends bytes to `/v1/<mount>/sign/<key>` and gets a signature back. An | |
| 9 | + attacker who owns the app can sign things for as long as they hold it; | |
| 10 | + they cannot walk away with a key that keeps working afterwards, and | |
| 11 | + every signature they ask for is in OpenBao's audit log. | |
| 12 | + | |
| 13 | + That is the property F-Droid's guidance is really after when it says | |
| 14 | + the repo signing key shouldn't be on a public server. The key isn't. | |
| 15 | + | |
| 16 | + ## Why this is opt-in | |
| 17 | + | |
| 18 | + It is a second stateful service to run, unseal and back up. The only | |
| 19 | + format that names it today is `fdroid`, and an instance that doesn't | |
| 20 | + serve an F-Droid repository never contacts it. See | |
| 21 | + `GitGud.Packages.Signing` for the configuration. | |
| 22 | + | |
| 23 | + ## Configuration | |
| 24 | + | |
| 25 | + config :git_gud, GitGud.Packages.Signing.OpenBao, | |
| 26 | + address: "http://openbao.openbao.svc:8200", | |
| 27 | + token: System.get_env("OPENBAO_TOKEN"), | |
| 28 | + mount: "transit", | |
| 29 | + key_type: "rsa-4096", | |
| 30 | + key_prefix: "gitgud" | |
| 31 | + | |
| 32 | + Auth is a static token read from a Kubernetes Secret, which is how | |
| 33 | + every other credential in this deployment already arrives. The | |
| 34 | + Kubernetes auth method — the app logging in with its service-account | |
| 35 | + JWT for a short-lived token — is the hardening step after this, and | |
| 36 | + changes nothing above `request/4`. | |
| 37 | + | |
| 38 | + ## Key naming | |
| 39 | + | |
| 40 | + `<key_prefix>-<format>-<namespace handle>`, e.g. `gitgud-fdroid-neiam`. | |
| 41 | + Flat, because transit keys have no hierarchy, and readable, because | |
| 42 | + the audit log is the point. | |
| 43 | + | |
| 44 | + ## The signature wire format | |
| 45 | + | |
| 46 | + Transit returns `vault:v<n>:<base64>`. The version prefix is stripped | |
| 47 | + here — the caller wants signature bytes, and a CMS structure has | |
| 48 | + nowhere to put a key version. Rotation therefore needs care: a | |
| 49 | + re-signed index is signed by whatever version is current, and | |
| 50 | + `key_version` would have to be threaded through to pin it. | |
| 51 | + """ | |
| 52 | + @behaviour GitGud.Packages.Signing | |
| 53 | + | |
| 54 | + alias GitGud.Repositories.Storage | |
| 55 | + | |
| 56 | + @default_mount "transit" | |
| 57 | + @default_key_type "rsa-4096" | |
| 58 | + @default_prefix "gitgud" | |
| 59 | + | |
| 60 | + @impl true | |
| 61 | + def ensure_key(scope, format) do | |
| 62 | + name = key_name(scope, format) | |
| 63 | + | |
| 64 | + # Transit's key creation is idempotent: creating one that exists is | |
| 65 | + # a no-op rather than an error, so there is no read-then-create race | |
| 66 | + # to lose. | |
| 67 | + with {:ok, _} <- create_key(name), | |
| 68 | + {:ok, pem} <- read_public_key(name) do | |
| 69 | + {:ok, %{public_pem: pem}} | |
| 70 | + end | |
| 71 | + end | |
| 72 | + | |
| 73 | + @impl true | |
| 74 | + def public_key(scope, format), do: scope |> key_name(format) |> read_public_key() | |
| 75 | + | |
| 76 | + @impl true | |
| 77 | + def sign(scope, format, data, opts) when is_binary(data) do | |
| 78 | + name = key_name(scope, format) | |
| 79 | + | |
| 80 | + body = %{ | |
| 81 | + "input" => Base.encode64(data), | |
| 82 | + "hash_algorithm" => hash_algorithm(Keyword.get(opts, :digest, :sha256)), | |
| 83 | + "signature_algorithm" => signature_algorithm(Keyword.get(opts, :scheme, :pkcs1v15)), | |
| 84 | + # The raw bytes go up and OpenBao hashes them. Sending a digest | |
| 85 | + # with `prehashed: true` also works, but `hash_algorithm: "none"` | |
| 86 | + # does not: transit documents that combination as producing a | |
| 87 | + # PKCSv1_5_NoOID signature, and CMS wants the DigestInfo-wrapped | |
| 88 | + # PKCSv1_5_DERnull form. | |
| 89 | + "prehashed" => false | |
| 90 | + } | |
| 91 | + | |
| 92 | + with {:ok, %{"data" => %{"signature" => signature}}} <- | |
| 93 | + request(:post, "/v1/#{mount()}/sign/#{name}", body) do | |
| 94 | + decode_signature(signature) | |
| 95 | + end | |
| 96 | + end | |
| 97 | + | |
| 98 | + # ── transit calls ──────────────────────────────────────────────────── | |
| 99 | + | |
| 100 | + defp create_key(name) do | |
| 101 | + request(:post, "/v1/#{mount()}/keys/#{name}", %{ | |
| 102 | + "type" => config(:key_type, @default_key_type), | |
| 103 | + # The whole point. Without this an operator could pull the key out | |
| 104 | + # and the guarantee this module claims would be a comment. | |
| 105 | + "exportable" => false, | |
| 106 | + "allow_plaintext_backup" => false | |
| 107 | + }) | |
| 108 | + end | |
| 109 | + | |
| 110 | + # `GET /v1/<mount>/keys/<name>` returns every version's public half | |
| 111 | + # keyed by version number; the latest is the one to publish. | |
| 112 | + defp read_public_key(name) do | |
| 113 | + with {:ok, %{"data" => data}} <- request(:get, "/v1/#{mount()}/keys/#{name}", nil) do | |
| 114 | + latest = data["latest_version"] | |
| 115 | + keys = data["keys"] || %{} | |
| 116 | + | |
| 117 | + case keys[to_string(latest)] do | |
| 118 | + %{"public_key" => pem} when is_binary(pem) -> {:ok, pem} | |
| 119 | + _ -> {:error, {:openbao, "key #{name} has no public key at version #{latest}"}} | |
| 120 | + end | |
| 121 | + end | |
| 122 | + end | |
| 123 | + | |
| 124 | + defp decode_signature("vault:v" <> rest) do | |
| 125 | + case String.split(rest, ":", parts: 2) do | |
| 126 | + [_version, encoded] -> decode_base64(encoded) | |
| 127 | + _ -> {:error, {:openbao, "unparseable signature"}} | |
| 128 | + end | |
| 129 | + end | |
| 130 | + | |
| 131 | + # Not all forks keep the `vault:` prefix; accept a bare signature too | |
| 132 | + # rather than failing on something that is perfectly usable. | |
| 133 | + defp decode_signature(other) when is_binary(other), do: decode_base64(other) | |
| 134 | + | |
| 135 | + defp decode_base64(encoded) do | |
| 136 | + case Base.decode64(encoded) do | |
| 137 | + {:ok, signature} -> {:ok, signature} | |
| 138 | + :error -> {:error, {:openbao, "signature was not base64"}} | |
| 139 | + end | |
| 140 | + end | |
| 141 | + | |
| 142 | + # ── http ───────────────────────────────────────────────────────────── | |
| 143 | + | |
| 144 | + defp request(method, path, body) do | |
| 145 | + case address() do | |
| 146 | + nil -> | |
| 147 | + {:error, {:openbao, "no address configured — set :address on #{inspect(__MODULE__)}"}} | |
| 148 | + | |
| 149 | + address -> | |
| 150 | + req = | |
| 151 | + Req.new( | |
| 152 | + method: method, | |
| 153 | + url: address <> path, | |
| 154 | + json: body, | |
| 155 | + headers: [{"x-vault-token", token() || ""}], | |
| 156 | + receive_timeout: 15_000, | |
| 157 | + retry: :safe_transient | |
| 158 | + ) | |
| 159 | + |> Req.merge(Application.get_env(:git_gud, :openbao_req_options, [])) | |
| 160 | + | |
| 161 | + run(req, path) | |
| 162 | + end | |
| 163 | + end | |
| 164 | + | |
| 165 | + defp run(req, path) do | |
| 166 | + case Req.request(req) do | |
| 167 | + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> | |
| 168 | + {:ok, body || %{}} | |
| 169 | + | |
| 170 | + # Creating a key that already exists, and a few other transit | |
| 171 | + # no-ops, answer 204 with no body. | |
| 172 | + {:ok, %Req.Response{status: 204}} -> | |
| 173 | + {:ok, %{}} | |
| 174 | + | |
| 175 | + {:ok, %Req.Response{status: 403}} -> | |
| 176 | + {:error, {:openbao, "denied by policy on #{path} — check the token's capabilities"}} | |
| 177 | + | |
| 178 | + {:ok, %Req.Response{status: 404}} -> | |
| 179 | + {:error, {:openbao, "no such path #{path} — is the transit engine mounted?"}} | |
| 180 | + | |
| 181 | + {:ok, %Req.Response{status: 503}} -> | |
| 182 | + {:error, {:openbao, "sealed or unavailable"}} | |
| 183 | + | |
| 184 | + {:ok, %Req.Response{status: status, body: body}} -> | |
| 185 | + {:error, {:openbao, "HTTP #{status}: #{errors(body)}"}} | |
| 186 | + | |
| 187 | + {:error, reason} -> | |
| 188 | + {:error, {:openbao, "unreachable: #{inspect(reason)}"}} | |
| 189 | + end | |
| 190 | + end | |
| 191 | + | |
| 192 | + defp errors(%{"errors" => [_ | _] = errors}), do: Enum.join(errors, "; ") | |
| 193 | + defp errors(body) when is_binary(body), do: body | |
| 194 | + defp errors(body), do: inspect(body) | |
| 195 | + | |
| 196 | + # ── naming + config ────────────────────────────────────────────────── | |
| 197 | + | |
| 198 | + @doc """ | |
| 199 | + The transit key name for a namespace and format. Public because the | |
| 200 | + policy an operator writes has to name the same paths. | |
| 201 | + """ | |
| 202 | + @spec key_name(struct(), String.t()) :: String.t() | |
| 203 | + def key_name(scope, format) do | |
| 204 | + handle = Storage.owner_handle(scope) | |
| 205 | + "#{config(:key_prefix, @default_prefix)}-#{format}-#{handle}" | |
| 206 | + end | |
| 207 | + | |
| 208 | + defp hash_algorithm(:sha1), do: "sha1" | |
| 209 | + defp hash_algorithm(_sha256), do: "sha2-256" | |
| 210 | + | |
| 211 | + defp signature_algorithm(:pss), do: "pss" | |
| 212 | + defp signature_algorithm(_pkcs1v15), do: "pkcs1v15" | |
| 213 | + | |
| 214 | + defp mount, do: config(:mount, @default_mount) | |
| 215 | + defp address, do: config(:address, nil) |> trim_slash() | |
| 216 | + defp token, do: config(:token, nil) | |
| 217 | + | |
| 218 | + defp trim_slash(nil), do: nil | |
| 219 | + defp trim_slash(url), do: String.trim_trailing(url, "/") | |
| 220 | + | |
| 221 | + defp config(key, default) do | |
| 222 | + Application.get_env(:git_gud, __MODULE__, []) |> Keyword.get(key, default) | |
| 223 | + end | |
| 224 | +end |
added
test/git_gud/packages/signing_open_bao_test.exs
+198
−0
@@ -0,0 +1,198 @@
| 1 | +defmodule GitGud.Packages.Signing.OpenBaoTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The OpenBao transit signing backend. | |
| 4 | + | |
| 5 | + The transit engine is stubbed with a Plug, the way the import | |
| 6 | + providers' HTTP layer already is. What is worth pinning down is the | |
| 7 | + request *shape*: a signature that comes back correct but was asked for | |
| 8 | + with the wrong `signature_algorithm` produces a JAR that F-Droid | |
| 9 | + rejects, and no amount of round-tripping locally would catch it. | |
| 10 | + """ | |
| 11 | + use ExUnit.Case, async: false | |
| 12 | + | |
| 13 | + alias GitGud.Accounts.User | |
| 14 | + alias GitGud.Packages.Signing | |
| 15 | + alias GitGud.Packages.Signing.OpenBao | |
| 16 | + | |
| 17 | + @address "http://openbao.test:8200" | |
| 18 | + | |
| 19 | + setup do | |
| 20 | + Application.put_env(:git_gud, OpenBao, | |
| 21 | + address: @address, | |
| 22 | + token: "bao-test-token", | |
| 23 | + mount: "transit", | |
| 24 | + key_type: "rsa-4096", | |
| 25 | + key_prefix: "gitgud" | |
| 26 | + ) | |
| 27 | + | |
| 28 | + on_exit(fn -> | |
| 29 | + Application.delete_env(:git_gud, OpenBao) | |
| 30 | + Application.delete_env(:git_gud, :openbao_req_options) | |
| 31 | + Application.delete_env(:git_gud, Signing) | |
| 32 | + end) | |
| 33 | + | |
| 34 | + %{user: %User{handle: "neiam"}} | |
| 35 | + end | |
| 36 | + | |
| 37 | + # Records every request so the assertions can look at what was sent, | |
| 38 | + # not just at what came back. | |
| 39 | + defp stub(handler) when is_function(handler, 1) do | |
| 40 | + test = self() | |
| 41 | + | |
| 42 | + Application.put_env(:git_gud, :openbao_req_options, | |
| 43 | + plug: fn conn -> | |
| 44 | + {:ok, raw, conn} = Plug.Conn.read_body(conn) | |
| 45 | + body = if raw == "", do: %{}, else: Jason.decode!(raw) | |
| 46 | + send(test, {:openbao_request, conn.method, conn.request_path, body}) | |
| 47 | + handler.({conn, conn.request_path, body}) | |
| 48 | + end | |
| 49 | + ) | |
| 50 | + end | |
| 51 | + | |
| 52 | + defp json(conn, status, body) do | |
| 53 | + conn | |
| 54 | + |> Plug.Conn.put_resp_content_type("application/json") | |
| 55 | + |> Plug.Conn.resp(status, Jason.encode!(body)) | |
| 56 | + end | |
| 57 | + | |
| 58 | + defp signature_response(bytes), | |
| 59 | + do: %{"data" => %{"signature" => "vault:v1:" <> Base.encode64(bytes)}} | |
| 60 | + | |
| 61 | + describe "sign/4" do | |
| 62 | + test "asks for PKCS#1 v1.5 over SHA-256 by default", %{user: user} do | |
| 63 | + stub(fn {conn, _path, _body} -> json(conn, 200, signature_response("sig-bytes")) end) | |
| 64 | + | |
| 65 | + assert {:ok, "sig-bytes"} = OpenBao.sign(user, "fdroid", "data to sign", []) | |
| 66 | + | |
| 67 | + assert_receive {:openbao_request, "POST", path, body} | |
| 68 | + assert path == "/v1/transit/sign/gitgud-fdroid-#{user.handle}" | |
| 69 | + assert body["signature_algorithm"] == "pkcs1v15" | |
| 70 | + assert body["hash_algorithm"] == "sha2-256" | |
| 71 | + assert body["input"] == Base.encode64("data to sign") | |
| 72 | + end | |
| 73 | + | |
| 74 | + test "never sends prehashed input" do | |
| 75 | + # `hash_algorithm: none` + prehashed yields a PKCSv1_5_NoOID | |
| 76 | + # signature, which CMS won't accept — so the raw bytes go up and | |
| 77 | + # OpenBao does the hashing. | |
| 78 | + user = %User{handle: "neiam"} | |
| 79 | + stub(fn {conn, _, _} -> json(conn, 200, signature_response("s")) end) | |
| 80 | + | |
| 81 | + assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", []) | |
| 82 | + | |
| 83 | + assert_receive {:openbao_request, "POST", _, body} | |
| 84 | + assert body["prehashed"] == false | |
| 85 | + refute body["hash_algorithm"] == "none" | |
| 86 | + end | |
| 87 | + | |
| 88 | + test "honours sha1 for index-v1", %{user: user} do | |
| 89 | + stub(fn {conn, _, _} -> json(conn, 200, signature_response("s")) end) | |
| 90 | + | |
| 91 | + assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", digest: :sha1) | |
| 92 | + | |
| 93 | + assert_receive {:openbao_request, "POST", _, body} | |
| 94 | + assert body["hash_algorithm"] == "sha1" | |
| 95 | + end | |
| 96 | + | |
| 97 | + test "strips the key-version prefix from the signature", %{user: user} do | |
| 98 | + stub(fn {conn, _, _} -> | |
| 99 | + json(conn, 200, %{"data" => %{"signature" => "vault:v7:" <> Base.encode64("raw")}}) | |
| 100 | + end) | |
| 101 | + | |
| 102 | + assert {:ok, "raw"} = OpenBao.sign(user, "fdroid", "x", []) | |
| 103 | + end | |
| 104 | + | |
| 105 | + test "accepts a signature with no prefix", %{user: user} do | |
| 106 | + stub(fn {conn, _, _} -> | |
| 107 | + json(conn, 200, %{"data" => %{"signature" => Base.encode64("raw")}}) | |
| 108 | + end) | |
| 109 | + | |
| 110 | + assert {:ok, "raw"} = OpenBao.sign(user, "fdroid", "x", []) | |
| 111 | + end | |
| 112 | + end | |
| 113 | + | |
| 114 | + describe "ensure_key/2" do | |
| 115 | + test "creates a non-exportable key and returns its public half", %{user: user} do | |
| 116 | + pem = "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n" | |
| 117 | + | |
| 118 | + stub(fn | |
| 119 | + {conn, "/v1/transit/keys/" <> _, _body} when conn.method == "POST" -> | |
| 120 | + json(conn, 204, %{}) | |
| 121 | + | |
| 122 | + {conn, "/v1/transit/keys/" <> _, _} -> | |
| 123 | + json(conn, 200, %{ | |
| 124 | + "data" => %{ | |
| 125 | + "latest_version" => 2, | |
| 126 | + "keys" => %{"1" => %{}, "2" => %{"public_key" => pem}} | |
| 127 | + } | |
| 128 | + }) | |
| 129 | + end) | |
| 130 | + | |
| 131 | + assert {:ok, %{public_pem: ^pem}} = OpenBao.ensure_key(user, "fdroid") | |
| 132 | + | |
| 133 | + assert_receive {:openbao_request, "POST", _, create} | |
| 134 | + assert create["type"] == "rsa-4096" | |
| 135 | + # Without this the guarantee this backend claims is just a comment. | |
| 136 | + assert create["exportable"] == false | |
| 137 | + assert create["allow_plaintext_backup"] == false | |
| 138 | + end | |
| 139 | + | |
| 140 | + test "reports a key with no public half at the latest version", %{user: user} do | |
| 141 | + stub(fn | |
| 142 | + {conn, _, _} when conn.method == "POST" -> | |
| 143 | + json(conn, 204, %{}) | |
| 144 | + | |
| 145 | + {conn, _, _} -> | |
| 146 | + json(conn, 200, %{"data" => %{"latest_version" => 3, "keys" => %{}}}) | |
| 147 | + end) | |
| 148 | + | |
| 149 | + assert {:error, {:openbao, message}} = OpenBao.ensure_key(user, "fdroid") | |
| 150 | + assert message =~ "no public key" | |
| 151 | + end | |
| 152 | + end | |
| 153 | + | |
| 154 | + describe "failure modes worth naming" do | |
| 155 | + test "a sealed OpenBao says so", %{user: user} do | |
| 156 | + stub(fn {conn, _, _} -> json(conn, 503, %{"errors" => ["sealed"]}) end) | |
| 157 | + | |
| 158 | + assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", []) | |
| 159 | + assert message =~ "sealed" | |
| 160 | + end | |
| 161 | + | |
| 162 | + test "a policy denial points at the token", %{user: user} do | |
| 163 | + stub(fn {conn, _, _} -> json(conn, 403, %{"errors" => ["permission denied"]}) end) | |
| 164 | + | |
| 165 | + assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", []) | |
| 166 | + assert message =~ "capabilities" | |
| 167 | + end | |
| 168 | + | |
| 169 | + test "an unmounted engine points at the mount", %{user: user} do | |
| 170 | + stub(fn {conn, _, _} -> json(conn, 404, %{}) end) | |
| 171 | + | |
| 172 | + assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", []) | |
| 173 | + assert message =~ "transit engine mounted" | |
| 174 | + end | |
| 175 | + | |
| 176 | + test "no configured address is an error, not a crash", %{user: user} do | |
| 177 | + Application.put_env(:git_gud, OpenBao, token: "t") | |
| 178 | + | |
| 179 | + assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", []) | |
| 180 | + assert message =~ "no address configured" | |
| 181 | + end | |
| 182 | + end | |
| 183 | + | |
| 184 | + describe "backend selection" do | |
| 185 | + test "defaults to Local so an existing deployment changes nothing" do | |
| 186 | + assert Signing.backend("deb") == GitGud.Packages.Signing.Local | |
| 187 | + end | |
| 188 | + | |
| 189 | + test "a format opts in by name" do | |
| 190 | + Application.put_env(:git_gud, Signing, formats: %{"fdroid" => OpenBao}) | |
| 191 | + | |
| 192 | + assert Signing.backend("fdroid") == OpenBao | |
| 193 | + assert Signing.backend("deb") == GitGud.Packages.Signing.Local | |
| 194 | + assert Signing.external?("fdroid") | |
| 195 | + refute Signing.external?("deb") | |
| 196 | + end | |
| 197 | + end | |
| 198 | +end |
Parents: 43c3d8d