11.1 KiB · text History b868907
defmodule GitGud.Packages.Formats.Fdroid.Jar do
  @moduledoc """
  Signs `entry.json` into the `entry.jar` an F-Droid client fetches
  first.

  A signed JAR is a zip carrying three extra members:

      META-INF/MANIFEST.MF   a SHA-256 digest of every other member
      META-INF/CERT.SF       a digest of the manifest, and of each of
                             its per-entry stanzas
      META-INF/CERT.RSA      PKCS#7 SignedData over CERT.SF

  Verification walks that backwards: the signature covers `CERT.SF`,
  which covers `MANIFEST.MF`, which covers the content. So the one
  signature this asks OpenBao for protects the whole chain — and
  `entry.json` in turn pins `index-v2.json` by hash, which is why the
  index itself never has to be wrapped in a JAR.

  ## Line endings and wrapping are load-bearing

  The manifest format is from 1997 and its parsers are unforgiving:
  lines end `\\r\\n`, a section ends with a blank line, and a line
  longer than 72 bytes continues on the next one starting with a single
  space. Digests are taken over the exact bytes emitted, so getting the
  wrapping wrong produces a file that looks right and verifies wrong.

  ## The certificate

  Self-signed, built here and signed through
  `GitGud.Packages.Signing` — which means with OpenBao the forge never
  sees the private key that signs it, and the certificate's own
  signature comes from transit like everything else.

  Its SHA-256 is the repository fingerprint a user pins when adding the
  repo, so it must stay stable: regenerating it is equivalent to
  standing up a new repository that every phone has to re-add.
  """

  alias GitGud.Packages.Formats.Fdroid.Pkcs7
  alias GitGud.Packages.Signing

  @format "fdroid"

  # Manifest lines wrap at 72 bytes including the CRLF.
  @line_width 70

  @crlf "\r\n"

  @doc """
  Build a signed JAR containing `entries` (a list of `{name, content}`),
  signed with `scope`'s F-Droid key.

  Returns `{:ok, jar_bytes, fingerprint}`, where `fingerprint` is the
  lowercase SHA-256 hex of the signing certificate — what a client pins
  and what the packages page has to show next to the repository URL.
  """
  @spec build(struct(), [{String.t(), binary()}], keyword()) ::
          {:ok, binary(), String.t()} | {:error, term()}
  def build(scope, entries, opts \\ []) do
    digest = Keyword.get(opts, :digest, :sha256)

    with {:ok, certificate} <- certificate(scope),
         manifest = manifest(entries, digest),
         signature_file = signature_file(manifest, entries, digest),
         {:ok, signature} <- Signing.sign(scope, @format, signature_file, digest: digest),
         {:ok, cert_rsa} <- Pkcs7.sign_data(certificate, signature, digest),
         {:ok, jar} <- zip(entries, manifest, signature_file, cert_rsa) do
      {:ok, jar, fingerprint(certificate)}
    end
  end

  @doc "Lowercase SHA-256 hex of a DER certificate — the repo fingerprint."
  @spec fingerprint(binary()) :: String.t()
  def fingerprint(der), do: :sha256 |> :crypto.hash(der) |> Base.encode16(case: :lower)

  # ── the manifest and signature file ──────────────────────────────────

  defp manifest(entries, digest) do
    main =
      section([
        {"Manifest-Version", "1.0"},
        {"Created-By", "GitGud"}
      ])

    per_entry =
      Enum.map_join(entries, "", fn {name, content} ->
        section([
          {"Name", name},
          {digest_header(digest), base64_digest(content, digest)}
        ])
      end)

    main <> per_entry
  end

  # CERT.SF digests the manifest as a whole, and each of the manifest's
  # per-entry sections individually. The second is what lets a verifier
  # report *which* entry failed rather than only that something did.
  defp signature_file(manifest, entries, digest) do
    main =
      section([
        {"Signature-Version", "1.0"},
        {"Created-By", "GitGud"},
        {digest_header_manifest(digest), base64_digest(manifest, digest)}
      ])

    sections = manifest_sections(manifest)

    per_entry =
      Enum.map_join(entries, "", fn {name, _content} ->
        stanza = Map.get(sections, name, "")

        section([
          {"Name", name},
          {digest_header(digest), base64_digest(stanza, digest)}
        ])
      end)

    main <> per_entry
  end

  # Split the manifest back into its per-Name sections, keeping the
  # exact bytes — the digest is over what was written, including the
  # blank line that terminates each section.
  defp manifest_sections(manifest) do
    manifest
    |> String.split(@crlf <> @crlf)
    |> Enum.reject(&(&1 == ""))
    |> Enum.reduce(%{}, fn chunk, acc ->
      case Regex.run(~r/^Name: (.+?)\r\n/, chunk <> @crlf) do
        [_, name] -> Map.put(acc, unwrap(name), chunk <> @crlf <> @crlf)
        nil -> acc
      end
    end)
  end

  defp unwrap(name), do: String.replace(name, @crlf <> " ", "")

  defp section(pairs) do
    Enum.map_join(pairs, "", fn {key, value} -> wrap("#{key}: #{value}") end) <> @crlf
  end

  # A manifest line is at most 72 bytes including CRLF; a continuation
  # begins with exactly one space, and that space counts toward the
  # next line's width.
  defp wrap(line) do
    case split_bytes(line, @line_width) do
      {head, ""} ->
        head <> @crlf

      {head, rest} ->
        head <> @crlf <> continuation(rest)
    end
  end

  defp continuation(""), do: ""

  defp continuation(rest) do
    case split_bytes(rest, @line_width - 1) do
      {head, ""} -> " " <> head <> @crlf
      {head, more} -> " " <> head <> @crlf <> continuation(more)
    end
  end

  defp split_bytes(binary, width) when byte_size(binary) <= width, do: {binary, ""}

  defp split_bytes(binary, width),
    do: {binary_part(binary, 0, width), binary_part(binary, width, byte_size(binary) - width)}

  defp base64_digest(content, digest),
    do: digest |> Signing.erlang_digest() |> :crypto.hash(content) |> Base.encode64()

  defp digest_header(:sha1), do: "SHA1-Digest"
  defp digest_header(_sha256), do: "SHA-256-Digest"

  defp digest_header_manifest(:sha1), do: "SHA1-Digest-Manifest"
  defp digest_header_manifest(_sha256), do: "SHA-256-Digest-Manifest"

  # ── the zip ──────────────────────────────────────────────────────────

  # MANIFEST.MF must come first so a streaming verifier meets it before
  # the entries it describes.
  defp zip(entries, manifest, signature_file, cert_rsa) do
    members =
      [
        {~c"META-INF/MANIFEST.MF", manifest},
        {~c"META-INF/CERT.SF", signature_file},
        {~c"META-INF/CERT.RSA", cert_rsa}
      ] ++ Enum.map(entries, fn {name, content} -> {String.to_charlist(name), content} end)

    case :zip.create(~c"entry.jar", members, [:memory]) do
      {:ok, {_name, bytes}} -> {:ok, bytes}
      {:error, reason} -> {:error, {:zip, reason}}
    end
  end

  # ── the certificate ──────────────────────────────────────────────────

  @doc """
  The repository's self-signed certificate, as DER.

  Built and signed through the configured signing backend, so with
  OpenBao the private key never reaches this process — the
  `TBSCertificate` is encoded here, sent out to be signed, and the
  result reassembled.
  """
  @spec certificate(struct()) :: {:ok, binary()} | {:error, term()}
  def certificate(scope) do
    with {:ok, %{public_pem: pem}} <- Signing.ensure_key(scope, @format),
         {:ok, public_key} <- decode_public(pem),
         {:ok, tbs} <- tbs_certificate(scope, public_key),
         {:ok, signature} <- Signing.sign(scope, @format, tbs, digest: :sha256) do
      {:ok,
       Pkcs7.tlv(
         0x30,
         tbs <> sha256_rsa_algorithm() <> Pkcs7.bit_string(signature)
       )}
    end
  end

  defp decode_public(pem) do
    case :public_key.pem_decode(pem) do
      [entry | _] -> {:ok, :public_key.pem_entry_decode(entry)}
      [] -> {:error, :not_a_pem_key}
    end
  rescue
    _ -> {:error, :not_a_pem_key}
  end

  # A minimal v3 certificate, and — critically — a *deterministic* one.
  #
  # The certificate's SHA-256 is the repository fingerprint a user pins
  # when adding the repo. A random serial or a `now` validity window
  # would mint a different certificate on every request, so no client
  # could ever pin one, and every fetch would look like a different
  # repository. Everything below is a pure function of the key and the
  # handle; RSA PKCS#1 v1.5 is deterministic too, so the signature over
  # it doesn't move either.
  defp tbs_certificate(scope, public_key) do
    # Via Signing, so a repository scope resolves to its namespace —
    # the same key, and the same certificate, whichever URL asked.
    handle = scope |> Signing.namespace() |> GitGud.Repositories.Storage.owner_handle()
    name = rdn("#{handle} (GitGud F-Droid repository)")

    tbs =
      Pkcs7.tlv(
        0x30,
        # [0] EXPLICIT version 2 (== v3)
        # subject, then subjectPublicKeyInfo. Swapping these two
        # produces a structure that encodes cleanly and that no X.509
        # parser will accept.
        Pkcs7.tlv(0xA0, Pkcs7.tlv(0x02, <<2>>)) <>
          Pkcs7.tlv(0x02, serial(public_key)) <>
          sha256_rsa_algorithm() <>
          name <>
          validity() <>
          name <>
          subject_public_key_info(public_key)
      )

    {:ok, tbs}
  rescue
    e -> {:error, {:certificate_build_failed, Exception.message(e)}}
  end

  # Derived from the key, so it is stable for the life of the key and
  # distinct between keys. Masked positive because DER integers are
  # signed and a leading 1 bit would read as a negative serial.
  defp serial(public_key) do
    <<first, rest::binary-size(19), _::binary>> =
      :crypto.hash(:sha256, :public_key.der_encode(:RSAPublicKey, public_key))

    <<Bitwise.band(first, 0x7F)>> <> rest
  end

  # Fixed dates, not `now`. Validity is not the control here — F-Droid
  # pins the certificate by fingerprint and never builds a chain — and a
  # moving window would move the fingerprint, which is the one thing
  # that must not happen.
  @not_before "20200101000000Z"
  @not_after "20700101000000Z"

  defp validity,
    do: Pkcs7.tlv(0x30, Pkcs7.tlv(0x18, @not_before) <> Pkcs7.tlv(0x18, @not_after))

  defp rdn(common_name) do
    # Name ::= SEQUENCE OF RelativeDistinguishedName (SET OF AttributeTypeAndValue)
    oid_cn = <<0x55, 0x04, 0x03>>

    attribute =
      Pkcs7.tlv(
        0x30,
        Pkcs7.tlv(0x06, oid_cn) <> Pkcs7.tlv(0x0C, common_name)
      )

    Pkcs7.tlv(0x30, Pkcs7.tlv(0x31, attribute))
  end

  defp subject_public_key_info(public_key) do
    der = :public_key.der_encode(:RSAPublicKey, public_key)
    Pkcs7.tlv(0x30, rsa_algorithm() <> Pkcs7.bit_string(der))
  end

  defp rsa_algorithm do
    Pkcs7.tlv(
      0x30,
      Pkcs7.tlv(0x06, <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01>>) <>
        Pkcs7.tlv(0x05, <<>>)
    )
  end

  # sha256WithRSAEncryption — 1.2.840.113549.1.1.11
  defp sha256_rsa_algorithm do
    Pkcs7.tlv(
      0x30,
      Pkcs7.tlv(0x06, <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B>>) <>
        Pkcs7.tlv(0x05, <<>>)
    )
  end
end