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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
6.4 KiB · text
History
b868907