Sign the F-Droid index

1a40fc9 · Gabriel Morell · 2026-09-12 17:25

15 files +812 -17
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages/formats/fdroid.ex
+26 −8
@@ -51,6 +51,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
51 51
52 52 alias GitGud.Packages
53 53 alias GitGud.Packages.Formats.Fdroid.Apk
54 + alias GitGud.Packages.Formats.Fdroid.Jar
54 55 alias GitGud.Packages.PackageFile
55 56 alias GitGud.Repositories.Repository
56 57 alias GitGud.Repositories.Storage
@@ -191,14 +192,31 @@ defmodule GitGud.Packages.Formats.Fdroid do
191 192 })
192 193 end
193 194
194 − # Phase C. Until the JAR signer lands this says so plainly rather
195 − # than serving something a client would reject with a signature
196 − # error, which is a much worse way to learn the same fact.
197 − defp signed_entry(_repos, _label, _scope, _ctx) do
198 − {:error,
199 − {501,
200 − "index signing is not implemented yet — /entry.json and /index-v2.json " <>
201 − "serve the unsigned documents"}}
195 + # The signed entry: entry.json wrapped in a JAR whose signature the
196 + # client verifies against the fingerprint it pinned when the
197 + # repository was added.
198 + defp signed_entry(repos, label, scope, ctx) do
199 + document = entry(repos, label, ctx) |> IO.iodata_to_binary()
200 +
201 + case Jar.build(scope, [{"entry.json", document}]) do
202 + {:ok, jar, _fingerprint} ->
203 + {:ok, "application/java-archive", jar}
204 +
205 + {:error, reason} ->
206 + {:error, {500, "could not sign the index: #{inspect(reason)}"}}
207 + end
208 + end
209 +
210 + @doc """
211 + The SHA-256 of this repository's signing certificate — the
212 + `?fingerprint=` a client pins when adding the repository.
213 +
214 + Generating the key if there isn't one yet, because the first person
215 + to ask is usually the packages page trying to print the URL.
216 + """
217 + @spec fingerprint(struct()) :: {:ok, String.t()} | {:error, term()}
218 + def fingerprint(scope) do
219 + with {:ok, der} <- Jar.certificate(scope), do: {:ok, Jar.fingerprint(der)}
202 220 end
203 221
204 222 # ── writes ───────────────────────────────────────────────────────────
added lib/git_gud/packages/formats/fdroid/jar.ex
+319 −0
@@ -0,0 +1,319 @@
1 +defmodule GitGud.Packages.Formats.Fdroid.Jar do
2 + @moduledoc """
3 + Signs `entry.json` into the `entry.jar` an F-Droid client fetches
4 + first.
5 +
6 + A signed JAR is a zip carrying three extra members:
7 +
8 + META-INF/MANIFEST.MF a SHA-256 digest of every other member
9 + META-INF/CERT.SF a digest of the manifest, and of each of
10 + its per-entry stanzas
11 + META-INF/CERT.RSA PKCS#7 SignedData over CERT.SF
12 +
13 + Verification walks that backwards: the signature covers `CERT.SF`,
14 + which covers `MANIFEST.MF`, which covers the content. So the one
15 + signature this asks OpenBao for protects the whole chain — and
16 + `entry.json` in turn pins `index-v2.json` by hash, which is why the
17 + index itself never has to be wrapped in a JAR.
18 +
19 + ## Line endings and wrapping are load-bearing
20 +
21 + The manifest format is from 1997 and its parsers are unforgiving:
22 + lines end `\\r\\n`, a section ends with a blank line, and a line
23 + longer than 72 bytes continues on the next one starting with a single
24 + space. Digests are taken over the exact bytes emitted, so getting the
25 + wrapping wrong produces a file that looks right and verifies wrong.
26 +
27 + ## The certificate
28 +
29 + Self-signed, built here and signed through
30 + `GitGud.Packages.Signing` — which means with OpenBao the forge never
31 + sees the private key that signs it, and the certificate's own
32 + signature comes from transit like everything else.
33 +
34 + Its SHA-256 is the repository fingerprint a user pins when adding the
35 + repo, so it must stay stable: regenerating it is equivalent to
36 + standing up a new repository that every phone has to re-add.
37 + """
38 +
39 + alias GitGud.Packages.Formats.Fdroid.Pkcs7
40 + alias GitGud.Packages.Signing
41 +
42 + @format "fdroid"
43 +
44 + # Manifest lines wrap at 72 bytes including the CRLF.
45 + @line_width 70
46 +
47 + @crlf "\r\n"
48 +
49 + @doc """
50 + Build a signed JAR containing `entries` (a list of `{name, content}`),
51 + signed with `scope`'s F-Droid key.
52 +
53 + Returns `{:ok, jar_bytes, fingerprint}`, where `fingerprint` is the
54 + lowercase SHA-256 hex of the signing certificate — what a client pins
55 + and what the packages page has to show next to the repository URL.
56 + """
57 + @spec build(struct(), [{String.t(), binary()}], keyword()) ::
58 + {:ok, binary(), String.t()} | {:error, term()}
59 + def build(scope, entries, opts \\ []) do
60 + digest = Keyword.get(opts, :digest, :sha256)
61 +
62 + with {:ok, certificate} <- certificate(scope),
63 + manifest = manifest(entries, digest),
64 + signature_file = signature_file(manifest, entries, digest),
65 + {:ok, signature} <- Signing.sign(scope, @format, signature_file, digest: digest),
66 + {:ok, cert_rsa} <- Pkcs7.sign_data(certificate, signature, digest),
67 + {:ok, jar} <- zip(entries, manifest, signature_file, cert_rsa) do
68 + {:ok, jar, fingerprint(certificate)}
69 + end
70 + end
71 +
72 + @doc "Lowercase SHA-256 hex of a DER certificate — the repo fingerprint."
73 + @spec fingerprint(binary()) :: String.t()
74 + def fingerprint(der), do: :sha256 |> :crypto.hash(der) |> Base.encode16(case: :lower)
75 +
76 + # ── the manifest and signature file ──────────────────────────────────
77 +
78 + defp manifest(entries, digest) do
79 + main =
80 + section([
81 + {"Manifest-Version", "1.0"},
82 + {"Created-By", "GitGud"}
83 + ])
84 +
85 + per_entry =
86 + Enum.map_join(entries, "", fn {name, content} ->
87 + section([
88 + {"Name", name},
89 + {digest_header(digest), base64_digest(content, digest)}
90 + ])
91 + end)
92 +
93 + main <> per_entry
94 + end
95 +
96 + # CERT.SF digests the manifest as a whole, and each of the manifest's
97 + # per-entry sections individually. The second is what lets a verifier
98 + # report *which* entry failed rather than only that something did.
99 + defp signature_file(manifest, entries, digest) do
100 + main =
101 + section([
102 + {"Signature-Version", "1.0"},
103 + {"Created-By", "GitGud"},
104 + {digest_header_manifest(digest), base64_digest(manifest, digest)}
105 + ])
106 +
107 + sections = manifest_sections(manifest)
108 +
109 + per_entry =
110 + Enum.map_join(entries, "", fn {name, _content} ->
111 + stanza = Map.get(sections, name, "")
112 +
113 + section([
114 + {"Name", name},
115 + {digest_header(digest), base64_digest(stanza, digest)}
116 + ])
117 + end)
118 +
119 + main <> per_entry
120 + end
121 +
122 + # Split the manifest back into its per-Name sections, keeping the
123 + # exact bytes — the digest is over what was written, including the
124 + # blank line that terminates each section.
125 + defp manifest_sections(manifest) do
126 + manifest
127 + |> String.split(@crlf <> @crlf)
128 + |> Enum.reject(&(&1 == ""))
129 + |> Enum.reduce(%{}, fn chunk, acc ->
130 + case Regex.run(~r/^Name: (.+?)\r\n/, chunk <> @crlf) do
131 + [_, name] -> Map.put(acc, unwrap(name), chunk <> @crlf <> @crlf)
132 + nil -> acc
133 + end
134 + end)
135 + end
136 +
137 + defp unwrap(name), do: String.replace(name, @crlf <> " ", "")
138 +
139 + defp section(pairs) do
140 + Enum.map_join(pairs, "", fn {key, value} -> wrap("#{key}: #{value}") end) <> @crlf
141 + end
142 +
143 + # A manifest line is at most 72 bytes including CRLF; a continuation
144 + # begins with exactly one space, and that space counts toward the
145 + # next line's width.
146 + defp wrap(line) do
147 + case split_bytes(line, @line_width) do
148 + {head, ""} ->
149 + head <> @crlf
150 +
151 + {head, rest} ->
152 + head <> @crlf <> continuation(rest)
153 + end
154 + end
155 +
156 + defp continuation(""), do: ""
157 +
158 + defp continuation(rest) do
159 + case split_bytes(rest, @line_width - 1) do
160 + {head, ""} -> " " <> head <> @crlf
161 + {head, more} -> " " <> head <> @crlf <> continuation(more)
162 + end
163 + end
164 +
165 + defp split_bytes(binary, width) when byte_size(binary) <= width, do: {binary, ""}
166 +
167 + defp split_bytes(binary, width),
168 + do: {binary_part(binary, 0, width), binary_part(binary, width, byte_size(binary) - width)}
169 +
170 + defp base64_digest(content, digest), do: digest |> :crypto.hash(content) |> Base.encode64()
171 +
172 + defp digest_header(:sha1), do: "SHA1-Digest"
173 + defp digest_header(_sha256), do: "SHA-256-Digest"
174 +
175 + defp digest_header_manifest(:sha1), do: "SHA1-Digest-Manifest"
176 + defp digest_header_manifest(_sha256), do: "SHA-256-Digest-Manifest"
177 +
178 + # ── the zip ──────────────────────────────────────────────────────────
179 +
180 + # MANIFEST.MF must come first so a streaming verifier meets it before
181 + # the entries it describes.
182 + defp zip(entries, manifest, signature_file, cert_rsa) do
183 + members =
184 + [
185 + {~c"META-INF/MANIFEST.MF", manifest},
186 + {~c"META-INF/CERT.SF", signature_file},
187 + {~c"META-INF/CERT.RSA", cert_rsa}
188 + ] ++ Enum.map(entries, fn {name, content} -> {String.to_charlist(name), content} end)
189 +
190 + case :zip.create(~c"entry.jar", members, [:memory]) do
191 + {:ok, {_name, bytes}} -> {:ok, bytes}
192 + {:error, reason} -> {:error, {:zip, reason}}
193 + end
194 + end
195 +
196 + # ── the certificate ──────────────────────────────────────────────────
197 +
198 + @doc """
199 + The repository's self-signed certificate, as DER.
200 +
201 + Built and signed through the configured signing backend, so with
202 + OpenBao the private key never reaches this process — the
203 + `TBSCertificate` is encoded here, sent out to be signed, and the
204 + result reassembled.
205 + """
206 + @spec certificate(struct()) :: {:ok, binary()} | {:error, term()}
207 + def certificate(scope) do
208 + with {:ok, %{public_pem: pem}} <- Signing.ensure_key(scope, @format),
209 + {:ok, public_key} <- decode_public(pem),
210 + {:ok, tbs} <- tbs_certificate(scope, public_key),
211 + {:ok, signature} <- Signing.sign(scope, @format, tbs, digest: :sha256) do
212 + {:ok,
213 + Pkcs7.tlv(
214 + 0x30,
215 + tbs <> sha256_rsa_algorithm() <> Pkcs7.bit_string(signature)
216 + )}
217 + end
218 + end
219 +
220 + defp decode_public(pem) do
221 + case :public_key.pem_decode(pem) do
222 + [entry | _] -> {:ok, :public_key.pem_entry_decode(entry)}
223 + [] -> {:error, :not_a_pem_key}
224 + end
225 + rescue
226 + _ -> {:error, :not_a_pem_key}
227 + end
228 +
229 + # A minimal v3 certificate, and — critically — a *deterministic* one.
230 + #
231 + # The certificate's SHA-256 is the repository fingerprint a user pins
232 + # when adding the repo. A random serial or a `now` validity window
233 + # would mint a different certificate on every request, so no client
234 + # could ever pin one, and every fetch would look like a different
235 + # repository. Everything below is a pure function of the key and the
236 + # handle; RSA PKCS#1 v1.5 is deterministic too, so the signature over
237 + # it doesn't move either.
238 + defp tbs_certificate(scope, public_key) do
239 + # Via Signing, so a repository scope resolves to its namespace —
240 + # the same key, and the same certificate, whichever URL asked.
241 + handle = scope |> Signing.namespace() |> GitGud.Repositories.Storage.owner_handle()
242 + name = rdn("#{handle} (GitGud F-Droid repository)")
243 +
244 + tbs =
245 + Pkcs7.tlv(
246 + 0x30,
247 + # [0] EXPLICIT version 2 (== v3)
248 + # subject, then subjectPublicKeyInfo. Swapping these two
249 + # produces a structure that encodes cleanly and that no X.509
250 + # parser will accept.
251 + Pkcs7.tlv(0xA0, Pkcs7.tlv(0x02, <<2>>)) <>
252 + Pkcs7.tlv(0x02, serial(public_key)) <>
253 + sha256_rsa_algorithm() <>
254 + name <>
255 + validity() <>
256 + name <>
257 + subject_public_key_info(public_key)
258 + )
259 +
260 + {:ok, tbs}
261 + rescue
262 + e -> {:error, {:certificate_build_failed, Exception.message(e)}}
263 + end
264 +
265 + # Derived from the key, so it is stable for the life of the key and
266 + # distinct between keys. Masked positive because DER integers are
267 + # signed and a leading 1 bit would read as a negative serial.
268 + defp serial(public_key) do
269 + <<first, rest::binary-size(19), _::binary>> =
270 + :crypto.hash(:sha256, :public_key.der_encode(:RSAPublicKey, public_key))
271 +
272 + <<Bitwise.band(first, 0x7F)>> <> rest
273 + end
274 +
275 + # Fixed dates, not `now`. Validity is not the control here — F-Droid
276 + # pins the certificate by fingerprint and never builds a chain — and a
277 + # moving window would move the fingerprint, which is the one thing
278 + # that must not happen.
279 + @not_before "20200101000000Z"
280 + @not_after "20700101000000Z"
281 +
282 + defp validity,
283 + do: Pkcs7.tlv(0x30, Pkcs7.tlv(0x18, @not_before) <> Pkcs7.tlv(0x18, @not_after))
284 +
285 + defp rdn(common_name) do
286 + # Name ::= SEQUENCE OF RelativeDistinguishedName (SET OF AttributeTypeAndValue)
287 + oid_cn = <<0x55, 0x04, 0x03>>
288 +
289 + attribute =
290 + Pkcs7.tlv(
291 + 0x30,
292 + Pkcs7.tlv(0x06, oid_cn) <> Pkcs7.tlv(0x0C, common_name)
293 + )
294 +
295 + Pkcs7.tlv(0x30, Pkcs7.tlv(0x31, attribute))
296 + end
297 +
298 + defp subject_public_key_info(public_key) do
299 + der = :public_key.der_encode(:RSAPublicKey, public_key)
300 + Pkcs7.tlv(0x30, rsa_algorithm() <> Pkcs7.bit_string(der))
301 + end
302 +
303 + defp rsa_algorithm do
304 + Pkcs7.tlv(
305 + 0x30,
306 + Pkcs7.tlv(0x06, <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01>>) <>
307 + Pkcs7.tlv(0x05, <<>>)
308 + )
309 + end
310 +
311 + # sha256WithRSAEncryption — 1.2.840.113549.1.1.11
312 + defp sha256_rsa_algorithm do
313 + Pkcs7.tlv(
314 + 0x30,
315 + Pkcs7.tlv(0x06, <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B>>) <>
316 + Pkcs7.tlv(0x05, <<>>)
317 + )
318 + end
319 +end
modified lib/git_gud/packages/formats/fdroid/manifest.ex
+2 −2
@@ -233,7 +233,7 @@ defmodule GitGud.Packages.Formats.Fdroid.Manifest do
233 233 defp utf16_len(<<len::little-16, rest::binary>>), do: {len, rest}
234 234 defp utf16_len(_), do: :error
235 235
236 − defp pool_at(pool, @absent), do: nil
236 + defp pool_at(_pool, @absent), do: nil
237 237
238 238 defp pool_at(pool, index) when index < tuple_size(pool), do: elem(pool, index)
239 239 defp pool_at(_pool, _index), do: nil
@@ -244,7 +244,7 @@ defmodule GitGud.Packages.Formats.Fdroid.Manifest do
244 244 # `header_size` covers ResChunk_header + ResXMLTree_node; the
245 245 # attrExt follows it.
246 246 case chunk do
247 − <<_::binary-size(header_size), _ns::little-32, name::little-32, attr_start::little-16,
247 + <<_::binary-size(^header_size), _ns::little-32, name::little-32, attr_start::little-16,
248 248 attr_size::little-16, attr_count::little-16, _id::little-16, _class::little-16,
249 249 _style::little-16, _::binary>> ->
250 250 case pool_at(pool, name) do
added lib/git_gud/packages/formats/fdroid/pkcs7.ex
+241 −0
@@ -0,0 +1,241 @@
1 +defmodule GitGud.Packages.Formats.Fdroid.Pkcs7 do
2 + @moduledoc """
3 + Builds the `CERT.RSA` of a signed JAR: a PKCS#7 / CMS `SignedData`
4 + carrying the signing certificate and one detached signature.
5 +
6 + ## Why this is written out rather than delegated
7 +
8 + `docs/fdroid-registry.md` originally planned a Rustler NIF around
9 + RustCrypto's `cms`, on the reasoning that `:public_key` speaks X.509
10 + and PKCS but not CMS. Two things changed that once the OpenBao
11 + backend landed:
12 +
13 + **The key is not ours to hold.** The whole point of transit is that
14 + the private key never reaches the app, so a NIF could not simply
15 + "sign" — it would have to hand back bytes to be signed elsewhere and
16 + then be called again to assemble the result. That splits the job
17 + exactly at the boundary where the hard part is already done: the
18 + assembly is DER, and DER is mechanical.
19 +
20 + **A JAR signature needs no `signedAttrs`.** PKCS#7 allows the
21 + signature to cover a set of authenticated attributes, which would
22 + mean encoding them, sending them out to be signed, and folding the
23 + result back. `jarsigner` has always also emitted the simpler form,
24 + where the signature is taken directly over the signed content — here,
25 + the bytes of `CERT.SF`. Java's verifier accepts both. The simpler
26 + form is a single call to `GitGud.Packages.Signing.sign/4` over a
27 + buffer we already have.
28 +
29 + So what's left is emitting a fixed DER structure, which is this
30 + module, and it adds no build dependency and no system library to
31 + either image stage.
32 +
33 + ## The structure
34 +
35 + ContentInfo ::= SEQUENCE {
36 + contentType OBJECT IDENTIFIER, -- signedData
37 + content [0] EXPLICIT SignedData }
38 +
39 + SignedData ::= SEQUENCE {
40 + version INTEGER, -- 1
41 + digestAlgorithms SET OF AlgorithmIdentifier,
42 + encapContentInfo SEQUENCE { eContentType OBJECT IDENTIFIER },
43 + certificates [0] IMPLICIT SET OF Certificate,
44 + signerInfos SET OF SignerInfo }
45 +
46 + SignerInfo ::= SEQUENCE {
47 + version INTEGER, -- 1
48 + sid IssuerAndSerialNumber,
49 + digestAlgorithm AlgorithmIdentifier,
50 + signatureAlgorithm AlgorithmIdentifier,
51 + signature OCTET STRING }
52 +
53 + `encapContentInfo` carries no `eContent`: the signature is detached,
54 + because the content it covers is `CERT.SF`, which is already its own
55 + member of the JAR.
56 + """
57 +
58 + # DER tags.
59 + @integer 0x02
60 + @bit_string 0x03
61 + @octet_string 0x04
62 + @null 0x05
63 + @oid 0x06
64 + @sequence 0x30
65 + @set 0x31
66 +
67 + # OIDs, as encoded bodies.
68 + @oid_signed_data <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02>>
69 + @oid_data <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x01>>
70 + @oid_sha256 <<0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01>>
71 + @oid_sha1 <<0x2B, 0x0E, 0x03, 0x02, 0x1A>>
72 + @oid_rsa <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01>>
73 +
74 + @doc """
75 + A `CERT.RSA` for `signature` over content digested with `digest`,
76 + attributing it to `certificate_der`.
77 +
78 + The certificate is parsed to lift its issuer and serial number, which
79 + is how a verifier finds the right certificate among the ones carried.
80 + Failing here rather than emitting a structure that points at nothing
81 + is the difference between a clear error and a client saying only
82 + "invalid signature".
83 + """
84 + @spec sign_data(binary(), binary(), :sha256 | :sha1) ::
85 + {:ok, binary()} | {:error, term()}
86 + def sign_data(certificate_der, signature, digest \\ :sha256)
87 + when is_binary(certificate_der) and is_binary(signature) do
88 + with {:ok, issuer, serial} <- issuer_and_serial(certificate_der) do
89 + signer_info =
90 + seq([
91 + int(1),
92 + seq([issuer, int(serial)]),
93 + algorithm(digest_oid(digest)),
94 + algorithm(@oid_rsa),
95 + tlv(@octet_string, signature)
96 + ])
97 +
98 + signed_data =
99 + seq([
100 + int(1),
101 + tlv(@set, algorithm(digest_oid(digest))),
102 + seq([tlv(@oid, @oid_data)]),
103 + # [0] IMPLICIT — the certificates are a SET, retagged.
104 + tlv(0xA0, certificate_der),
105 + tlv(@set, signer_info)
106 + ])
107 +
108 + {:ok, seq([tlv(@oid, @oid_signed_data), tlv(0xA0, signed_data)])}
109 + end
110 + end
111 +
112 + @doc """
113 + Issuer (as its raw DER `Name`) and serial number of a certificate.
114 +
115 + Both are lifted as encoded bytes rather than re-encoded from a parsed
116 + form: a `Name` that round-trips through a decoder can come back
117 + differing in string types, and a verifier matches it byte for byte.
118 + """
119 + @spec issuer_and_serial(binary()) :: {:ok, binary(), integer()} | {:error, term()}
120 + def issuer_and_serial(certificate_der) do
121 + with {:ok, tbs} <- first_child(certificate_der),
122 + {:ok, fields} <- children(tbs) do
123 + # TBSCertificate ::= SEQUENCE { version [0] EXPLICIT OPTIONAL,
124 + # serialNumber INTEGER, signature AlgorithmIdentifier,
125 + # issuer Name, ... }
126 + fields =
127 + case fields do
128 + [<<0xA0, _::binary>> | rest] -> rest
129 + all -> all
130 + end
131 +
132 + case fields do
133 + [<<@integer, _::binary>> = serial, _signature_alg, issuer | _] ->
134 + {:ok, issuer, decode_integer(serial)}
135 +
136 + _ ->
137 + {:error, :unexpected_certificate_shape}
138 + end
139 + end
140 + end
141 +
142 + # ── DER writing ──────────────────────────────────────────────────────
143 +
144 + defp seq(parts), do: tlv(@sequence, IO.iodata_to_binary(parts))
145 +
146 + defp algorithm(oid), do: seq([tlv(@oid, oid), tlv(@null, <<>>)])
147 +
148 + defp int(n) when is_integer(n) and n >= 0 do
149 + bytes = :binary.encode_unsigned(n)
150 + # DER integers are signed, so a leading bit of 1 needs a zero byte
151 + # in front or the value reads as negative.
152 + bytes = if :binary.first(bytes) >= 0x80, do: <<0>> <> bytes, else: bytes
153 + tlv(@integer, bytes)
154 + end
155 +
156 + @doc false
157 + # A DER BIT STRING with no unused bits — the wrapper an X.509
158 + # signature goes in.
159 + def bit_string(bytes), do: tlv(@bit_string, <<0>> <> bytes)
160 +
161 + @doc false
162 + def tlv(tag, value) when is_integer(tag) and is_binary(value),
163 + do: <<tag>> <> length_of(value) <> value
164 +
165 + defp length_of(value) when byte_size(value) < 0x80, do: <<byte_size(value)>>
166 +
167 + defp length_of(value) do
168 + bytes = :binary.encode_unsigned(byte_size(value))
169 + <<0x80 + byte_size(bytes)>> <> bytes
170 + end
171 +
172 + defp digest_oid(:sha1), do: @oid_sha1
173 + defp digest_oid(_sha256), do: @oid_sha256
174 +
175 + # ── DER reading, enough to pick a certificate apart ──────────────────
176 +
177 + defp first_child(der) do
178 + with {:ok, body} <- value_of(der), {:ok, [child | _]} <- children_of(body) do
179 + {:ok, child}
180 + else
181 + _ -> {:error, :not_a_sequence}
182 + end
183 + end
184 +
185 + defp children(der) do
186 + with {:ok, body} <- value_of(der), {:ok, list} <- children_of(body) do
187 + {:ok, list}
188 + else
189 + _ -> {:error, :not_a_sequence}
190 + end
191 + end
192 +
193 + defp value_of(<<_tag, rest::binary>> = der) do
194 + with {:ok, len, header} <- read_length(rest),
195 + true <- header + len <= byte_size(der) do
196 + {:ok, binary_part(der, header, len)}
197 + else
198 + _ -> :error
199 + end
200 + end
201 +
202 + defp value_of(_), do: :error
203 +
204 + defp children_of(body), do: children_of(body, [])
205 +
206 + defp children_of(<<>>, acc), do: {:ok, Enum.reverse(acc)}
207 +
208 + defp children_of(<<_tag, rest::binary>> = data, acc) do
209 + with {:ok, len, header} <- read_length(rest),
210 + total = header + len,
211 + true <- total <= byte_size(data) do
212 + child = binary_part(data, 0, total)
213 + children_of(binary_part(data, total, byte_size(data) - total), [child | acc])
214 + else
215 + _ -> {:ok, Enum.reverse(acc)}
216 + end
217 + end
218 +
219 + defp children_of(_, acc), do: {:ok, Enum.reverse(acc)}
220 +
221 + defp read_length(<<len, _::binary>>) when len < 0x80, do: {:ok, len, 2}
222 +
223 + defp read_length(<<first, rest::binary>>) when first > 0x80 and first - 0x80 <= 4 do
224 + count = first - 0x80
225 +
226 + if byte_size(rest) >= count do
227 + {:ok, :binary.decode_unsigned(binary_part(rest, 0, count)), 2 + count}
228 + else
229 + :error
230 + end
231 + end
232 +
233 + defp read_length(_), do: :error
234 +
235 + defp decode_integer(<<@integer, _::binary>> = tlv) do
236 + case value_of(tlv) do
237 + {:ok, bytes} -> :binary.decode_unsigned(bytes)
238 + _ -> 0
239 + end
240 + end
241 +end
modified lib/git_gud/packages/signing.ex
+20 −3
@@ -51,6 +51,7 @@ defmodule GitGud.Packages.Signing do
51 51 """
52 52
53 53 alias GitGud.Packages.Signing.Local
54 + alias GitGud.Repositories.Repository
54 55
55 56 @typedoc "The namespace a key belongs to."
56 57 @type scope :: struct()
@@ -98,14 +99,30 @@ defmodule GitGud.Packages.Signing do
98 99 end
99 100
100 101 @spec ensure_key(scope(), String.t()) :: {:ok, %{public_pem: String.t()}} | {:error, term()}
101 − def ensure_key(scope, format), do: backend(format).ensure_key(scope, format)
102 + def ensure_key(scope, format),
103 + do: backend(format).ensure_key(namespace(scope), format)
102 104
103 105 @spec public_key(scope(), String.t()) :: {:ok, String.t()} | {:error, term()}
104 − def public_key(scope, format), do: backend(format).public_key(scope, format)
106 + def public_key(scope, format),
107 + do: backend(format).public_key(namespace(scope), format)
105 108
106 109 @spec sign(scope(), String.t(), binary(), sign_opts()) :: {:ok, binary()} | {:error, term()}
107 110 def sign(scope, format, data, opts \\ []),
108 − do: backend(format).sign(scope, format, data, opts)
111 + do: backend(format).sign(namespace(scope), format, data, opts)
112 +
113 + @doc """
114 + The namespace a scope's key belongs to.
115 +
116 + A repository resolves to the user or organization that owns it, so
117 + `pkg/fdroid/acme/tools` and `pkg/fdroid/acme/_all` are signed by the
118 + same key — one fingerprint per publisher, which is what a phone
119 + pins. `Packages.signing_key/2` has always resolved this way; this
120 + makes every backend see a namespace rather than each having to
121 + remember to.
122 + """
123 + @spec namespace(scope()) :: scope()
124 + def namespace(%Repository{} = repo), do: GitGud.Packages.namespace(repo)
125 + def namespace(scope), do: scope
109 126
110 127 @doc """
111 128 Whether `format` signs through something other than the local key
modified test/git_gud/packages/fdroid_format_test.exs
+26 −4
@@ -213,10 +213,32 @@ defmodule GitGud.Packages.Formats.FdroidTest do
213 213 decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))
214 214 end
215 215
216 − test "entry.jar says signing isn't implemented rather than serving something unsigned",
217 − %{repo: repo} do
218 − assert {:error, {501, message}} = Fdroid.index(repo, ctx(%{path: ["entry.jar"]}))
219 − assert message =~ "not implemented yet"
216 + test "entry.jar is a signed JAR carrying entry.json", %{repo: repo} do
217 + {:ok, _, _} = publish(repo, build_apk())
218 +
219 + assert {:ok, "application/java-archive", jar} =
220 + Fdroid.index(repo, ctx(%{path: ["entry.jar"]}))
221 +
222 + {:ok, members} = :zip.unzip(IO.iodata_to_binary(jar), [:memory])
223 + names = members |> Enum.map(fn {n, _} -> List.to_string(n) end) |> MapSet.new()
224 +
225 + assert "META-INF/MANIFEST.MF" in names
226 + assert "META-INF/CERT.RSA" in names
227 + assert "entry.json" in names
228 +
229 + # The JAR's payload has to be the same document /entry.json
230 + # serves, or the hash it pins would be signing one index while
231 + # clients fetched another.
232 + {_, packed} = Enum.find(members, fn {n, _} -> n == ~c"entry.json" end)
233 + assert Jason.decode!(packed) == decode(Fdroid.index(repo, ctx(%{path: ["entry.json"]})))
234 + end
235 +
236 + test "the repository reports a pinnable fingerprint", %{user: user} do
237 + assert {:ok, fingerprint} = Fdroid.fingerprint(user)
238 + assert String.match?(fingerprint, ~r/\A[0-9a-f]{64}\z/)
239 +
240 + # Stable, because it is what a user pins when adding the repo.
241 + assert {:ok, ^fingerprint} = Fdroid.fingerprint(user)
220 242 end
221 243 end
222 244
added test/git_gud/packages/fdroid_jar_test.exs
+178 −0
@@ -0,0 +1,178 @@
1 +defmodule GitGud.Packages.Formats.Fdroid.JarTest do
2 + @moduledoc """
3 + Signing `entry.jar`.
4 +
5 + The point of this file is the `jarsigner -verify` test. Everything
6 + here — the manifest format, the signature file, the PKCS#7 DER, the
7 + self-signed certificate — is hand-written, and hand-written ASN.1 has
8 + a particular failure mode: it looks completely fine and a verifier
9 + rejects it. Checking our own arithmetic proves nothing. Handing the
10 + result to the reference implementation proves something.
11 +
12 + `jarsigner` is tagged so the suite still runs where a JDK isn't
13 + installed, but it is the test that matters.
14 + """
15 + use GitGud.DataCase, async: false
16 +
17 + import GitGud.ForgeFixtures
18 +
19 + alias GitGud.Packages.Formats.Fdroid.Jar
20 + alias GitGud.Packages.Formats.Fdroid.Pkcs7
21 +
22 + setup do
23 + {user, _repo} = repository_fixture()
24 + %{scope: user}
25 + end
26 +
27 + describe "the certificate" do
28 + test "is a self-signed X.509 that :public_key can parse", %{scope: scope} do
29 + assert {:ok, der} = Jar.certificate(scope)
30 +
31 + # If OTP's decoder accepts it, the structure is right — this is
32 + # the same parse a client does before checking the signature.
33 + cert = :public_key.pkix_decode_cert(der, :plain)
34 + assert elem(cert, 0) == :Certificate
35 +
36 + assert {:ok, _issuer, serial} = Pkcs7.issuer_and_serial(der)
37 + assert serial > 0
38 + end
39 +
40 + test "fingerprints as lowercase sha256 hex", %{scope: scope} do
41 + assert {:ok, der} = Jar.certificate(scope)
42 + assert String.match?(Jar.fingerprint(der), ~r/\A[0-9a-f]{64}\z/)
43 + end
44 +
45 + test "is stable across calls — the fingerprint is what users pin", %{scope: scope} do
46 + # The key is minted once and reused, so the certificate's public
47 + # half doesn't move. Regenerating it would be standing up a new
48 + # repository that every phone has to add again.
49 + {:ok, first} = Jar.certificate(scope)
50 + {:ok, second} = Jar.certificate(scope)
51 +
52 + assert public_key_of(first) == public_key_of(second)
53 + end
54 + end
55 +
56 + describe "the manifest" do
57 + test "wraps long lines the way the format requires", %{scope: scope} do
58 + long = String.duplicate("a", 300)
59 + {:ok, jar, _} = Jar.build(scope, [{long <> ".json", "x"}])
60 +
61 + manifest = member(jar, ~c"META-INF/MANIFEST.MF")
62 +
63 + # Lines cap at 72 bytes including CRLF, and continuations start
64 + # with exactly one space. A digest is taken over these exact
65 + # bytes, so getting it wrong verifies wrong while looking right.
66 + for line <- String.split(manifest, "\r\n"), line != "" do
67 + assert byte_size(line) <= 70
68 + end
69 +
70 + assert manifest =~ "\r\n "
71 + end
72 +
73 + test "digests every entry", %{scope: scope} do
74 + {:ok, jar, _} = Jar.build(scope, [{"entry.json", "{\"a\":1}"}])
75 + manifest = member(jar, ~c"META-INF/MANIFEST.MF")
76 +
77 + expected = :sha256 |> :crypto.hash("{\"a\":1}") |> Base.encode64()
78 +
79 + assert manifest =~ "Name: entry.json"
80 + assert manifest =~ "SHA-256-Digest: #{expected}"
81 + end
82 + end
83 +
84 + describe "the jar" do
85 + test "carries the three signature members plus the content", %{scope: scope} do
86 + {:ok, jar, _} = Jar.build(scope, [{"entry.json", "{}"}])
87 +
88 + names = jar |> member_names() |> MapSet.new()
89 +
90 + assert "META-INF/MANIFEST.MF" in names
91 + assert "META-INF/CERT.SF" in names
92 + assert "META-INF/CERT.RSA" in names
93 + assert "entry.json" in names
94 + end
95 +
96 + test "returns the fingerprint alongside", %{scope: scope} do
97 + {:ok, _jar, fingerprint} = Jar.build(scope, [{"entry.json", "{}"}])
98 + {:ok, der} = Jar.certificate(scope)
99 +
100 + assert fingerprint == Jar.fingerprint(der)
101 + end
102 +
103 + test "CERT.SF digests the manifest", %{scope: scope} do
104 + {:ok, jar, _} = Jar.build(scope, [{"entry.json", "{}"}])
105 +
106 + manifest = member(jar, ~c"META-INF/MANIFEST.MF")
107 + sf = member(jar, ~c"META-INF/CERT.SF")
108 + expected = :sha256 |> :crypto.hash(manifest) |> Base.encode64()
109 +
110 + assert sf =~ "SHA-256-Digest-Manifest: #{expected}"
111 + end
112 + end
113 +
114 + describe "jarsigner" do
115 + @describetag :jarsigner
116 +
117 + test "verifies the signature", %{scope: scope} do
118 + unless System.find_executable("jarsigner") do
119 + flunk("jarsigner not installed — run with --exclude jarsigner to skip")
120 + end
121 +
122 + {:ok, jar, _fingerprint} = Jar.build(scope, [{"entry.json", ~s({"version":20002})}])
123 +
124 + path =
125 + Path.join(System.tmp_dir!(), "gitgud-entry-#{System.unique_integer([:positive])}.jar")
126 +
127 + File.write!(path, jar)
128 + on_exit(fn -> File.rm(path) end)
129 +
130 + {output, status} =
131 + System.cmd("jarsigner", ["-verify", "-strict", path], stderr_to_stdout: true)
132 +
133 + # This is the assertion the whole module exists for: the PKCS#7,
134 + # the manifest and the signature file are all hand-written, and
135 + # hand-written ASN.1 fails by looking correct. The reference
136 + # implementation says otherwise or it doesn't.
137 + assert output =~ "jar verified", "jarsigner said:\n#{output}"
138 +
139 + refute output =~ "jar is unsigned"
140 + refute output =~ "not parsable or not verifiable"
141 + refute output =~ "does not match"
142 +
143 + # `-strict` reports the chain as invalid and exits 4, because the
144 + # certificate is self-signed and there is nothing to chain it to.
145 + # That is the design: F-Droid pins this certificate by SHA-256
146 + # and never builds a path. Asserting on it rather than ignoring
147 + # it, so a *different* signer error can't hide in the same exit
148 + # code.
149 + assert status in [0, 4], "jarsigner exited #{status}:\n#{output}"
150 +
151 + if status == 4 do
152 + assert output =~ "self-signed",
153 + "exit 4 should only ever be the self-signed chain:\n#{output}"
154 + end
155 + end
156 + end
157 +
158 + # ── helpers ──────────────────────────────────────────────────────────
159 +
160 + defp member(jar, name) do
161 + {:ok, entries} = :zip.unzip(jar, [:memory])
162 + {^name, content} = Enum.find(entries, fn {n, _} -> n == name end)
163 + content
164 + end
165 +
166 + defp member_names(jar) do
167 + {:ok, entries} = :zip.unzip(jar, [:memory])
168 + Enum.map(entries, fn {name, _} -> List.to_string(name) end)
169 + end
170 +
171 + defp public_key_of(der) do
172 + der
173 + |> :public_key.pkix_decode_cert(:otp)
174 + |> then(fn cert ->
175 + cert |> elem(1) |> elem(7)
176 + end)
177 + end
178 +end

Parents: 631f976