6.2 KiB · text History 1a40fc9
defmodule GitGud.Packages.Formats.Fdroid.JarTest do
  @moduledoc """
  Signing `entry.jar`.

  The point of this file is the `jarsigner -verify` test. Everything
  here — the manifest format, the signature file, the PKCS#7 DER, the
  self-signed certificate — is hand-written, and hand-written ASN.1 has
  a particular failure mode: it looks completely fine and a verifier
  rejects it. Checking our own arithmetic proves nothing. Handing the
  result to the reference implementation proves something.

  `jarsigner` is tagged so the suite still runs where a JDK isn't
  installed, but it is the test that matters.
  """
  use GitGud.DataCase, async: false

  import GitGud.ForgeFixtures

  alias GitGud.Packages.Formats.Fdroid.Jar
  alias GitGud.Packages.Formats.Fdroid.Pkcs7

  setup do
    {user, _repo} = repository_fixture()
    %{scope: user}
  end

  describe "the certificate" do
    test "is a self-signed X.509 that :public_key can parse", %{scope: scope} do
      assert {:ok, der} = Jar.certificate(scope)

      # If OTP's decoder accepts it, the structure is right — this is
      # the same parse a client does before checking the signature.
      cert = :public_key.pkix_decode_cert(der, :plain)
      assert elem(cert, 0) == :Certificate

      assert {:ok, _issuer, serial} = Pkcs7.issuer_and_serial(der)
      assert serial > 0
    end

    test "fingerprints as lowercase sha256 hex", %{scope: scope} do
      assert {:ok, der} = Jar.certificate(scope)
      assert String.match?(Jar.fingerprint(der), ~r/\A[0-9a-f]{64}\z/)
    end

    test "is stable across calls — the fingerprint is what users pin", %{scope: scope} do
      # The key is minted once and reused, so the certificate's public
      # half doesn't move. Regenerating it would be standing up a new
      # repository that every phone has to add again.
      {:ok, first} = Jar.certificate(scope)
      {:ok, second} = Jar.certificate(scope)

      assert public_key_of(first) == public_key_of(second)
    end
  end

  describe "the manifest" do
    test "wraps long lines the way the format requires", %{scope: scope} do
      long = String.duplicate("a", 300)
      {:ok, jar, _} = Jar.build(scope, [{long <> ".json", "x"}])

      manifest = member(jar, ~c"META-INF/MANIFEST.MF")

      # Lines cap at 72 bytes including CRLF, and continuations start
      # with exactly one space. A digest is taken over these exact
      # bytes, so getting it wrong verifies wrong while looking right.
      for line <- String.split(manifest, "\r\n"), line != "" do
        assert byte_size(line) <= 70
      end

      assert manifest =~ "\r\n "
    end

    test "digests every entry", %{scope: scope} do
      {:ok, jar, _} = Jar.build(scope, [{"entry.json", "{\"a\":1}"}])
      manifest = member(jar, ~c"META-INF/MANIFEST.MF")

      expected = :sha256 |> :crypto.hash("{\"a\":1}") |> Base.encode64()

      assert manifest =~ "Name: entry.json"
      assert manifest =~ "SHA-256-Digest: #{expected}"
    end
  end

  describe "the jar" do
    test "carries the three signature members plus the content", %{scope: scope} do
      {:ok, jar, _} = Jar.build(scope, [{"entry.json", "{}"}])

      names = jar |> member_names() |> MapSet.new()

      assert "META-INF/MANIFEST.MF" in names
      assert "META-INF/CERT.SF" in names
      assert "META-INF/CERT.RSA" in names
      assert "entry.json" in names
    end

    test "returns the fingerprint alongside", %{scope: scope} do
      {:ok, _jar, fingerprint} = Jar.build(scope, [{"entry.json", "{}"}])
      {:ok, der} = Jar.certificate(scope)

      assert fingerprint == Jar.fingerprint(der)
    end

    test "CERT.SF digests the manifest", %{scope: scope} do
      {:ok, jar, _} = Jar.build(scope, [{"entry.json", "{}"}])

      manifest = member(jar, ~c"META-INF/MANIFEST.MF")
      sf = member(jar, ~c"META-INF/CERT.SF")
      expected = :sha256 |> :crypto.hash(manifest) |> Base.encode64()

      assert sf =~ "SHA-256-Digest-Manifest: #{expected}"
    end
  end

  describe "jarsigner" do
    @describetag :jarsigner

    test "verifies the signature", %{scope: scope} do
      unless System.find_executable("jarsigner") do
        flunk("jarsigner not installed — run with --exclude jarsigner to skip")
      end

      {:ok, jar, _fingerprint} = Jar.build(scope, [{"entry.json", ~s({"version":20002})}])

      path =
        Path.join(System.tmp_dir!(), "gitgud-entry-#{System.unique_integer([:positive])}.jar")

      File.write!(path, jar)
      on_exit(fn -> File.rm(path) end)

      {output, status} =
        System.cmd("jarsigner", ["-verify", "-strict", path], stderr_to_stdout: true)

      # This is the assertion the whole module exists for: the PKCS#7,
      # the manifest and the signature file are all hand-written, and
      # hand-written ASN.1 fails by looking correct. The reference
      # implementation says otherwise or it doesn't.
      assert output =~ "jar verified", "jarsigner said:\n#{output}"

      refute output =~ "jar is unsigned"
      refute output =~ "not parsable or not verifiable"
      refute output =~ "does not match"

      # `-strict` reports the chain as invalid and exits 4, because the
      # certificate is self-signed and there is nothing to chain it to.
      # That is the design: F-Droid pins this certificate by SHA-256
      # and never builds a path. Asserting on it rather than ignoring
      # it, so a *different* signer error can't hide in the same exit
      # code.
      assert status in [0, 4], "jarsigner exited #{status}:\n#{output}"

      if status == 4 do
        assert output =~ "self-signed",
               "exit 4 should only ever be the self-signed chain:\n#{output}"
      end
    end
  end

  # ── helpers ──────────────────────────────────────────────────────────

  defp member(jar, name) do
    {:ok, entries} = :zip.unzip(jar, [:memory])
    {^name, content} = Enum.find(entries, fn {n, _} -> n == name end)
    content
  end

  defp member_names(jar) do
    {:ok, entries} = :zip.unzip(jar, [:memory])
    Enum.map(entries, fn {name, _} -> List.to_string(name) end)
  end

  defp public_key_of(der) do
    der
    |> :public_key.pkix_decode_cert(:otp)
    |> then(fn cert ->
      cert |> elem(1) |> elem(7)
    end)
  end
end