Sign the F-Droid index with a key the forge cannot read

ea9cadf · Gabriel Morell · 2026-09-12 16:20

15 files +702 -19
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