8.1 KiB · text History b868907
defmodule GitGud.Packages.Formats.Fdroid.Pkcs7 do
  @moduledoc """
  Builds the `CERT.RSA` of a signed JAR: a PKCS#7 / CMS `SignedData`
  carrying the signing certificate and one detached signature.

  ## Why this is written out rather than delegated

  `docs/fdroid-registry.md` originally planned a Rustler NIF around
  RustCrypto's `cms`, on the reasoning that `:public_key` speaks X.509
  and PKCS but not CMS. Two things changed that once the OpenBao
  backend landed:

  **The key is not ours to hold.** The whole point of transit is that
  the private key never reaches the app, so a NIF could not simply
  "sign" — it would have to hand back bytes to be signed elsewhere and
  then be called again to assemble the result. That splits the job
  exactly at the boundary where the hard part is already done: the
  assembly is DER, and DER is mechanical.

  **A JAR signature needs no `signedAttrs`.** PKCS#7 allows the
  signature to cover a set of authenticated attributes, which would
  mean encoding them, sending them out to be signed, and folding the
  result back. `jarsigner` has always also emitted the simpler form,
  where the signature is taken directly over the signed content — here,
  the bytes of `CERT.SF`. Java's verifier accepts both. The simpler
  form is a single call to `GitGud.Packages.Signing.sign/4` over a
  buffer we already have.

  So what's left is emitting a fixed DER structure, which is this
  module, and it adds no build dependency and no system library to
  either image stage.

  ## The structure

      ContentInfo ::= SEQUENCE {
        contentType  OBJECT IDENTIFIER,   -- signedData
        content      [0] EXPLICIT SignedData }

      SignedData ::= SEQUENCE {
        version           INTEGER,        -- 1
        digestAlgorithms  SET OF AlgorithmIdentifier,
        encapContentInfo  SEQUENCE { eContentType OBJECT IDENTIFIER },
        certificates      [0] IMPLICIT SET OF Certificate,
        signerInfos       SET OF SignerInfo }

      SignerInfo ::= SEQUENCE {
        version             INTEGER,      -- 1
        sid                 IssuerAndSerialNumber,
        digestAlgorithm     AlgorithmIdentifier,
        signatureAlgorithm  AlgorithmIdentifier,
        signature           OCTET STRING }

  `encapContentInfo` carries no `eContent`: the signature is detached,
  because the content it covers is `CERT.SF`, which is already its own
  member of the JAR.
  """

  # DER tags.
  @integer 0x02
  @bit_string 0x03
  @octet_string 0x04
  @null 0x05
  @oid 0x06
  @sequence 0x30
  @set 0x31

  # OIDs, as encoded bodies.
  @oid_signed_data <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x02>>
  @oid_data <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x07, 0x01>>
  @oid_sha256 <<0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01>>
  @oid_sha1 <<0x2B, 0x0E, 0x03, 0x02, 0x1A>>
  @oid_rsa <<0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01>>

  @doc """
  A `CERT.RSA` for `signature` over content digested with `digest`,
  attributing it to `certificate_der`.

  The certificate is parsed to lift its issuer and serial number, which
  is how a verifier finds the right certificate among the ones carried.
  Failing here rather than emitting a structure that points at nothing
  is the difference between a clear error and a client saying only
  "invalid signature".
  """
  @spec sign_data(binary(), binary(), :sha256 | :sha1) ::
          {:ok, binary()} | {:error, term()}
  def sign_data(certificate_der, signature, digest \\ :sha256)
      when is_binary(certificate_der) and is_binary(signature) do
    with {:ok, issuer, serial} <- issuer_and_serial(certificate_der) do
      signer_info =
        seq([
          int(1),
          seq([issuer, int(serial)]),
          algorithm(digest_oid(digest)),
          algorithm(@oid_rsa),
          tlv(@octet_string, signature)
        ])

      signed_data =
        seq([
          int(1),
          tlv(@set, algorithm(digest_oid(digest))),
          seq([tlv(@oid, @oid_data)]),
          # [0] IMPLICIT — the certificates are a SET, retagged.
          tlv(0xA0, certificate_der),
          tlv(@set, signer_info)
        ])

      {:ok, seq([tlv(@oid, @oid_signed_data), tlv(0xA0, signed_data)])}
    end
  end

  @doc """
  Issuer (as its raw DER `Name`) and serial number of a certificate.

  Both are lifted as encoded bytes rather than re-encoded from a parsed
  form: a `Name` that round-trips through a decoder can come back
  differing in string types, and a verifier matches it byte for byte.
  """
  @spec issuer_and_serial(binary()) :: {:ok, binary(), integer()} | {:error, term()}
  def issuer_and_serial(certificate_der) do
    with {:ok, tbs} <- first_child(certificate_der),
         {:ok, fields} <- children(tbs) do
      # TBSCertificate ::= SEQUENCE { version [0] EXPLICIT OPTIONAL,
      #   serialNumber INTEGER, signature AlgorithmIdentifier,
      #   issuer Name, ... }
      fields =
        case fields do
          [<<0xA0, _::binary>> | rest] -> rest
          all -> all
        end

      case fields do
        [<<@integer, _::binary>> = serial, _signature_alg, issuer | _] ->
          {:ok, issuer, decode_integer(serial)}

        _ ->
          {:error, :unexpected_certificate_shape}
      end
    end
  end

  # ── DER writing ──────────────────────────────────────────────────────

  defp seq(parts), do: tlv(@sequence, IO.iodata_to_binary(parts))

  defp algorithm(oid), do: seq([tlv(@oid, oid), tlv(@null, <<>>)])

  defp int(n) when is_integer(n) and n >= 0 do
    bytes = :binary.encode_unsigned(n)
    # DER integers are signed, so a leading bit of 1 needs a zero byte
    # in front or the value reads as negative.
    bytes = if :binary.first(bytes) >= 0x80, do: <<0>> <> bytes, else: bytes
    tlv(@integer, bytes)
  end

  @doc false
  # A DER BIT STRING with no unused bits — the wrapper an X.509
  # signature goes in.
  def bit_string(bytes), do: tlv(@bit_string, <<0>> <> bytes)

  @doc false
  def tlv(tag, value) when is_integer(tag) and is_binary(value),
    do: <<tag>> <> length_of(value) <> value

  defp length_of(value) when byte_size(value) < 0x80, do: <<byte_size(value)>>

  defp length_of(value) do
    bytes = :binary.encode_unsigned(byte_size(value))
    <<0x80 + byte_size(bytes)>> <> bytes
  end

  defp digest_oid(:sha1), do: @oid_sha1
  defp digest_oid(_sha256), do: @oid_sha256

  # ── DER reading, enough to pick a certificate apart ──────────────────

  defp first_child(der) do
    with {:ok, body} <- value_of(der), {:ok, [child | _]} <- children_of(body) do
      {:ok, child}
    else
      _ -> {:error, :not_a_sequence}
    end
  end

  defp children(der) do
    with {:ok, body} <- value_of(der), {:ok, list} <- children_of(body) do
      {:ok, list}
    else
      _ -> {:error, :not_a_sequence}
    end
  end

  defp value_of(<<_tag, rest::binary>> = der) do
    with {:ok, len, header} <- read_length(rest),
         true <- header + len <= byte_size(der) do
      {:ok, binary_part(der, header, len)}
    else
      _ -> :error
    end
  end

  defp value_of(_), do: :error

  defp children_of(body), do: children_of(body, [])

  defp children_of(<<>>, acc), do: {:ok, Enum.reverse(acc)}

  defp children_of(<<_tag, rest::binary>> = data, acc) do
    with {:ok, len, header} <- read_length(rest),
         total = header + len,
         true <- total <= byte_size(data) do
      child = binary_part(data, 0, total)
      children_of(binary_part(data, total, byte_size(data) - total), [child | acc])
    else
      _ -> {:ok, Enum.reverse(acc)}
    end
  end

  defp children_of(_, acc), do: {:ok, Enum.reverse(acc)}

  defp read_length(<<len, _::binary>>) when len < 0x80, do: {:ok, len, 2}

  defp read_length(<<first, rest::binary>>) when first > 0x80 and first - 0x80 <= 4 do
    count = first - 0x80

    if byte_size(rest) >= count do
      {:ok, :binary.decode_unsigned(binary_part(rest, 0, count)), 2 + count}
    else
      :error
    end
  end

  defp read_length(_), do: :error

  defp decode_integer(<<@integer, _::binary>> = tlv) do
    case value_of(tlv) do
      {:ok, bytes} -> :binary.decode_unsigned(bytes)
      _ -> 0
    end
  end
end