defmodule GitGud.Packages.Signing.OpenBaoTest do
@moduledoc """
The OpenBao transit signing backend.
The transit engine is stubbed with a Plug, the way the import
providers' HTTP layer already is. What is worth pinning down is the
request *shape*: a signature that comes back correct but was asked for
with the wrong `signature_algorithm` produces a JAR that F-Droid
rejects, and no amount of round-tripping locally would catch it.
"""
use ExUnit.Case, async: false
alias GitGud.Accounts.User
alias GitGud.Packages.Signing
alias GitGud.Packages.Signing.OpenBao
alias GitGud.Packages.Signing.OpenBao.Session
@address "http://openbao.test:8200"
setup do
Application.put_env(:git_gud, OpenBao,
address: @address,
token: "bao-test-token",
mount: "transit",
key_type: "rsa-4096",
key_prefix: "gitgud"
)
on_exit(fn ->
Application.delete_env(:git_gud, OpenBao)
Application.delete_env(:git_gud, :openbao_req_options)
Application.delete_env(:git_gud, Signing)
Session.invalidate()
end)
# The session holding the token is a singleton the application
# starts, so each test begins from "no token cached" — otherwise
# these pass or fail depending on what ran before them.
Session.invalidate()
%{user: %User{handle: "neiam"}}
end
# Records every request so the assertions can look at what was sent,
# not just at what came back.
defp stub(handler) when is_function(handler, 1) do
test = self()
Application.put_env(:git_gud, :openbao_req_options,
plug: fn conn ->
{:ok, raw, conn} = Plug.Conn.read_body(conn)
body = if raw == "", do: %{}, else: Jason.decode!(raw)
send(test, {:openbao_request, conn.method, conn.request_path, body})
# Intercepted here rather than in every handler: the session
# asks a static token for its lease before the first signature,
# and that is not what any of these tests are about.
if conn.request_path == "/v1/auth/token/lookup-self" do
json(conn, 200, lookup_self_response())
else
handler.({conn, conn.request_path, body})
end
end
)
end
defp json(conn, status, body) do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.resp(status, Jason.encode!(body))
end
defp signature_response(bytes),
do: %{"data" => %{"signature" => "vault:v1:" <> Base.encode64(bytes)}}
# The session asks a static token for its own lease before first use.
# Answering it here keeps these tests about signing.
defp lookup_self_response, do: %{"data" => %{"ttl" => 3600, "renewable" => true}}
describe "sign/4" do
test "asks for PKCS#1 v1.5 over SHA-256 by default", %{user: user} do
stub(fn {conn, _path, _body} -> json(conn, 200, signature_response("sig-bytes")) end)
assert {:ok, "sig-bytes"} = OpenBao.sign(user, "fdroid", "data to sign", [])
assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> key, body}
assert key == "gitgud-fdroid-#{user.handle}"
assert body["signature_algorithm"] == "pkcs1v15"
assert body["hash_algorithm"] == "sha2-256"
assert body["input"] == Base.encode64("data to sign")
end
test "never sends prehashed input" do
# `hash_algorithm: none` + prehashed yields a PKCSv1_5_NoOID
# signature, which CMS won't accept — so the raw bytes go up and
# OpenBao does the hashing.
user = %User{handle: "neiam"}
stub(fn {conn, _, _} -> json(conn, 200, signature_response("s")) end)
assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", [])
assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> _, body}
assert body["prehashed"] == false
refute body["hash_algorithm"] == "none"
end
test "honours sha1 for index-v1", %{user: user} do
stub(fn {conn, _, _} -> json(conn, 200, signature_response("s")) end)
assert {:ok, _} = OpenBao.sign(user, "fdroid", "x", digest: :sha1)
assert_receive {:openbao_request, "POST", "/v1/transit/sign/" <> _, body}
assert body["hash_algorithm"] == "sha1"
end
test "strips the key-version prefix from the signature", %{user: user} do
stub(fn {conn, _, _} ->
json(conn, 200, %{"data" => %{"signature" => "vault:v7:" <> Base.encode64("raw")}})
end)
assert {:ok, "raw"} = OpenBao.sign(user, "fdroid", "x", [])
end
test "accepts a signature with no prefix", %{user: user} do
stub(fn {conn, _, _} ->
json(conn, 200, %{"data" => %{"signature" => Base.encode64("raw")}})
end)
assert {:ok, "raw"} = OpenBao.sign(user, "fdroid", "x", [])
end
end
describe "ensure_key/2" do
test "creates a non-exportable key and returns its public half", %{user: user} do
pem = "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----\n"
stub(fn
{conn, "/v1/transit/keys/" <> _, _body} when conn.method == "POST" ->
json(conn, 204, %{})
{conn, "/v1/transit/keys/" <> _, _} ->
json(conn, 200, %{
"data" => %{
"latest_version" => 2,
"keys" => %{"1" => %{}, "2" => %{"public_key" => pem}}
}
})
end)
assert {:ok, %{public_pem: ^pem}} = OpenBao.ensure_key(user, "fdroid")
assert_receive {:openbao_request, "POST", "/v1/transit/keys/" <> _, create}
assert create["type"] == "rsa-4096"
# Without this the guarantee this backend claims is just a comment.
assert create["exportable"] == false
assert create["allow_plaintext_backup"] == false
end
test "reports a key with no public half at the latest version", %{user: user} do
stub(fn
{conn, _, _} when conn.method == "POST" ->
json(conn, 204, %{})
{conn, _, _} ->
json(conn, 200, %{"data" => %{"latest_version" => 3, "keys" => %{}}})
end)
assert {:error, {:openbao, message}} = OpenBao.ensure_key(user, "fdroid")
assert message =~ "no public key"
end
end
describe "failure modes worth naming" do
test "a sealed OpenBao says so", %{user: user} do
stub(fn {conn, _, _} -> json(conn, 503, %{"errors" => ["sealed"]}) end)
assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", [])
assert message =~ "sealed"
end
test "a policy denial points at the token", %{user: user} do
stub(fn {conn, _, _} -> json(conn, 403, %{"errors" => ["permission denied"]}) end)
assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", [])
assert message =~ "capabilities"
end
test "an unmounted engine points at the mount", %{user: user} do
stub(fn {conn, _, _} -> json(conn, 404, %{}) end)
assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", [])
assert message =~ "transit engine mounted"
end
test "no configured address is an error, not a crash", %{user: user} do
Application.put_env(:git_gud, OpenBao, token: "t")
assert {:error, {:openbao, message}} = OpenBao.sign(user, "fdroid", "x", [])
assert message =~ "no address configured"
end
end
describe "backend selection" do
test "defaults to Local so an existing deployment changes nothing" do
assert Signing.backend("deb") == GitGud.Packages.Signing.Local
end
test "a format opts in by name" do
Application.put_env(:git_gud, Signing, formats: %{"fdroid" => OpenBao})
assert Signing.backend("fdroid") == OpenBao
assert Signing.backend("deb") == GitGud.Packages.Signing.Local
assert Signing.external?("fdroid")
refute Signing.external?("deb")
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.6 KiB · text
History
b868907