16.6 KiB · text History ea9cadf

F-Droid app repository — plan

Serving an F-Droid repository out of the forge, the way apt already is, so that pkg/fdroid/<owner>/_all is one repo line in an F-Droid client and CI publishes into it the same way it publishes .debs today.

Spec details are quoted inline deliberately: they were the expensive part to establish, and an implementer shouldn’t have to re-derive them.

The framing

This is one more GitGud.Packages.Format adapter, not a subsystem. Slice 70 left more in place than it looks:

Need Already there
Route /pkg/:format/:owner/:name/*rest — dispatches fdroid unchanged
Namespace view Format.namespace_marker/0 (_all) + the optional namespace_index/3 callback, built for exactly this
Per-namespace signing key SigningKey, minted on first use, private half AES-GCM in Postgres via GitGud.Secrets
Public key endpoint /pkg/_keys/:format/:owner
Auth RegistryToken + HTTP Basic — which the F-Droid client speaks for private repos
Blobs, retention, pruning, disk usage, UI unchanged

namespace_index/3 already carries the rationale for _all:

It earns its place where the namespace is the unit a consumer actually configures — apt, where five repositories mean five deb lines and five keyring entries for what an administrator thinks of as one publisher.

An F-Droid repo is the same story. A phone should get one repo entry for neiam, not one per Android project.

What actually has to be built

Four pieces. Two are parsers, one is a signer, one is JSON assembly.


Phase A — reading an APK

The analogue of Formats.Deb.Control, and bigger, because an APK does not carry a text stanza.

AndroidManifest.xml is binary

An APK is a zip (:zip opens it), but the manifest inside is Android’s compiled resource format, not XML text. Layout:

ResChunk_header      type u16, headerSize u16, size u32
  RES_XML_TYPE              0x0003   the file itself
  RES_STRING_POOL_TYPE      0x0001
  RES_XML_RESOURCE_MAP_TYPE 0x0180
  RES_XML_START_ELEMENT     0x0102
  RES_XML_END_ELEMENT       0x0103

String pool header adds stringCount u32, styleCount u32, flags u32, stringsStart u32, stylesStart u32, then a stringCount-long u32 offset array, then the data.

  • flags &&& 0x100 (UTF8_FLAG) picks the encoding. Both appear in the wild — AAPT2 emits UTF-8 for most resources but the manifest of the APK this was written against is UTF-16.
  • UTF-16: u16 length, then that many UTF-16LE code units, then a u16 NUL. A length with the high bit set is a two-unit length.
  • UTF-8: u8 UTF-16 length, u8 byte length, bytes, u8 NUL — each length independently extensible by its own high bit.

A start element is ResXMLTree_node (lineNumber u32, comment u32) then ResXMLTree_attrExt:

ns u32, name u32, attributeStart u16, attributeSize u16,
attributeCount u16, idIndex u16, classIndex u16, styleIndex u16

then attributeCount × ResXMLTree_attribute:

ns u32, name u32, rawValue u32, size u16, res0 u8, dataType u8, data u32

ns/name/rawValue are string-pool indices, 0xFFFFFFFF for absent. Data types that matter here:

dataType Meaning
0x03 TYPE_STRING data is a string-pool index
0x10 TYPE_INT_DEC data is the integer
0x11 TYPE_INT_HEX ditto
0x12 TYPE_INT_BOOLEAN non-zero is true
0x01 TYPE_REFERENCE @0x… resource id — not resolvable without resources.arsc

Attributes are matched by name string, not by resource id. The resource map chunk maps pool index → resource id, and a fully correct reader would use it; the names are in the pool regardless, and matching them avoids carrying a resource-id table. The cost is that an APK built by a toolchain that strips names would read as empty — none do.

What gets pulled out:

Field From
packageName <manifest package>
versionCode <manifest android:versionCode>
versionName <manifest android:versionName>
minSdkVersion, targetSdkVersion <uses-sdk>
uses-permission <uses-permission android:name>
nativecode lib/<abi>/ entries in the zip, not the manifest

android:label and android:icon are usually TYPE_REFERENCE into resources.arsc. Resolving those means an ARSC reader, which is a second format of comparable size. Out of scope: the index falls back to packageName for the name, and the repo serves no icons. An .arsc reader can come later if anyone wants pretty listings.

Why this is hand-written rather than rusty-axml

A Rustler NIF is landing anyway for Phase C, so an AXML crate would cost nothing extra in deployment. rusty-axml was the candidate (0.2.1, Apache-2.0, ~16k recent downloads, last release 2025-07). It was evaluated by running it against this repo’s own manifest rather than by reading its docs, the same way the sequoia backend was chosen:

<manifest> attrs = {"package": "org.neiam.gitgud.app",
                    "android:versionCode": "(type 0x10) 0x1",
                    "android:versionName": "0.1.0", ...}
<uses-sdk> attrs = {"android:minSdkVersion": "(type 0x10) 0x1a", ...}

It discards typed values. attributes() is a HashMap<String, String>, and parse_start_element stringifies an integer into "(type 0x10) 0x1" at parse time — the dataType is gone, and there is no typed accessor anywhere in the public API. Every number this adapter needs is an integer attribute, and F-Droid compares versionCode numerically to decide whether something is an update.

Recovering 1 from "(type 0x10) 0x1" means scraping a debug format: correct today, silently wrong the day that format! changes, and wrong in the direction of “the client stops seeing updates” rather than the direction of a crash.

Its other helpers (find_node_by_name matches android:name, not the element type; get_requested_permissions) are shaped for security analysis of components, not for indexing.

So the parser stays in Elixir: it returns version_code: 1 typed from dataType, it is tested against a real AAPT2 manifest including every truncation of it, and it adds no dependency to a code path that reads attacker-supplied input.

The signing certificate is not where you’d expect

F-Droid’s index carries signer — the SHA-256 of the APK’s signing certificate — and the client refuses an update whose signer differs from what’s installed. So it has to be extracted.

The obvious place is META-INF/*.RSA (v1 / JAR signing). It is not there. Checked against this repo’s own APK:

META-INF/*.RSA, MANIFEST.MF  → absent
APK Sig Block 42             → present, block id 0x7109871a (Scheme v2)

minSdk is 26, and AGP omits v1 signing above 24. So the cert comes from the APK Signing Block, which sits between the last zip entry and the central directory:

EOCD (PK\x05\x06) → central directory offset
 [cd_offset - 16, cd_offset)      magic "APK Sig Block 42"
 [cd_offset - 24, cd_offset - 16) u64 size-of-block (repeated at its start)
 block = u64 size ‖ { u64 len ‖ u32 id ‖ value }* ‖ u64 size ‖ magic

IDs: 0x7109871a is Scheme v2, 0xf05368c0 Scheme v3, 0x42726577 is apksigner’s padding block. A v2/v3 value is:

signers: len-prefixed sequence of
  signer: len-prefixed
    signed data:  len-prefixed
    signatures:   len-prefixed
    public key:   len-prefixed
  where signed data = digests ‖ certificates ‖ additional attributes
  and certificates = len-prefixed sequence of len-prefixed X.509 DER

Every length is a little-endian u32. The first certificate of the first signer is the signing cert; sha256 of its DER is signer.

Both paths get implemented — v1 for anything with minSdk < 24, the signing block for everything else — and v1 is checked second, because an APK carrying both is signed by the same key either way and the block is authoritative.

Verification is out of scope. The forge reads the certificate; it does not verify the signature over the APK. The client does that, and Android does it again at install. Re-implementing v2 digest verification would be a third format for no added guarantee.


Phase B — the index documents

Mechanical assembly from PackageVersion rows, the same shape as the Deb adapter’s release_document / packages_document.

Serve index-v2, plus index-v1 for older clients:

/pkg/fdroid/<owner>/_all/entry.jar          signed; points at index-v2
/pkg/fdroid/<owner>/_all/index-v2.json      verified by hash from entry
/pkg/fdroid/<owner>/_all/index-v1.jar       signed; legacy clients
/pkg/fdroid/<owner>/_all/<apk filename>     the artifact

Per F-Droid’s Security Model: index-v1 signs with SHA1withRSA, index-v2 with SHA256withRSA, and index-v2 accepts “any algorithm supported by apksigner and android-23 and newer”. SHA-1 there is not a weakness — the relevant property is second-preimage resistance — but new work signs v2 and only emits v1 for reach.


Phase C — signing the index

The one genuine unknown, and the same shape of gap OpenPGP was for apt.

A signed JAR is a zip containing:

META-INF/MANIFEST.MF   per-entry SHA-256 digests
META-INF/CERT.SF       digest of the manifest, and of each entry's stanza
META-INF/CERT.RSA      PKCS#7 / CMS detached SignedData over CERT.SF

:public_key speaks X.509 and PKCS but not CMS SignedData. That is precisely the hole native/gitgud_apt was built to fill for OpenPGP.

Route taken: a Rustler NIF, native/gitgud_apk, using RustCrypto’s cms, x509-cert, rsa, sha2 and zip. All pure Rust. Measured against the standard GitGud.Packages.Apt.Native sets out:

gitgud_apt (sequoia)  builder: libclang-dev   runtime: —
gitgud_apk (RustCrypto) builder: —            runtime: —

Cheaper than the backend already accepted for apt — nothing is added to either image stage.

The repo’s self-signed X.509 certificate does not need the NIF: :public_key.pkix_sign/2 builds it, over the RSA keypair Packages.Token.generate_keypair/0 already produces. So generate_keypair(owner, "fdroid") is one new clause returning PEM, and the NIF only does CMS.

Sign at publish, not at read

The Deb adapter signs InRelease on every request. At NIF speed that is free. It would not be at any other speed, and F-Droid clients poll indexes.

So fdroid materialises entry.jar and index-v1.jar into GitGud.Storage when an APK is published and serves them as blobs — {:blob, package_file} is already an index_result variant, so the adapter contract needs nothing new. Signing then runs once per publish instead of once per poll.

This also keeps the fallback open (below) without a rewrite.

If RustCrypto goes wrong

jarsigner/apksigner in their own container, the way zot is the fallback-shaped dependency for OCI and aptly was named for apt. It works, and sign-at-publish makes the latency irrelevant — but it is a worse trade here than aptly was for apt:

  • aptly replaces an entire repository format.
  • A signer sidecar replaces only the CMS step. Phases A, B and the zip assembly stay.

And it costs a key-custody split: the private key lives encrypted in Postgres today, and a JVM wants a keystore file. If it happens, the key stays in Postgres and is handed over loopback per request, never persisted in the container, which must have no ingress — it will sign whatever it is given.

Any shim written for this is a small Rust daemon, not a JVM wrapper. The JVM is the thing being avoided; adding one to escape 150 lines of Rust is the wrong direction.


Phase D — publishing from CI

The existing deb job is the template, down to the verify step:

- name: Publish the APK
  run: |
    curl --fail-with-body -sS \
      -H "Authorization: Bearer ${{ github.token }}" \
      -H "Content-Type: application/vnd.android.package-archive" \
      --upload-file android/app/build/outputs/apk/release/app-release.apk \
      "$FORGE_URL/pkg/fdroid/$REPOSITORY/upload"

Consuming it is one repo entry:

$FORGE_URL/pkg/fdroid/<owner>/_all?fingerprint=<sha256 of the repo cert>

Open question — confirm before building UI on it. The fingerprint query parameter is how fdroidserver hands out repo URLs and how the QR path works, but neither All_our_APIs nor Setup_an_F-Droid_App_Repo documents it (the latter is stale — it still describes index.xml). Confirm against fdroidserver source. If it holds, the packages page should emit a QR as well as the URL: F-Droid adds repos by scan, and EQRCode is already a dependency.

Prerequisite: a release keystore

android/README.md notes there isn’t one. A debug-signed APK can be published but not meaningfully consumed — and the signer fingerprint would be the shared Android debug key, which every developer has.

This is the APK signing key, distinct from the index signing key. Keep them apart:

Key Lives Signs
APK release keystore CI secrets (GitGud.Secrets) the app
Repo index key SigningKey, per namespace the index

Key custody — OpenBao

F-Droid’s docs say: “the repo signing keys should not ever be on a public server.” A forge that serves its own index cannot follow that literally. It can, however, stop holding the key.

GitGud.Packages.Signing is the seam: sign/4 takes bytes and returns a signature, and an implementation is free to never possess key material. Two backends:

Backend Key lives Used by
Signing.Local Postgres, AES-GCM via GitGud.Secrets everything, by default
Signing.OpenBao inside OpenBao transit, non-exportable fdroid, opt-in

For F-Droid the key is generated inside OpenBao’s transit engine with exportable: false and allow_plaintext_backup: false. No API returns it — not to the app, not to an operator. The forge sends bytes to /v1/transit/sign/gitgud-fdroid-<namespace> and gets a signature back.

The property that buys: compromising the forge yields the ability to sign for as long as the compromise lasts, not a key that outlives it, and every signature is in an audit log. That is what F-Droid’s guidance is actually after.

It is opt-in, and only fdroid opts in. OpenBao is a second stateful service to unseal and back up; an instance that doesn’t serve an F-Droid repository never contacts it, never needs it deployed, and fails no health check for its absence. Configuration is gated on OPENBAO_ADDR being set at all:

config :git_gud, GitGud.Packages.Signing,
  formats: %{"fdroid" => GitGud.Packages.Signing.OpenBao}

Deployment is a separate Argo Application (openbao/ in the deployment repo) rather than part of git-gud’s, so not deploying it is the default and requires no edit.

The transit call, exactly

POST /v1/transit/sign/<key>
{ "input": "<base64 of the bytes>",
  "hash_algorithm": "sha2-256",       // "sha1" for index-v1
  "signature_algorithm": "pkcs1v15",  // CMS wants v1.5, not PSS
  "prehashed": false }
→ { "data": { "signature": "vault:v1:<base64>" } }

Two details worth not rediscovering:

  • Send raw bytes, not a digest. hash_algorithm: "none" with prehashed: true is documented as yielding a PKCSv1_5_NoOID signature; CMS needs the DigestInfo-wrapped PKCSv1_5_DERnull form, which is what the default path produces.
  • The vault:v<n>: prefix is stripped. A CMS structure has nowhere to record a key version, so rotation needs key_version threaded through deliberately if it ever matters.

What this does not cover

The APK signing key is a different key with a different home: CI secrets, used by apksigner at build time. OpenBao holds the index key. Keeping them apart is the point — an attacker with the index key can lie about which versions exist; they cannot forge an app, because Android verifies the APK signature at install against the certificate recorded in signer.

Order

A → B → C → D. Extraction first, because it is provable against an APK this repo already builds. Then the index documents, served unsigned, checked with curl. Then signing, last, because it is the piece with a fallback. CI publishing is the smallest step and lands once there is something to publish into.

Sources