Keep the OpenBao token alive

bc4a319 · Gabriel Morell · 2026-09-12 16:45

15 files +569 -12
Message
{commit_body(@commit)}

Files changed

modified config/runtime.exs
+5 −0
@@ -190,6 +190,11 @@ if config_env() == :prod do
190 190 config :git_gud, GitGud.Packages.Signing.OpenBao,
191 191 address: bao_addr,
192 192 token: System.get_env("OPENBAO_TOKEN"),
193 + # Set OPENBAO_ROLE to use the Kubernetes auth method instead: the
194 + # app logs in with its ServiceAccount JWT and renews a short-lived
195 + # token itself, so no static token has to be minted or rotated.
196 + role: System.get_env("OPENBAO_ROLE"),
197 + auth_mount: System.get_env("OPENBAO_AUTH_MOUNT", "kubernetes"),
193 198 mount: System.get_env("OPENBAO_TRANSIT_MOUNT", "transit"),
194 199 key_type: System.get_env("OPENBAO_KEY_TYPE", "rsa-4096"),
195 200 key_prefix: System.get_env("OPENBAO_KEY_PREFIX", "gitgud")
modified lib/git_gud/application.ex
+3 −0
@@ -33,6 +33,9 @@ defmodule GitGud.Application do
33 33 GitGud.Cache,
34 34 {Oban, Application.fetch_env!(:git_gud, Oban)},
35 35 GitGud.Federation.RateLimiter,
36 + # Holds (and renews) the OpenBao token the F-Droid signer
37 + # uses. Idle unless OpenBao is configured.
38 + GitGud.Packages.Signing.OpenBao.Session,
36 39 # Ad-hoc background work kicked off by a request or a live
37 40 # view — see GitGudWeb.RepoLive.EntryTimes.
38 41 {Task.Supervisor, name: GitGud.TaskSupervisor},
modified lib/git_gud/packages/signing/open_bao.ex
+8 −6
@@ -29,11 +29,11 @@ defmodule GitGud.Packages.Signing.OpenBao do
29 29 key_type: "rsa-4096",
30 30 key_prefix: "gitgud"
31 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`.
32 + Auth comes from `GitGud.Packages.Signing.OpenBao.Session`, which
33 + either logs in with the pod's ServiceAccount JWT (`:role` set, a
34 + short-lived token it renews itself) or carries the static token from
35 + a Kubernetes Secret, renewing that before its period lapses. Either
36 + way this module just asks for the current token.
37 37
38 38 ## Key naming
39 39
@@ -213,7 +213,9 @@ defmodule GitGud.Packages.Signing.OpenBao do
213 213
214 214 defp mount, do: config(:mount, @default_mount)
215 215 defp address, do: config(:address, nil) |> trim_slash()
216 − defp token, do: config(:token, nil)
216 + # Not `config(:token, …)`: the session owns acquisition and renewal,
217 + # and with Kubernetes auth there is no configured token to read.
218 + defp token, do: GitGud.Packages.Signing.OpenBao.Session.token()
217 219
218 220 defp trim_slash(nil), do: nil
219 221 defp trim_slash(url), do: String.trim_trailing(url, "/")
added lib/git_gud/packages/signing/open_bao/session.ex
+302 −0
@@ -0,0 +1,302 @@
1 +defmodule GitGud.Packages.Signing.OpenBao.Session do
2 + @moduledoc """
3 + Holds the OpenBao token the signer uses, and keeps it alive.
4 +
5 + Two ways in, and the difference is whether a human has to remember
6 + something:
7 +
8 + * **Kubernetes auth** (`:role` set) — the app logs in with its own
9 + ServiceAccount JWT and gets a short-lived token back. Nothing is
10 + stored anywhere, the credential is the pod's identity, and a
11 + leaked token expires on its own. This is the one to use.
12 +
13 + * **A static token** (`:token` set) — what the first deploy did,
14 + because it needs no auth method configured. A `-period=768h`
15 + token is renewable rather than eternal, so this process renews it
16 + before it lapses. Without that, F-Droid signing breaks 32 days
17 + after a deploy, at a moment unrelated to anything anyone changed.
18 +
19 + Renewal happens at two thirds of the lease, so a failure still has a
20 + third of the lease to retry in rather than being the last word. Under
21 + Kubernetes auth a failed renewal falls back to logging in again; a
22 + static token has no such move, so it keeps the token it has and
23 + retries — that token is valid until it isn't.
24 +
25 + Started unconditionally but idle unless OpenBao is configured: no
26 + address, no work, and no state beyond an empty struct.
27 + """
28 +
29 + use GenServer
30 +
31 + require Logger
32 +
33 + # Renew at two thirds of the lease. Earlier wastes calls; later leaves
34 + # no room to retry.
35 + @renew_fraction 2 / 3
36 +
37 + # Kubernetes projects the pod's ServiceAccount token here.
38 + @jwt_path "/var/run/secrets/kubernetes.io/serviceaccount/token"
39 +
40 + defstruct token: nil, expires_at: nil, timer: nil
41 +
42 + @doc false
43 + def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
44 +
45 + @doc """
46 + The token to present to OpenBao, or `nil` when it isn't configured.
47 +
48 + Logs in on first ask rather than at boot: an instance that never
49 + serves an F-Droid repository should never talk to OpenBao at all, and
50 + a boot that waits on a sealed OpenBao is a boot that fails for the
51 + wrong reason.
52 + """
53 + @spec token() :: String.t() | nil
54 + def token do
55 + case GenServer.whereis(__MODULE__) do
56 + nil -> static_token()
57 + _pid -> GenServer.call(__MODULE__, :token, 20_000)
58 + end
59 + end
60 +
61 + @doc "Drop the cached token, so the next ask acquires a new one."
62 + @spec invalidate() :: :ok
63 + def invalidate do
64 + case GenServer.whereis(__MODULE__) do
65 + nil -> :ok
66 + _pid -> GenServer.cast(__MODULE__, :invalidate)
67 + end
68 + end
69 +
70 + @impl true
71 + def init(_opts), do: {:ok, %__MODULE__{}}
72 +
73 + @impl true
74 + def handle_call(:token, _from, state) do
75 + case ensure_token(state) do
76 + {:ok, state} -> {:reply, state.token, state}
77 + {:error, reason, state} -> {:reply, nil, log_failure(reason, state)}
78 + end
79 + end
80 +
81 + @impl true
82 + def handle_cast(:invalidate, state), do: {:noreply, clear(state)}
83 +
84 + @impl true
85 + def handle_info(:renew, state) do
86 + case renew(state) do
87 + {:ok, state} ->
88 + {:noreply, state}
89 +
90 + {:error, reason, state} ->
91 + Logger.warning("openbao: renewal failed (#{inspect(reason)})")
92 +
93 + if role() do
94 + {_result, state} = login_or_log(clear(state))
95 + {:noreply, state}
96 + else
97 + {:noreply, schedule(state, retry_after(state))}
98 + end
99 + end
100 + end
101 +
102 + def handle_info(_msg, state), do: {:noreply, state}
103 +
104 + # ── acquiring ────────────────────────────────────────────────────────
105 +
106 + defp ensure_token(%__MODULE__{token: token} = state) when is_binary(token) do
107 + if expired?(state), do: acquire(clear(state)), else: {:ok, state}
108 + end
109 +
110 + defp ensure_token(state), do: acquire(state)
111 +
112 + defp acquire(state) do
113 + cond do
114 + is_nil(address()) -> {:error, :not_configured, state}
115 + role() -> login(state)
116 + static_token() -> introspect(state, static_token())
117 + true -> {:error, :not_configured, state}
118 + end
119 + end
120 +
121 + defp login(state) do
122 + with {:ok, jwt} <- read_jwt(),
123 + {:ok, %{"auth" => auth}} <-
124 + post("/v1/auth/#{auth_mount()}/login", %{role: role(), jwt: jwt}, nil) do
125 + {:ok, store(state, auth["client_token"], auth["lease_duration"])}
126 + else
127 + {:error, reason} -> {:error, reason, state}
128 + {:ok, _unexpected} -> {:error, :unexpected_response, state}
129 + end
130 + end
131 +
132 + # A static token's lease has to be asked for: the Secret holds the
133 + # token, not its expiry.
134 + defp introspect(state, token) do
135 + case post("/v1/auth/token/lookup-self", nil, token) do
136 + {:ok, %{"data" => data}} ->
137 + {:ok, store(state, token, data["ttl"])}
138 +
139 + {:error, reason} ->
140 + # Can't read its own lease — use it anyway rather than refusing
141 + # to sign. `lookup-self` is granted by the default policy, but a
142 + # policy can be edited.
143 + Logger.warning("openbao: token lookup failed (#{inspect(reason)}); using it unrenewed")
144 + {:ok, %{state | token: token, expires_at: nil}}
145 +
146 + {:ok, _} ->
147 + {:ok, %{state | token: token, expires_at: nil}}
148 + end
149 + end
150 +
151 + defp renew(%__MODULE__{token: nil} = state), do: {:error, :no_token, state}
152 +
153 + defp renew(%__MODULE__{token: token} = state) do
154 + case post("/v1/auth/token/renew-self", %{}, token) do
155 + {:ok, %{"auth" => auth}} -> {:ok, store(state, token, auth["lease_duration"])}
156 + {:ok, _} -> {:error, :unexpected_response, state}
157 + {:error, reason} -> {:error, reason, state}
158 + end
159 + end
160 +
161 + defp login_or_log(state) do
162 + case login(state) do
163 + {:ok, state} -> {:ok, state}
164 + {:error, reason, state} -> {:error, log_failure(reason, state)}
165 + end
166 + end
167 +
168 + # ── lease bookkeeping ────────────────────────────────────────────────
169 +
170 + defp store(state, token, lease) when is_binary(token) do
171 + state = cancel_timer(state)
172 + lease = normalize_lease(lease)
173 +
174 + state = %{
175 + state
176 + | token: token,
177 + expires_at: lease && DateTime.add(DateTime.utc_now(), lease, :second)
178 + }
179 +
180 + if lease, do: schedule(state, round(lease * @renew_fraction)), else: state
181 + end
182 +
183 + defp store(state, _token, _lease), do: state
184 +
185 + # A zero or missing lease means "does not expire" (a root token, or
186 + # one issued with no TTL) — nothing to renew.
187 + defp normalize_lease(lease) when is_integer(lease) and lease > 0, do: lease
188 + defp normalize_lease(_), do: nil
189 +
190 + defp schedule(state, seconds) do
191 + seconds = max(seconds, 1)
192 + %{state | timer: Process.send_after(self(), :renew, seconds * 1000)}
193 + end
194 +
195 + defp retry_after(%__MODULE__{expires_at: nil}), do: 300
196 +
197 + defp retry_after(%__MODULE__{expires_at: expires_at}) do
198 + expires_at
199 + |> DateTime.diff(DateTime.utc_now())
200 + |> div(3)
201 + |> max(30)
202 + |> min(3600)
203 + end
204 +
205 + defp expired?(%__MODULE__{expires_at: nil}), do: false
206 +
207 + defp expired?(%__MODULE__{expires_at: expires_at}),
208 + do: DateTime.compare(DateTime.utc_now(), expires_at) != :lt
209 +
210 + defp clear(state), do: %{cancel_timer(state) | token: nil, expires_at: nil}
211 +
212 + defp cancel_timer(%__MODULE__{timer: nil} = state), do: state
213 +
214 + defp cancel_timer(%__MODULE__{timer: timer} = state) do
215 + Process.cancel_timer(timer)
216 + %{state | timer: nil}
217 + end
218 +
219 + defp log_failure(:not_configured, state), do: state
220 +
221 + defp log_failure(reason, state) do
222 + Logger.warning("openbao: could not obtain a token (#{inspect(reason)})")
223 + state
224 + end
225 +
226 + # ── http ─────────────────────────────────────────────────────────────
227 +
228 + defp post(path, body, token) do
229 + req =
230 + Req.new(
231 + method: :post,
232 + url: address() <> path,
233 + json: body,
234 + headers: token_headers(token),
235 + receive_timeout: 15_000,
236 + retry: :safe_transient
237 + )
238 + |> Req.merge(Application.get_env(:git_gud, :openbao_req_options, []))
239 +
240 + case Req.request(req) do
241 + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
242 + {:ok, body || %{}}
243 +
244 + {:ok, %Req.Response{status: 403, body: body}} ->
245 + {:error, {:denied, errors(body)}}
246 +
247 + {:ok, %Req.Response{status: 503}} ->
248 + {:error, :sealed}
249 +
250 + {:ok, %Req.Response{status: status, body: body}} ->
251 + {:error, {:http, status, errors(body)}}
252 +
253 + {:error, reason} ->
254 + {:error, {:unreachable, reason}}
255 + end
256 + end
257 +
258 + defp token_headers(nil), do: []
259 + defp token_headers(token), do: [{"x-vault-token", token}]
260 +
261 + defp errors(%{"errors" => [_ | _] = errors}), do: Enum.join(errors, "; ")
262 + defp errors(body) when is_binary(body), do: String.slice(body, 0, 200)
263 + defp errors(body), do: body |> inspect() |> String.slice(0, 200)
264 +
265 + defp read_jwt do
266 + case File.read(jwt_path()) do
267 + {:ok, jwt} -> {:ok, String.trim(jwt)}
268 + {:error, reason} -> {:error, {:no_service_account_token, reason}}
269 + end
270 + end
271 +
272 + # ── config ───────────────────────────────────────────────────────────
273 +
274 + defp config(key, default) do
275 + Application.get_env(:git_gud, GitGud.Packages.Signing.OpenBao, [])
276 + |> Keyword.get(key, default)
277 + end
278 +
279 + defp address do
280 + case config(:address, nil) do
281 + address when is_binary(address) and address != "" -> String.trim_trailing(address, "/")
282 + _ -> nil
283 + end
284 + end
285 +
286 + defp role do
287 + case config(:role, nil) do
288 + role when is_binary(role) and role != "" -> role
289 + _ -> nil
290 + end
291 + end
292 +
293 + defp static_token do
294 + case config(:token, nil) do
295 + token when is_binary(token) and token != "" -> token
296 + _ -> nil
297 + end
298 + end
299 +
300 + defp auth_mount, do: config(:auth_mount, "kubernetes")
301 + defp jwt_path, do: config(:jwt_path, @jwt_path)
302 +end
added test/git_gud/packages/signing_open_bao_session_test.exs
+226 −0
@@ -0,0 +1,226 @@
1 +defmodule GitGud.Packages.Signing.OpenBao.SessionTest do
2 + @moduledoc """
3 + How the signer gets a token, and how it keeps one.
4 +
5 + The interesting cases are the ones an operator only meets 32 days
6 + after a deploy: a lease that has to be renewed, a renewal that fails,
7 + and a login that can't happen because nothing is configured.
8 + """
9 + use ExUnit.Case, async: false
10 +
11 + alias GitGud.Packages.Signing.OpenBao
12 + alias GitGud.Packages.Signing.OpenBao.Session
13 +
14 + @address "http://openbao.test:8200"
15 +
16 + setup do
17 + on_exit(fn ->
18 + Application.delete_env(:git_gud, OpenBao)
19 + Application.delete_env(:git_gud, :openbao_req_options)
20 + Session.invalidate()
21 + end)
22 +
23 + # The session is a singleton the application starts, so each test
24 + # has to begin from "no token cached".
25 + Session.invalidate()
26 + :ok
27 + end
28 +
29 + describe "with nothing configured" do
30 + test "hands back no token and makes no request" do
31 + stub(fn _ -> raise "should not have called OpenBao" end)
32 +
33 + assert Session.token() == nil
34 + end
35 +
36 + test "an address with no credentials is still no token" do
37 + configure(address: @address)
38 + stub(fn _ -> raise "should not have called OpenBao" end)
39 +
40 + assert Session.token() == nil
41 + end
42 + end
43 +
44 + describe "with a static token" do
45 + test "asks for its lease, then reuses the token without asking again" do
46 + configure(address: @address, token: "bao-static")
47 +
48 + stub(fn
49 + {conn, "/v1/auth/token/lookup-self", _} -> json(conn, %{"data" => %{"ttl" => 2_764_800}})
50 + {_conn, path, _} -> raise "unexpected #{path}"
51 + end)
52 +
53 + assert Session.token() == "bao-static"
54 + assert_received {:openbao_request, "POST", "/v1/auth/token/lookup-self", _}
55 +
56 + # Cached: a second ask is free.
57 + assert Session.token() == "bao-static"
58 + refute_received {:openbao_request, "POST", "/v1/auth/token/lookup-self", _}
59 + end
60 +
61 + test "renews before the lease runs out, keeping the same token" do
62 + configure(address: @address, token: "bao-static")
63 +
64 + stub(fn
65 + {conn, "/v1/auth/token/lookup-self", _} ->
66 + json(conn, %{"data" => %{"ttl" => 60}})
67 +
68 + {conn, "/v1/auth/token/renew-self", _} ->
69 + json(conn, %{"auth" => %{"lease_duration" => 60}})
70 +
71 + {_conn, path, _} ->
72 + raise "unexpected #{path}"
73 + end)
74 +
75 + assert Session.token() == "bao-static"
76 +
77 + # Don't wait 40 seconds for the timer; ask for the renewal the
78 + # timer would have asked for.
79 + send(Process.whereis(Session), :renew)
80 +
81 + assert_receive {:openbao_request, "POST", "/v1/auth/token/renew-self", _}, 1_000
82 + assert Session.token() == "bao-static"
83 + end
84 +
85 + test "a failed renewal keeps the token it has rather than signing with nothing" do
86 + configure(address: @address, token: "bao-static")
87 +
88 + stub(fn
89 + {conn, "/v1/auth/token/lookup-self", _} -> json(conn, %{"data" => %{"ttl" => 600}})
90 + {conn, "/v1/auth/token/renew-self", _} -> error(conn, 403, "permission denied")
91 + {_conn, path, _} -> raise "unexpected #{path}"
92 + end)
93 +
94 + assert Session.token() == "bao-static"
95 +
96 + send(Process.whereis(Session), :renew)
97 + assert_receive {:openbao_request, "POST", "/v1/auth/token/renew-self", _}, 1_000
98 +
99 + # Still usable: the token is valid until its own expiry, and
100 + # refusing to sign early would be a worse failure than trying.
101 + assert Session.token() == "bao-static"
102 + end
103 +
104 + test "a token whose lease can't be read is used anyway" do
105 + configure(address: @address, token: "bao-static")
106 +
107 + stub(fn
108 + {conn, "/v1/auth/token/lookup-self", _} -> error(conn, 403, "permission denied")
109 + {_conn, path, _} -> raise "unexpected #{path}"
110 + end)
111 +
112 + assert Session.token() == "bao-static"
113 + end
114 + end
115 +
116 + describe "with Kubernetes auth" do
117 + setup do
118 + path = Path.join(System.tmp_dir!(), "sa-token-#{System.unique_integer([:positive])}")
119 + File.write!(path, "the-projected-jwt\n")
120 + on_exit(fn -> File.rm(path) end)
121 + %{jwt_path: path}
122 + end
123 +
124 + test "logs in with the pod's ServiceAccount JWT", ctx do
125 + configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path)
126 +
127 + stub(fn
128 + {conn, "/v1/auth/kubernetes/login", body} ->
129 + # The role and the JWT are the whole credential; a trailing
130 + # newline in the projected file would be rejected by OpenBao.
131 + assert body["role"] == "gitgud-fdroid"
132 + assert body["jwt"] == "the-projected-jwt"
133 +
134 + json(conn, %{"auth" => %{"client_token" => "bao-short-lived", "lease_duration" => 3600}})
135 +
136 + {_conn, path, _} ->
137 + raise "unexpected #{path}"
138 + end)
139 +
140 + assert Session.token() == "bao-short-lived"
141 + # No static token was needed anywhere.
142 + assert_received {:openbao_request, "POST", "/v1/auth/kubernetes/login", _}
143 + end
144 +
145 + test "a rejected login is reported as no token, not a stale one", ctx do
146 + configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path)
147 +
148 + stub(fn
149 + {conn, "/v1/auth/kubernetes/login", _} -> error(conn, 403, "role not found")
150 + {_conn, path, _} -> raise "unexpected #{path}"
151 + end)
152 +
153 + assert Session.token() == nil
154 + end
155 +
156 + test "a missing ServiceAccount token is a clear failure, not a crash", %{jwt_path: path} do
157 + File.rm!(path)
158 + configure(address: @address, role: "gitgud-fdroid", jwt_path: path)
159 + stub(fn _ -> raise "should not have reached the network" end)
160 +
161 + assert Session.token() == nil
162 + end
163 +
164 + test "a lease that runs out is replaced rather than handed out stale", ctx do
165 + configure(address: @address, role: "gitgud-fdroid", jwt_path: ctx.jwt_path)
166 +
167 + stub(fn
168 + {conn, "/v1/auth/kubernetes/login", _} ->
169 + # Zero lease reads as "no expiry" — so use a 1-second one and
170 + # let it pass.
171 + json(conn, %{
172 + "auth" => %{
173 + "client_token" => "bao-#{System.unique_integer([:positive])}",
174 + "lease_duration" => 1
175 + }
176 + })
177 +
178 + # The renewal timer fires inside this window too; refusing it
179 + # is what forces the login path to be the one that recovers.
180 + {conn, "/v1/auth/token/renew-self", _} ->
181 + error(conn, 403, "not renewable")
182 +
183 + {_conn, path, _} ->
184 + raise "unexpected #{path}"
185 + end)
186 +
187 + first = Session.token()
188 + assert is_binary(first)
189 +
190 + Process.sleep(1_100)
191 + second = Session.token()
192 +
193 + assert is_binary(second)
194 + assert second != first
195 + end
196 + end
197 +
198 + # ── helpers ──────────────────────────────────────────────────────────
199 +
200 + defp configure(opts), do: Application.put_env(:git_gud, OpenBao, opts)
201 +
202 + defp stub(handler) when is_function(handler, 1) do
203 + test = self()
204 +
205 + Application.put_env(:git_gud, :openbao_req_options,
206 + plug: fn conn ->
207 + {:ok, raw, conn} = Plug.Conn.read_body(conn)
208 + body = if raw == "", do: %{}, else: Jason.decode!(raw)
209 + send(test, {:openbao_request, conn.method, conn.request_path, body})
210 + handler.({conn, conn.request_path, body})
211 + end
212 + )
213 + end
214 +
215 + defp json(conn, payload) do
216 + conn
217 + |> Plug.Conn.put_resp_content_type("application/json")
218 + |> Plug.Conn.resp(200, Jason.encode!(payload))
219 + end
220 +
221 + defp error(conn, status, message) do
222 + conn
223 + |> Plug.Conn.put_resp_content_type("application/json")
224 + |> Plug.Conn.resp(status, Jason.encode!(%{"errors" => [message]}))
225 + end
226 +end
modified test/git_gud/packages/signing_open_bao_test.exs
+25 −6
@@ -13,6 +13,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
13 13 alias GitGud.Accounts.User
14 14 alias GitGud.Packages.Signing
15 15 alias GitGud.Packages.Signing.OpenBao
16 + alias GitGud.Packages.Signing.OpenBao.Session
16 17
17 18 @address "http://openbao.test:8200"
18 19
@@ -29,8 +30,14 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
29 30 Application.delete_env(:git_gud, OpenBao)
30 31 Application.delete_env(:git_gud, :openbao_req_options)
31 32 Application.delete_env(:git_gud, Signing)
33 + Session.invalidate()
32 34 end)
33 35
36 + # The session holding the token is a singleton the application
37 + # starts, so each test begins from "no token cached" — otherwise
38 + # these pass or fail depending on what ran before them.
39 + Session.invalidate()
40 +
34 41 %{user: %User{handle: "neiam"}}
35 42 end
36 43
@@ -44,7 +51,15 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
44 51 {:ok, raw, conn} = Plug.Conn.read_body(conn)
45 52 body = if raw == "", do: %{}, else: Jason.decode!(raw)
46 53 send(test, {:openbao_request, conn.method, conn.request_path, body})
47 − handler.({conn, conn.request_path, body})
54 +
55 + # Intercepted here rather than in every handler: the session
56 + # asks a static token for its lease before the first signature,
57 + # and that is not what any of these tests are about.
58 + if conn.request_path == "/v1/auth/token/lookup-self" do
59 + json(conn, 200, lookup_self_response())
60 + else
61 + handler.({conn, conn.request_path, body})
62 + end
48 63 end
49 64 )
50 65 end
@@ -58,14 +73,18 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
58 73 defp signature_response(bytes),
59 74 do: %{"data" => %{"signature" => "vault:v1:" <> Base.encode64(bytes)}}
60 75
76 + # The session asks a static token for its own lease before first use.
77 + # Answering it here keeps these tests about signing.
78 + defp lookup_self_response, do: %{"data" => %{"ttl" => 3600, "renewable" => true}}
79 +
61 80 describe "sign/4" do
62 81 test "asks for PKCS#1 v1.5 over SHA-256 by default", %{user: user} do
63 82 stub(fn {conn, _path, _body} -> json(conn, 200, signature_response("sig-bytes")) end)
64 83
65 84 assert {:ok, "sig-bytes"} = OpenBao.sign(user, "fdroid", "data to sign", [])
66 85
67 − assert_receive {:openbao_request, "POST", path, body}
68 − assert path == "/v1/transit/sign/gitgud-fdroid-#{user.handle}"
86 + assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> key, body}
87 + assert key == "gitgud-fdroid-#{user.handle}"
69 88 assert body["signature_algorithm"] == "pkcs1v15"
70 89 assert body["hash_algorithm"] == "sha2-256"
71 90 assert body["input"] == Base.encode64("data to sign")
@@ -80,7 +99,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
80 99
81 100 assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", [])
82 101
83 − assert_receive {:openbao_request, "POST", _, body}
102 + assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> _, body}
84 103 assert body["prehashed"] == false
85 104 refute body["hash_algorithm"] == "none"
86 105 end
@@ -90,7 +109,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
90 109
91 110 assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", digest: :sha1)
92 111
93 − assert_receive {:openbao_request, "POST", _, body}
112 + assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> _, body}
94 113 assert body["hash_algorithm"] == "sha1"
95 114 end
96 115
@@ -130,7 +149,7 @@ defmodule GitGud.Packages.Signing.OpenBaoTest do
130 149
131 150 assert {:ok, %{public_pem: ^pem}} = OpenBao.ensure_key(user, "fdroid")
132 151
133 − assert_receive {:openbao_request, "POST", _, create}
152 + assert_receive {:openbao_request, "POST", "/v1/transit/keys/" <> _, create}
134 153 assert create["type"] == "rsa-4096"
135 154 # Without this the guarantee this backend claims is just a comment.
136 155 assert create["exportable"] == false

Parents: ea9cadf