6.4 KiB · text History 43c3d8d
defmodule GitGud.Packages.Fdroid.SignatureTest do
  @moduledoc """
  Reading the signing certificate out of an APK.

  The APK is assembled here rather than committed: a real one is ~19 MB,
  and building the signing block from its documented layout is what
  actually exercises the parser. The certificate inside it *is* real —
  `debug-signer.der` is the `CN=Android Debug` certificate lifted out of
  this repo's own `app-debug.apk`, so `:public_key` has genuine X.509 to
  validate rather than a hand-rolled approximation.

  There is also a tagged test against the built APK, for when one is
  lying around. It asserts shape rather than a specific fingerprint:
  AGP generates `~/.android/debug.keystore` per machine, so the value
  differs for every developer.
  """
  use ExUnit.Case, async: true

  alias GitGud.Packages.Formats.Fdroid.Signature

  @magic "APK Sig Block 42"
  @v2_id 0x7109871A
  @v3_id 0xF05368C0

  @cert_fixture "test/support/fixtures/apk/debug-signer.der"
  @built_apk "android/app/build/outputs/apk/debug/app-debug.apk"

  defp cert, do: File.read!(@cert_fixture)

  # u32 length-prefixed, the framing the whole scheme nests with.
  defp lp(data), do: <<byte_size(data)::little-32, data::binary>>

  # signers → signer → signed data → { digests, certificates → cert }
  #
  # `certificates` is a length-prefixed *sequence* whose elements are
  # themselves length-prefixed, so a single certificate nests twice.
  defp scheme_value(der) do
    certificates = lp(lp(der))
    digests = lp("fake digest")
    signed_data = lp(digests <> certificates)
    signer = lp(signed_data)
    lp(signer)
  end

  defp pair(id, value), do: <<byte_size(value) + 4::little-64, id::little-32, value::binary>>

  defp signing_block(pairs) do
    size = byte_size(pairs) + 24
    <<size::little-64, pairs::binary, size::little-64, @magic::binary>>
  end

  @doc false
  # A zip with the block spliced in where an APK carries one: after the
  # last entry, before the central directory. The EOCD's pointer to the
  # central directory has to move by exactly the block's length.
  defp apk_with(block) do
    {:ok, {_name, zip}} =
      :zip.create(~c"t.apk", [{~c"AndroidManifest.xml", "not really a manifest"}], [:memory])

    eocd = find_eocd(zip)
    <<_::binary-size(16), cd_offset::little-32, _::binary>> = binary_part(zip, eocd, 22)

    before = binary_part(zip, 0, cd_offset)
    after_cd = binary_part(zip, cd_offset, byte_size(zip) - cd_offset)
    shifted = cd_offset + byte_size(block)

    # The EOCD itself moves by the block's length, since the block is
    # spliced in ahead of it.
    patched_eocd = eocd + byte_size(block)
    body = before <> block <> after_cd

    prefix = patched_eocd + 16
    <<head::binary-size(^prefix), _old::little-32, tail::binary>> = body
    head <> <<shifted::little-32>> <> tail
  end

  defp find_eocd(zip), do: find_eocd(zip, byte_size(zip) - 22)

  defp find_eocd(zip, pos) do
    case binary_part(zip, pos, 4) do
      <<0x50, 0x4B, 0x05, 0x06>> -> pos
      _ -> find_eocd(zip, pos - 1)
    end
  end

  describe "APK signing block" do
    test "reads the certificate out of a v2 block" do
      apk = apk_with(signing_block(pair(@v2_id, scheme_value(cert()))))

      assert {:ok, result} = Signature.certificate(apk)
      assert result.scheme == :v2
      assert result.der == cert()
      assert result.sha256 == expected_fingerprint()
    end

    test "prefers v3 over v2 when both are present" do
      # v3 is the scheme that carries rotation, so where both exist its
      # certificate is the current one.
      pairs = pair(@v2_id, scheme_value(cert())) <> pair(@v3_id, scheme_value(cert()))

      assert {:ok, %{scheme: :v3}} = Signature.certificate(apk_with(signing_block(pairs)))
    end

    test "skips apksigner's padding block" do
      pairs = pair(0x42726577, :binary.copy(<<0>>, 64)) <> pair(@v2_id, scheme_value(cert()))

      assert {:ok, %{scheme: :v2}} = Signature.certificate(apk_with(signing_block(pairs)))
    end

    test "reports a block carrying neither v2 nor v3" do
      pairs = pair(0x42726577, :binary.copy(<<0>>, 64))

      assert {:error, {422, message}} = Signature.certificate(apk_with(signing_block(pairs)))
      assert message =~ "no v2 or v3"
    end

    test "refuses a certificate that isn't X.509" do
      apk = apk_with(signing_block(pair(@v2_id, scheme_value("nope, just some bytes"))))

      assert {:error, {422, message}} = Signature.certificate(apk)
      assert message =~ "not valid X.509"
    end

    test "reports a truncated scheme block rather than raising" do
      value = scheme_value(cert())
      truncated = binary_part(value, 0, div(byte_size(value), 2))

      assert {:error, {422, message}} =
               Signature.certificate(apk_with(signing_block(pair(@v2_id, truncated))))

      assert message =~ "malformed"
    end
  end

  describe "falling back to META-INF" do
    test "an APK with neither a block nor META-INF reads as unsigned" do
      {:ok, {_name, zip}} =
        :zip.create(~c"t.apk", [{~c"AndroidManifest.xml", "x"}], [:memory])

      assert {:error, {422, message}} = Signature.certificate(zip)
      assert message =~ "unsigned"
    end
  end

  describe "rejecting input" do
    test "refuses something that isn't a zip" do
      assert {:error, {422, _}} = Signature.certificate("hello")
      assert {:error, {422, _}} = Signature.certificate(<<>>)
      assert {:error, {422, _}} = Signature.certificate(:binary.copy(<<0>>, 1000))
    end

    test "terminates on truncations of a well-formed APK" do
      apk = apk_with(signing_block(pair(@v2_id, scheme_value(cert()))))

      for len <- Enum.take_every(1..byte_size(apk), 53) do
        case Signature.certificate(binary_part(apk, 0, len)) do
          {:ok, result} -> assert is_binary(result.sha256)
          {:error, {422, _}} -> :ok
        end
      end
    end
  end

  describe "the APK this repo builds" do
    @tag :built_apk
    test "reads a v2 certificate out of it" do
      unless File.exists?(@built_apk) do
        # `mix test --include built_apk` after ./gradlew :app:assembleDebug.
        flunk("#{@built_apk} not built")
      end

      assert {:ok, result} = Signature.certificate(File.read!(@built_apk))

      # AGP omits v1 above minSdk 24, and the app's minSdk is 26.
      assert result.scheme == :v2
      assert String.match?(result.sha256, ~r/\A[0-9a-f]{64}\z/)
    end
  end

  defp expected_fingerprint,
    do: :sha256 |> :crypto.hash(cert()) |> Base.encode16(case: :lower)
end