7.6 KiB · text History b868907
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