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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
11.1 KiB · text
History
b868907