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