Read an APK's signing certificate

43c3d8d · Gabriel Morell · 2026-09-12 16:08

14 files +504 -0
Message
{commit_body(@commit)}

Files changed

added lib/git_gud/packages/formats/fdroid/signature.ex
+322 −0
@@ -0,0 +1,322 @@
1 +defmodule GitGud.Packages.Formats.Fdroid.Signature do
2 + @moduledoc """
3 + Pulls an APK's signing certificate out, for the index's `signer`
4 + field.
5 +
6 + F-Droid records the SHA-256 of the signing certificate and refuses an
7 + update whose signer differs from what is installed — it is the thing
8 + standing between a repository and someone replacing an app with their
9 + own build of it. So the forge has to know it at publish time.
10 +
11 + The obvious place is `META-INF/*.RSA` (v1, JAR signing). For anything
12 + modern **it is not there**: AGP omits v1 above `minSdk` 24, and this
13 + repo's own APK carries only an APK Signing Block. So the block comes
14 + first and `META-INF` is the fallback, not the other way round.
15 +
16 + ## Layout
17 +
18 + The signing block sits between the last zip entry and the central
19 + directory, and is found by walking back from the end-of-central-
20 + directory record:
21 +
22 + EOCD (PK\\x05\\x06) → central directory offset
23 + [cd - 16, cd) magic "APK Sig Block 42"
24 + [cd - 24, cd - 16) u64 size, repeated at the block's start
25 + block = u64 size ‖ { u64 len ‖ u32 id ‖ value }* ‖ u64 size ‖ magic
26 +
27 + Scheme v3 is `0xf05368c0`, v2 is `0x7109871a`, and `0x42726577` is
28 + apksigner's padding. v3 is preferred where present: it is the scheme
29 + that carries rotation, so its certificate is the current one.
30 +
31 + A v2/v3 value nests length-prefixed (u32, little-endian) throughout:
32 +
33 + signers → signer → signed data → certificates → certificate (DER)
34 +
35 + ## What this does not do
36 +
37 + **It does not verify the signature.** The certificate is read, not
38 + checked against the APK's contents. The F-Droid client verifies, and
39 + Android verifies again at install; re-implementing v2 digest
40 + verification here would be a third binary format for no guarantee
41 + that isn't already made twice downstream.
42 +
43 + What it *does* guarantee is that the bytes are a well-formed X.509
44 + certificate, because `:public_key` has to parse them before the
45 + fingerprint is taken.
46 + """
47 +
48 + @v2_block_id 0x7109871A
49 + @v3_block_id 0xF05368C0
50 +
51 + @magic "APK Sig Block 42"
52 +
53 + # The EOCD is last, but a zip comment can follow it — up to 0xFFFF
54 + # bytes, plus the record's own 22.
55 + @eocd_search_limit 0xFFFF + 22
56 +
57 + @zip64_sentinel 0xFFFFFFFF
58 +
59 + @type result :: %{
60 + der: binary(),
61 + sha256: String.t(),
62 + scheme: :v2 | :v3 | :v1
63 + }
64 +
65 + @doc """
66 + The signing certificate of `apk`.
67 +
68 + Returns `{:ok, %{der: …, sha256: …, scheme: …}}` where `sha256` is
69 + lowercase hex of the DER — the form F-Droid's `signer` takes — or
70 + `{:error, {status, message}}` shaped for the registry controller.
71 + """
72 + @spec certificate(binary()) :: {:ok, result()} | {:error, {integer(), String.t()}}
73 + def certificate(apk) when is_binary(apk) do
74 + case signing_block(apk) do
75 + {:ok, blocks} -> from_block(blocks)
76 + # No signing block is not yet an error: a v1-only APK is legal for
77 + # minSdk < 24, and its certificate lives in META-INF.
78 + :none -> from_meta_inf(apk)
79 + {:error, _} = err -> err
80 + end
81 + end
82 +
83 + def certificate(_), do: {:error, {422, "not an APK"}}
84 +
85 + # ── the signing block ────────────────────────────────────────────────
86 +
87 + defp signing_block(apk) do
88 + with {:ok, cd_offset} <- central_directory_offset(apk),
89 + true <- cd_offset >= 24 and cd_offset <= byte_size(apk),
90 + @magic <- binary_part(apk, cd_offset - 16, 16),
91 + <<size::little-64>> <- binary_part(apk, cd_offset - 24, 8),
92 + # `size` counts from just after the leading size field to the
93 + # end of the magic, so the block starts 8 bytes before that.
94 + start when start >= 0 <- cd_offset - 8 - size,
95 + true <- size >= 24 and start + 8 + size <= byte_size(apk) do
96 + pairs = binary_part(apk, start + 8, size - 24)
97 + {:ok, id_value_pairs(pairs, %{})}
98 + else
99 + {:error, _} = err -> err
100 + _ -> :none
101 + end
102 + end
103 +
104 + defp central_directory_offset(apk) when byte_size(apk) < 22,
105 + do: {:error, {422, "not a zip archive"}}
106 +
107 + defp central_directory_offset(apk) do
108 + window = min(byte_size(apk), @eocd_search_limit)
109 + tail = binary_part(apk, byte_size(apk) - window, window)
110 +
111 + case find_eocd(tail, byte_size(tail) - 22) do
112 + nil ->
113 + {:error, {422, "not a zip archive — no end-of-central-directory record"}}
114 +
115 + pos ->
116 + case binary_part(tail, pos, 22) do
117 + <<_::binary-size(16), @zip64_sentinel::little-32, _::binary>> ->
118 + {:error, {422, "zip64 APKs are not supported"}}
119 +
120 + <<_::binary-size(16), offset::little-32, _::binary>> ->
121 + {:ok, offset}
122 + end
123 + end
124 + end
125 +
126 + # Backwards, because the record we want is the last one — a stored
127 + # entry whose data happens to contain the signature would otherwise
128 + # win.
129 + defp find_eocd(_tail, pos) when pos < 0, do: nil
130 +
131 + defp find_eocd(tail, pos) do
132 + case binary_part(tail, pos, 4) do
133 + <<0x50, 0x4B, 0x05, 0x06>> -> pos
134 + _ -> find_eocd(tail, pos - 1)
135 + end
136 + end
137 +
138 + defp id_value_pairs(<<>>, acc), do: acc
139 +
140 + defp id_value_pairs(<<len::little-64, rest::binary>>, acc)
141 + when len >= 4 and len - 4 <= byte_size(rest) - 4 do
142 + <<id::little-32, remainder::binary>> = rest
143 + value_len = len - 4
144 + value = binary_part(remainder, 0, value_len)
145 + tail = binary_part(remainder, value_len, byte_size(remainder) - value_len)
146 +
147 + id_value_pairs(tail, Map.put_new(acc, id, value))
148 + end
149 +
150 + # A malformed or truncated pair ends the walk with what was read
151 + # rather than failing: the padding block is last and its length is
152 + # the one most likely to disagree with a rewritten archive.
153 + defp id_value_pairs(_trailing, acc), do: acc
154 +
155 + defp from_block(blocks) do
156 + cond do
157 + value = Map.get(blocks, @v3_block_id) -> parse_scheme(value, :v3)
158 + value = Map.get(blocks, @v2_block_id) -> parse_scheme(value, :v2)
159 + true -> {:error, {422, "APK signing block carries no v2 or v3 signature"}}
160 + end
161 + end
162 +
163 + # signers → signer → signed data → certificates → first certificate.
164 + defp parse_scheme(value, scheme) do
165 + with {:ok, signers} <- length_prefixed(value),
166 + {:ok, signer} <- length_prefixed(signers),
167 + {:ok, signed_data} <- length_prefixed(signer),
168 + # Inside signed data: digests, then certificates.
169 + {:ok, _digests, after_digests} <- take_length_prefixed(signed_data),
170 + {:ok, certificates, _} <- take_length_prefixed(after_digests),
171 + {:ok, der} <- length_prefixed(certificates) do
172 + fingerprint(der, scheme)
173 + else
174 + _ -> {:error, {422, "APK #{scheme} signature block is malformed"}}
175 + end
176 + end
177 +
178 + # The first length-prefixed element of a sequence.
179 + defp length_prefixed(data) do
180 + case take_length_prefixed(data) do
181 + {:ok, value, _rest} -> {:ok, value}
182 + :error -> :error
183 + end
184 + end
185 +
186 + defp take_length_prefixed(<<len::little-32, rest::binary>>) when len <= byte_size(rest),
187 + do: {:ok, binary_part(rest, 0, len), binary_part(rest, len, byte_size(rest) - len)}
188 +
189 + defp take_length_prefixed(_), do: :error
190 +
191 + # ── v1 / META-INF fallback ───────────────────────────────────────────
192 +
193 + # A v1-signed APK keeps its certificate inside a PKCS#7 SignedData
194 + # blob at META-INF/<name>.{RSA,DSA,EC}. `:public_key` has no PKCS#7
195 + # decoder, so the certificates field is reached by walking DER
196 + # generically — which is enough, because its position in SignedData
197 + # is fixed and the contents are handed to `:public_key` to validate.
198 + defp from_meta_inf(apk) do
199 + with {:ok, entries} <- zip_entries(apk),
200 + {:ok, blob} <- first_signature_blob(entries) do
201 + case pkcs7_certificate(blob) do
202 + {:ok, der} -> fingerprint(der, :v1)
203 + :error -> {:error, {422, "could not read the certificate from META-INF"}}
204 + end
205 + end
206 + end
207 +
208 + defp zip_entries(apk) do
209 + case :zip.unzip(apk, [:memory]) do
210 + {:ok, entries} -> {:ok, entries}
211 + {:error, _} -> {:error, {422, "APK is not a readable zip archive"}}
212 + end
213 + end
214 +
215 + defp first_signature_blob(entries) do
216 + entries
217 + |> Enum.filter(fn {name, _} ->
218 + name = List.to_string(name)
219 +
220 + String.starts_with?(name, "META-INF/") and
221 + String.upcase(Path.extname(name)) in [".RSA", ".DSA", ".EC"]
222 + end)
223 + |> Enum.sort_by(fn {name, _} -> List.to_string(name) end)
224 + |> case do
225 + [{_, blob} | _] ->
226 + {:ok, blob}
227 +
228 + [] ->
229 + {:error, {422, "APK is unsigned — no APK signing block and no META-INF signature"}}
230 + end
231 + end
232 +
233 + # ContentInfo ::= SEQUENCE { contentType OID, content [0] SignedData }
234 + # SignedData ::= SEQUENCE { version, digestAlgorithms, contentInfo,
235 + # certificates [0] IMPLICIT OPTIONAL, … }
236 + defp pkcs7_certificate(blob) do
237 + with {:ok, content_info} <- der_value(blob, 0x30),
238 + {:ok, _oid, rest} <- der_take(content_info),
239 + {:ok, explicit} <- der_value(rest, 0xA0),
240 + {:ok, signed_data} <- der_value(explicit, 0x30),
241 + {:ok, certificates} <- signed_data_certificates(signed_data),
242 + # The set's first element is a Certificate, itself a SEQUENCE.
243 + {:ok, cert, _} <- der_take(certificates) do
244 + {:ok, cert}
245 + else
246 + _ -> :error
247 + end
248 + end
249 +
250 + # Skip version (INTEGER), digestAlgorithms (SET), contentInfo
251 + # (SEQUENCE); what follows is [0] IMPLICIT certificates when present.
252 + defp signed_data_certificates(signed_data) do
253 + with {:ok, _version, r1} <- der_take(signed_data),
254 + {:ok, _digest_algs, r2} <- der_take(r1),
255 + {:ok, _content, r3} <- der_take(r2) do
256 + der_value(r3, 0xA0)
257 + end
258 + end
259 +
260 + # Whole TLV, returned with the rest of the buffer.
261 + defp der_take(<<_tag, rest::binary>> = data) do
262 + with {:ok, len, header} <- der_length(rest),
263 + true <- header + len <= byte_size(data) do
264 + {:ok, binary_part(data, 0, header + len),
265 + binary_part(data, header + len, byte_size(data) - header - len)}
266 + else
267 + _ -> :error
268 + end
269 + end
270 +
271 + defp der_take(_), do: :error
272 +
273 + # Contents of a TLV whose tag must be `tag`.
274 + defp der_value(<<tag, rest::binary>>, tag) do
275 + with {:ok, len, header} <- der_length(rest),
276 + true <- header - 1 + len <= byte_size(rest) do
277 + {:ok, binary_part(rest, header - 1, len)}
278 + else
279 + _ -> :error
280 + end
281 + end
282 +
283 + defp der_value(_, _), do: :error
284 +
285 + # Returns the content length and the size of tag+length together.
286 + defp der_length(<<len, _::binary>>) when len < 0x80, do: {:ok, len, 2}
287 +
288 + defp der_length(<<first, rest::binary>>) when first > 0x80 and first - 0x80 <= 4 do
289 + count = first - 0x80
290 +
291 + if byte_size(rest) >= count do
292 + len = :binary.decode_unsigned(binary_part(rest, 0, count))
293 + {:ok, len, 2 + count}
294 + else
295 + :error
296 + end
297 + end
298 +
299 + # 0x80 is the indefinite form, illegal in DER.
300 + defp der_length(_), do: :error
301 +
302 + # ── fingerprint ──────────────────────────────────────────────────────
303 +
304 + # Parsing before hashing is the one check worth making: it means a
305 + # `signer` in the index is always the fingerprint of something that
306 + # really is a certificate, rather than of whatever bytes sat at that
307 + # offset.
308 + defp fingerprint(der, scheme) do
309 + try do
310 + _ = :public_key.pkix_decode_cert(der, :plain)
311 +
312 + {:ok,
313 + %{
314 + der: der,
315 + sha256: :sha256 |> :crypto.hash(der) |> Base.encode16(case: :lower),
316 + scheme: scheme
317 + }}
318 + rescue
319 + _ -> {:error, {422, "APK signing certificate is not valid X.509"}}
320 + end
321 + end
322 +end
added test/git_gud/packages/fdroid_signature_test.exs
+182 −0
@@ -0,0 +1,182 @@
1 +defmodule GitGud.Packages.Fdroid.SignatureTest do
2 + @moduledoc """
3 + Reading the signing certificate out of an APK.
4 +
5 + The APK is assembled here rather than committed: a real one is ~19 MB,
6 + and building the signing block from its documented layout is what
7 + actually exercises the parser. The certificate inside it *is* real —
8 + `debug-signer.der` is the `CN=Android Debug` certificate lifted out of
9 + this repo's own `app-debug.apk`, so `:public_key` has genuine X.509 to
10 + validate rather than a hand-rolled approximation.
11 +
12 + There is also a tagged test against the built APK, for when one is
13 + lying around. It asserts shape rather than a specific fingerprint:
14 + AGP generates `~/.android/debug.keystore` per machine, so the value
15 + differs for every developer.
16 + """
17 + use ExUnit.Case, async: true
18 +
19 + alias GitGud.Packages.Formats.Fdroid.Signature
20 +
21 + @magic "APK Sig Block 42"
22 + @v2_id 0x7109871A
23 + @v3_id 0xF05368C0
24 +
25 + @cert_fixture "test/support/fixtures/apk/debug-signer.der"
26 + @built_apk "android/app/build/outputs/apk/debug/app-debug.apk"
27 +
28 + defp cert, do: File.read!(@cert_fixture)
29 +
30 + # u32 length-prefixed, the framing the whole scheme nests with.
31 + defp lp(data), do: <<byte_size(data)::little-32, data::binary>>
32 +
33 + # signers → signer → signed data → { digests, certificates → cert }
34 + #
35 + # `certificates` is a length-prefixed *sequence* whose elements are
36 + # themselves length-prefixed, so a single certificate nests twice.
37 + defp scheme_value(der) do
38 + certificates = lp(lp(der))
39 + digests = lp("fake digest")
40 + signed_data = lp(digests <> certificates)
41 + signer = lp(signed_data)
42 + lp(signer)
43 + end
44 +
45 + defp pair(id, value), do: <<byte_size(value) + 4::little-64, id::little-32, value::binary>>
46 +
47 + defp signing_block(pairs) do
48 + size = byte_size(pairs) + 24
49 + <<size::little-64, pairs::binary, size::little-64, @magic::binary>>
50 + end
51 +
52 + @doc false
53 + # A zip with the block spliced in where an APK carries one: after the
54 + # last entry, before the central directory. The EOCD's pointer to the
55 + # central directory has to move by exactly the block's length.
56 + defp apk_with(block) do
57 + {:ok, {_name, zip}} =
58 + :zip.create(~c"t.apk", [{~c"AndroidManifest.xml", "not really a manifest"}], [:memory])
59 +
60 + eocd = find_eocd(zip)
61 + <<_::binary-size(16), cd_offset::little-32, _::binary>> = binary_part(zip, eocd, 22)
62 +
63 + before = binary_part(zip, 0, cd_offset)
64 + after_cd = binary_part(zip, cd_offset, byte_size(zip) - cd_offset)
65 + shifted = cd_offset + byte_size(block)
66 +
67 + # The EOCD itself moves by the block's length, since the block is
68 + # spliced in ahead of it.
69 + patched_eocd = eocd + byte_size(block)
70 + body = before <> block <> after_cd
71 +
72 + prefix = patched_eocd + 16
73 + <<head::binary-size(^prefix), _old::little-32, tail::binary>> = body
74 + head <> <<shifted::little-32>> <> tail
75 + end
76 +
77 + defp find_eocd(zip), do: find_eocd(zip, byte_size(zip) - 22)
78 +
79 + defp find_eocd(zip, pos) do
80 + case binary_part(zip, pos, 4) do
81 + <<0x50, 0x4B, 0x05, 0x06>> -> pos
82 + _ -> find_eocd(zip, pos - 1)
83 + end
84 + end
85 +
86 + describe "APK signing block" do
87 + test "reads the certificate out of a v2 block" do
88 + apk = apk_with(signing_block(pair(@v2_id, scheme_value(cert()))))
89 +
90 + assert {:ok, result} = Signature.certificate(apk)
91 + assert result.scheme == :v2
92 + assert result.der == cert()
93 + assert result.sha256 == expected_fingerprint()
94 + end
95 +
96 + test "prefers v3 over v2 when both are present" do
97 + # v3 is the scheme that carries rotation, so where both exist its
98 + # certificate is the current one.
99 + pairs = pair(@v2_id, scheme_value(cert())) <> pair(@v3_id, scheme_value(cert()))
100 +
101 + assert {:ok, %{scheme: :v3}} = Signature.certificate(apk_with(signing_block(pairs)))
102 + end
103 +
104 + test "skips apksigner's padding block" do
105 + pairs = pair(0x42726577, :binary.copy(<<0>>, 64)) <> pair(@v2_id, scheme_value(cert()))
106 +
107 + assert {:ok, %{scheme: :v2}} = Signature.certificate(apk_with(signing_block(pairs)))
108 + end
109 +
110 + test "reports a block carrying neither v2 nor v3" do
111 + pairs = pair(0x42726577, :binary.copy(<<0>>, 64))
112 +
113 + assert {:error, {422, message}} = Signature.certificate(apk_with(signing_block(pairs)))
114 + assert message =~ "no v2 or v3"
115 + end
116 +
117 + test "refuses a certificate that isn't X.509" do
118 + apk = apk_with(signing_block(pair(@v2_id, scheme_value("nope, just some bytes"))))
119 +
120 + assert {:error, {422, message}} = Signature.certificate(apk)
121 + assert message =~ "not valid X.509"
122 + end
123 +
124 + test "reports a truncated scheme block rather than raising" do
125 + value = scheme_value(cert())
126 + truncated = binary_part(value, 0, div(byte_size(value), 2))
127 +
128 + assert {:error, {422, message}} =
129 + Signature.certificate(apk_with(signing_block(pair(@v2_id, truncated))))
130 +
131 + assert message =~ "malformed"
132 + end
133 + end
134 +
135 + describe "falling back to META-INF" do
136 + test "an APK with neither a block nor META-INF reads as unsigned" do
137 + {:ok, {_name, zip}} =
138 + :zip.create(~c"t.apk", [{~c"AndroidManifest.xml", "x"}], [:memory])
139 +
140 + assert {:error, {422, message}} = Signature.certificate(zip)
141 + assert message =~ "unsigned"
142 + end
143 + end
144 +
145 + describe "rejecting input" do
146 + test "refuses something that isn't a zip" do
147 + assert {:error, {422, _}} = Signature.certificate("hello")
148 + assert {:error, {422, _}} = Signature.certificate(<<>>)
149 + assert {:error, {422, _}} = Signature.certificate(:binary.copy(<<0>>, 1000))
150 + end
151 +
152 + test "terminates on truncations of a well-formed APK" do
153 + apk = apk_with(signing_block(pair(@v2_id, scheme_value(cert()))))
154 +
155 + for len <- Enum.take_every(1..byte_size(apk), 53) do
156 + case Signature.certificate(binary_part(apk, 0, len)) do
157 + {:ok, result} -> assert is_binary(result.sha256)
158 + {:error, {422, _}} -> :ok
159 + end
160 + end
161 + end
162 + end
163 +
164 + describe "the APK this repo builds" do
165 + @tag :built_apk
166 + test "reads a v2 certificate out of it" do
167 + unless File.exists?(@built_apk) do
168 + # `mix test --include built_apk` after ./gradlew :app:assembleDebug.
169 + flunk("#{@built_apk} not built")
170 + end
171 +
172 + assert {:ok, result} = Signature.certificate(File.read!(@built_apk))
173 +
174 + # AGP omits v1 above minSdk 24, and the app's minSdk is 26.
175 + assert result.scheme == :v2
176 + assert String.match?(result.sha256, ~r/\A[0-9a-f]{64}\z/)
177 + end
178 + end
179 +
180 + defp expected_fingerprint,
181 + do: :sha256 |> :crypto.hash(cert()) |> Base.encode16(case: :lower)
182 +end
modified ?
+0 −0

Binary file — not rendered.

Parents: 001cc6a