neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Sign the F-Droid index
1a40fc9 · Gabriel Morell · 2026-09-12 17:25
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