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
deblines 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:
u16length, then that many UTF-16LE code units, then au16NUL. A length with the high bit set is a two-unit length. - UTF-8:
u8UTF-16 length,u8byte length, bytes,u8NUL — 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, stated deliberately
F-Droid’s docs say: “the repo signing keys should not ever be on a public server.” The forge holds the index key, so this departs from that advice — knowingly, and for the same reason the apt PGP key is held: a registry that cannot sign its own index is not a registry.
The mitigation is that it is the lesser key. It signs metadata, not
apps. An attacker with it can lie about what versions exist; they
cannot forge an app, because Android verifies the APK signature at
install against the key in signer, which lives in CI.
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
- F-Droid: All our APIs
- F-Droid: Security Model
- APK Signature Scheme v2
- JAR file specification — signed JARs
frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h— the chunk layouts above