11.6 KiB · text History b868907
defmodule GitGud.Packages.Formats.Fdroid.Signature do
  @moduledoc """
  Pulls an APK's signing certificate out, for the index's `signer`
  field.

  F-Droid records the SHA-256 of the signing certificate and refuses an
  update whose signer differs from what is installed — it is the thing
  standing between a repository and someone replacing an app with their
  own build of it. So the forge has to know it at publish time.

  The obvious place is `META-INF/*.RSA` (v1, JAR signing). For anything
  modern **it is not there**: AGP omits v1 above `minSdk` 24, and this
  repo's own APK carries only an APK Signing Block. So the block comes
  first and `META-INF` is the fallback, not the other way round.

  ## Layout

  The signing block sits between the last zip entry and the central
  directory, and is found by walking back from the end-of-central-
  directory record:

      EOCD (PK\\x05\\x06) → central directory offset
       [cd - 16, cd)        magic "APK Sig Block 42"
       [cd - 24, cd - 16)   u64 size, repeated at the block's start
       block = u64 size ‖ { u64 len ‖ u32 id ‖ value }* ‖ u64 size ‖ magic

  Scheme v3 is `0xf05368c0`, v2 is `0x7109871a`, and `0x42726577` is
  apksigner's padding. v3 is preferred where present: it is the scheme
  that carries rotation, so its certificate is the current one.

  A v2/v3 value nests length-prefixed (u32, little-endian) throughout:

      signers → signer → signed data → certificates → certificate (DER)

  ## What this does not do

  **It does not verify the signature.** The certificate is read, not
  checked against the APK's contents. The F-Droid client verifies, and
  Android verifies again at install; re-implementing v2 digest
  verification here would be a third binary format for no guarantee
  that isn't already made twice downstream.

  What it *does* guarantee is that the bytes are a well-formed X.509
  certificate, because `:public_key` has to parse them before the
  fingerprint is taken.
  """

  alias GitGud.Packages.Formats.Fdroid.Zip

  @v2_block_id 0x7109871A
  @v3_block_id 0xF05368C0

  @magic "APK Sig Block 42"

  # The EOCD is last, but a zip comment can follow it — up to 0xFFFF
  # bytes, plus the record's own 22.
  @eocd_search_limit 0xFFFF + 22

  @zip64_sentinel 0xFFFFFFFF

  @type result :: %{
          der: binary(),
          sha256: String.t(),
          scheme: :v2 | :v3 | :v1
        }

  @doc """
  The signing certificate of `apk`.

  Returns `{:ok, %{der: …, sha256: …, scheme: …}}` where `sha256` is
  lowercase hex of the DER — the form F-Droid's `signer` takes — or
  `{:error, {status, message}}` shaped for the registry controller.
  """
  @spec certificate(binary()) :: {:ok, result()} | {:error, {integer(), String.t()}}
  def certificate(apk) when is_binary(apk) do
    case signing_block(apk) do
      {:ok, blocks} -> from_block(blocks)
      # No signing block is not yet an error: a v1-only APK is legal for
      # minSdk < 24, and its certificate lives in META-INF.
      :none -> from_meta_inf(apk)
      {:error, _} = err -> err
    end
  end

  def certificate(_), do: {:error, {422, "not an APK"}}

  # ── the signing block ────────────────────────────────────────────────

  defp signing_block(apk) do
    with {:ok, cd_offset} <- central_directory_offset(apk),
         true <- cd_offset >= 24 and cd_offset <= byte_size(apk),
         @magic <- binary_part(apk, cd_offset - 16, 16),
         <<size::little-64>> <- binary_part(apk, cd_offset - 24, 8),
         # `size` counts from just after the leading size field to the
         # end of the magic, so the block starts 8 bytes before that.
         start when start >= 0 <- cd_offset - 8 - size,
         true <- size >= 24 and start + 8 + size <= byte_size(apk) do
      pairs = binary_part(apk, start + 8, size - 24)
      {:ok, id_value_pairs(pairs, %{})}
    else
      {:error, _} = err -> err
      _ -> :none
    end
  end

  defp central_directory_offset(apk) when byte_size(apk) < 22,
    do: {:error, {422, "not a zip archive"}}

  defp central_directory_offset(apk) do
    window = min(byte_size(apk), @eocd_search_limit)
    tail = binary_part(apk, byte_size(apk) - window, window)

    case find_eocd(tail, byte_size(tail) - 22) do
      nil ->
        {:error, {422, "not a zip archive — no end-of-central-directory record"}}

      pos ->
        case binary_part(tail, pos, 22) do
          <<_::binary-size(16), @zip64_sentinel::little-32, _::binary>> ->
            {:error, {422, "zip64 APKs are not supported"}}

          <<_::binary-size(16), offset::little-32, _::binary>> ->
            {:ok, offset}
        end
    end
  end

  # Backwards, because the record we want is the last one — a stored
  # entry whose data happens to contain the signature would otherwise
  # win.
  defp find_eocd(_tail, pos) when pos < 0, do: nil

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

  defp id_value_pairs(<<>>, acc), do: acc

  defp id_value_pairs(<<len::little-64, rest::binary>>, acc)
       when len >= 4 and len - 4 <= byte_size(rest) - 4 do
    <<id::little-32, remainder::binary>> = rest
    value_len = len - 4
    value = binary_part(remainder, 0, value_len)
    tail = binary_part(remainder, value_len, byte_size(remainder) - value_len)

    id_value_pairs(tail, Map.put_new(acc, id, value))
  end

  # A malformed or truncated pair ends the walk with what was read
  # rather than failing: the padding block is last and its length is
  # the one most likely to disagree with a rewritten archive.
  defp id_value_pairs(_trailing, acc), do: acc

  defp from_block(blocks) do
    cond do
      value = Map.get(blocks, @v3_block_id) -> parse_scheme(value, :v3)
      value = Map.get(blocks, @v2_block_id) -> parse_scheme(value, :v2)
      true -> {:error, {422, "APK signing block carries no v2 or v3 signature"}}
    end
  end

  # signers → signer → signed data → certificates → first certificate.
  defp parse_scheme(value, scheme) do
    with {:ok, signers} <- length_prefixed(value),
         {:ok, signer} <- length_prefixed(signers),
         {:ok, signed_data} <- length_prefixed(signer),
         # Inside signed data: digests, then certificates.
         {:ok, _digests, after_digests} <- take_length_prefixed(signed_data),
         {:ok, certificates, _} <- take_length_prefixed(after_digests),
         {:ok, der} <- length_prefixed(certificates) do
      fingerprint(der, scheme)
    else
      _ -> {:error, {422, "APK #{scheme} signature block is malformed"}}
    end
  end

  # The first length-prefixed element of a sequence.
  defp length_prefixed(data) do
    case take_length_prefixed(data) do
      {:ok, value, _rest} -> {:ok, value}
      :error -> :error
    end
  end

  defp take_length_prefixed(<<len::little-32, rest::binary>>) when len <= byte_size(rest),
    do: {:ok, binary_part(rest, 0, len), binary_part(rest, len, byte_size(rest) - len)}

  defp take_length_prefixed(_), do: :error

  # ── v1 / META-INF fallback ───────────────────────────────────────────

  # A v1-signed APK keeps its certificate inside a PKCS#7 SignedData
  # blob at META-INF/<name>.{RSA,DSA,EC}. `:public_key` has no PKCS#7
  # decoder, so the certificates field is reached by walking DER
  # generically — which is enough, because its position in SignedData
  # is fixed and the contents are handed to `:public_key` to validate.
  defp from_meta_inf(apk) do
    with {:ok, blob} <- first_signature_blob(apk) do
      case pkcs7_certificate(blob) do
        {:ok, der} -> fingerprint(der, :v1)
        :error -> {:error, {422, "could not read the certificate from META-INF"}}
      end
    end
  end

  # Named from the central directory, then read one at a time through
  # `Zip`: `:zip.unzip/2` raises on an aligned APK, and inflating a
  # 13 MB archive to reach 2 KB of PKCS#7 would be the wrong trade even
  # where it works.
  defp first_signature_blob(apk) do
    apk
    |> Zip.names()
    |> Enum.filter(fn name ->
      String.starts_with?(name, "META-INF/") and
        String.upcase(Path.extname(name)) in [".RSA", ".DSA", ".EC"]
    end)
    |> Enum.sort()
    |> Enum.find_value(fn name ->
      case Zip.read(apk, name) do
        {:ok, blob} -> {:ok, blob}
        :none -> nil
      end
    end)
    |> case do
      {:ok, blob} ->
        {:ok, blob}

      nil ->
        {:error, {422, "APK is unsigned — no APK signing block and no META-INF signature"}}
    end
  end

  # ContentInfo ::= SEQUENCE { contentType OID, content [0] SignedData }
  # SignedData  ::= SEQUENCE { version, digestAlgorithms, contentInfo,
  #                            certificates [0] IMPLICIT OPTIONAL, … }
  defp pkcs7_certificate(blob) do
    with {:ok, content_info} <- der_value(blob, 0x30),
         {:ok, _oid, rest} <- der_take(content_info),
         {:ok, explicit} <- der_value(rest, 0xA0),
         {:ok, signed_data} <- der_value(explicit, 0x30),
         {:ok, certificates} <- signed_data_certificates(signed_data),
         # The set's first element is a Certificate, itself a SEQUENCE.
         {:ok, cert, _} <- der_take(certificates) do
      {:ok, cert}
    else
      _ -> :error
    end
  end

  # Skip version (INTEGER), digestAlgorithms (SET), contentInfo
  # (SEQUENCE); what follows is [0] IMPLICIT certificates when present.
  defp signed_data_certificates(signed_data) do
    with {:ok, _version, r1} <- der_take(signed_data),
         {:ok, _digest_algs, r2} <- der_take(r1),
         {:ok, _content, r3} <- der_take(r2) do
      der_value(r3, 0xA0)
    end
  end

  # Whole TLV, returned with the rest of the buffer.
  defp der_take(<<_tag, rest::binary>> = data) do
    with {:ok, len, header} <- der_length(rest),
         true <- header + len <= byte_size(data) do
      {:ok, binary_part(data, 0, header + len),
       binary_part(data, header + len, byte_size(data) - header - len)}
    else
      _ -> :error
    end
  end

  defp der_take(_), do: :error

  # Contents of a TLV whose tag must be `tag`.
  defp der_value(<<tag, rest::binary>>, tag) do
    with {:ok, len, header} <- der_length(rest),
         true <- header - 1 + len <= byte_size(rest) do
      {:ok, binary_part(rest, header - 1, len)}
    else
      _ -> :error
    end
  end

  defp der_value(_, _), do: :error

  # Returns the content length and the size of tag+length together.
  defp der_length(<<len, _::binary>>) when len < 0x80, do: {:ok, len, 2}

  defp der_length(<<first, rest::binary>>) when first > 0x80 and first - 0x80 <= 4 do
    count = first - 0x80

    if byte_size(rest) >= count do
      len = :binary.decode_unsigned(binary_part(rest, 0, count))
      {:ok, len, 2 + count}
    else
      :error
    end
  end

  # 0x80 is the indefinite form, illegal in DER.
  defp der_length(_), do: :error

  # ── fingerprint ──────────────────────────────────────────────────────

  # Parsing before hashing is the one check worth making: it means a
  # `signer` in the index is always the fingerprint of something that
  # really is a certificate, rather than of whatever bytes sat at that
  # offset.
  defp fingerprint(der, scheme) do
    try do
      _ = :public_key.pkix_decode_cert(der, :plain)

      {:ok,
       %{
         der: der,
         sha256: :sha256 |> :crypto.hash(der) |> Base.encode16(case: :lower),
         scheme: scheme
       }}
    rescue
      _ -> {:error, {422, "APK signing certificate is not valid X.509"}}
    end
  end
end