Add authenticated F-Droid user repositories and management CLI

Verify pinned JAR/CMS signatures, v2 index hashes and APK downloads; support signed v1 fallback and persist TOFU identities. Reuse the catalogue reducer and document repository publishing with offline and live verification evidence.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 21:06:06 +10:00
1 parent 268afccf05
commit 784a48f218
12 files changed
+903

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# User repositories
Scope: ui/apk_sources/fdroid.py, fixture tests and docs/apk-repos.md. No headset access.
No delegation or independent reviewer launched: task explicitly forbids delegation;
parent integrates and reviews. Existing catalogue API stays unchanged.
Decisions:
- Support F-Droid signed v2 and v1, HTTPS only, RSA PKCS#1 CMS/JAR verification.
- Pin operator certificate fingerprints. Without a supplied fingerprint, verify
the complete signature chain of hashes on first use, then persist that signer.
- Reuse frame_catalog._IndexReader and _reduce_index; keep authenticated source
cache separate from legacy unauthenticated catalogue cache to avoid laundering trust.
- Sources list compatible builds (Android <=30, arm64 or no native code), as
existing catalogue reducer does. This is compatibility filtering, not a runtime guarantee.
- No Obtainium export import or new unsigned JSON format in this source.
Research: downloaded F-Droid API/setup docs, Obtainium and SideQuest READMEs,
Izzy repo page and entry.jar via HTTPS. Izzy's published fingerprint matched
pure-Python CMS validation: 3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
## Final verification
All commands below ran in /Users/saphid/projects/steam-frame-userrepo on user-repos.
- `python3 --version`: Python 3.9.6.
- `python3 -m unittest discover -s tests -p test_fdroid_sources.py`: initially
13 tests, OK, exit 0. Added a cache-corruption test afterward.
- Final `python3 -m unittest discover -s tests`: 180 tests in 6.866s, OK,
exit 0 (includes 14 source tests and existing catalogue/version tests).
- `python3 ui/apk_sources/fdroid.py add 'https://apt.izzysoft.de/fdroid/repo?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A' --name 'IzzyOnDroid verification'`: exit 0;
saved fdroid-user-57e98c13877f14fdea65 with the published pin.
- `python3 ui/apk_sources/fdroid.py search fdroid-user-57e98c13877f14fdea65 'tinymusicplayer'`:
exit 0; com.martinmimigames.tinymusicplayer, version 1.3 / code 4,
GPL-3.0-only, 16,520 bytes.
- `python3 ui/apk_sources/fdroid.py download fdroid-user-57e98c13877f14fdea65 com.martinmimigames.tinymusicplayer`:
exit 0, verified true. Independent hashlib readback matched
d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a.
- Direct `_fetch` + `_jar` calls on F-Droid main/archive entry.jar: exit 0;
both matched the published 43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab pin.
- `.claude/user-repos-proof.json` retains live CLI results and APK readback.
Live APK remains in the per-user apk-sources cache; the added source remains
in the per-user apk-repos.json, as requested for the real add/search/download run.
Not verified: headset installation/runtime, native UI/server integration (sibling
worker), real v1-only server (offline signed fixture covers fallback), executing
the fdroidserver publishing instructions, full F-Droid main/archive index/APK
downloads (their live signed entry jars were checked). No independent reviewer
was run because this task forbids delegation and assigns review to the parent.
Known limits / follow-up questions: only one RSA-2048–8192 JAR signer supported;
no ECDSA/DSA/PSS or section-only SF signatures; no index timestamp rollback or
expiry policy, automated key rotation or cross-process settings-write locking.
The settings API serializes threads and publishes atomically. Should a later
change add explicit rollback policy and broader JAR algorithms? Parent may
choose UI wording for TOFU; this source already returns trust_on_first_use.
+50
View File
@@ -0,0 +1,50 @@
{
"add": {
"id": "fdroid-user-57e98c13877f14fdea65",
"kind": "fdroid",
"name": "IzzyOnDroid verification",
"url": "https://apt.izzysoft.de/fdroid/repo/",
"builtin": false,
"enabled": true,
"trust": "user",
"fingerprint": "3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a",
"trust_on_first_use": false
},
"search": [
{
"source": "fdroid-user-57e98c13877f14fdea65",
"id": "com.martinmimigames.tinymusicplayer",
"package": "com.martinmimigames.tinymusicplayer",
"name": "Tiny Music Player",
"summary": "Android 1.0+ minimal (",
"icon": "https://apt.izzysoft.de/fdroid/repo/com.martinmimigames.tinymusicplayer/en-US/icon.png",
"page": "https://martinmimigames.github.io/projects/tiny-music-player/index.html",
"vr": null,
"free": true,
"license": "GPL-3.0-only",
"downloadable": true,
"version": "1.3",
"version_code": 4,
"min_sdk": 1,
"abis": [],
"size": 16520,
"updated": "2023-02-04"
}
],
"download": {
"apk": "/Users/saphid/Library/Caches/Frame Control/apk-sources/d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a.apk",
"obb": [],
"sha256": "d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a",
"verified": true
},
"independent_readback": {
"size": 16520,
"sha256": "d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a"
},
"python": "3.9.6",
"suite": "python3 -m unittest discover -s tests: 180 tests, 6.866s, OK, exit 0",
"builtins_entry_signatures": {
"fdroid": "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab",
"fdroid-archive": "43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab"
}
}
+131
View File
@@ -0,0 +1,131 @@
# APK repositories
Frame Control supports **F-Droid-format repositories**, including F-Droid,
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
indexes are authenticated before their apps appear. Search lists builds with
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
## Formats considered
| Format | Users and purpose | Support in this source |
|---|---|---|
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
Research references (checked 2026-09-28):
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
- [Droid-ify](https://github.com/Droid-ify/client) and
[Neo Store](https://github.com/NeoApplications/Neo-Store).
- [Obtainium](https://github.com/ImranR98/Obtainium), its
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
and [community app configurations](https://apps.obtainium.imranr.dev/).
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
The absence of a specification in these materials is not proof that no
historical or private custom-feed format exists.
## Add a repository in Frame Control
From the Frame Control checkout, use its source-management CLI:
```sh
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
python3 ui/apk_sources/fdroid.py list
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
```
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
A URL must identify the repository directory, not its website or an index file.
Adding fetches and validates the complete index **before saving** the source.
Without a fingerprint, Frame Control verifies the JAR signature and remembers
its signer: trust on first use (TOFU). This establishes continuity with the
first server response, not independent publisher identity. Obtain the published
fingerprint through a trusted channel when possible. Re-adding an existing URL
preserves its pin; changing it requires deliberately removing and re-adding it.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
separate work. Built-in sources can be disabled but cannot be removed.
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
Authenticated reduced indexes and APKs live under
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours.
The existing catalogue's unverified index cache is never treated as authenticated.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
Do not publish paid app mirrors or bypass store licences. Check distribution
terms before adding someone else's repository; this module does not infer legal
permission from a signature or automatically audit a repository's terms.
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
and its documented Android/Java dependencies on the publishing machine, then:
```sh
mkdir my-fdroid
cd my-fdroid
fdroid init
# Set repo_url in config.yml to https://example.org/fdroid/repo
# Also set repo_name and repo_description; keep the generated signing key safe.
cp /path/to/your-free-app.apk repo/
fdroid update --create-metadata
# Review the generated metadata (name, summary, licence, source and website).
fdroid update
```
Serve the generated **repo directory** at that HTTPS URL, including APKs,
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
private signing keystore or configuration passwords. Configure fdroidserver's
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
public directory with your existing deployment tool. Publish the SHA-256
repository certificate fingerprint displayed by fdroidserver in a link such as
`https://example.org/fdroid/repo?fingerprint=…`.
Keep the repository signing key backed up: changing it breaks existing pins.
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
publish again. Test the published URL with Frame Control's `add`, `search` and
`download` commands. The above publisher setup is documented from fdroidserver;
it was not executed as part of this implementation.
## Verification and limits
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
recognized. It checks the signer certificate pin, the signature over `.SF`,
the whole-manifest digest, and the manifest's digest of the JSON member.
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
rejected. Certificates are pinned identities, not validated as Web PKI chains.
HTTPS certificates are separately checked by Python's normal TLS validation.
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
fingerprint, index hash, TLS and server errors never trigger an unsigned
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
`verified: true` means the bytes match the signed repository's APK hash; it
is not an independent APK publisher-signature or runtime compatibility verdict.
There is no repository timestamp rollback/expiry policy or automated signing-key
rotation yet. An old correctly signed index can still validate.
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
the real IzzyOnDroid repository was added with its published pin, searched for
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
No headset connection or installation was performed.
+14
View File
@@ -0,0 +1,14 @@
# F-Droid verification fixtures
`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs,
including CMS signed attributes. `fingerprint.txt` identifies their throwaway
certificate. Their JSON describes org.example.app; `example.apk` is deliberately
plain test data, not an installable app. The v2 index includes incompatible
Android-31 and x86-only versions to exercise the shared reducer.
`izzy-entry.jar` was recorded from
https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate
fingerprint matches the operator's published fingerprint:
3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
It exercises an independent production JAR/CMS encoder without network access.
The index it references is not needed by this signature-only fixture test.
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Fixture APK payload, deliberately not installable.
+1
View File
@@ -0,0 +1 @@
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}}
Binary file not shown.
+176
View File
@@ -0,0 +1,176 @@
"""Offline authenticated repository fixtures; no tests contact a server."""
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import urllib.error
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, fdroid
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
URL = 'https://example.org/repo/'
class Repositories(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
mock = patch.object(fdroid.frame_host, name, value)
mock.start()
self.addCleanup(mock.stop)
net = patch.object(fdroid.urllib.request, 'build_opener', side_effect=AssertionError('network forbidden'))
net.start()
self.addCleanup(net.stop)
mock = patch.object(fdroid, '_fetch', side_effect=self.fetch)
self.fetch_mock = mock.start()
self.addCleanup(mock.stop)
self.v1 = False
self.corrupt = None
def fetch(self, url, path, maximum):
name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
payload = (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
if name == self.corrupt:
payload += b'tampered'
Path(path).write_bytes(payload)
def add(self):
return fdroid.add_repo(URL, PIN)
def test_add_search_details_download_cache(self):
source = self.add()
self.assertEqual(source['fingerprint'], PIN)
self.assertFalse(source['trust_on_first_use'])
result = fdroid.search(source, 'example offline')
self.assertEqual(len(result), 1)
self.assertNotIn('versions', result[0])
self.assertEqual(result[0]['version_code'], 2)
self.assertEqual([v['version_code'] for v in fdroid.details(source, 'org.example.app')['versions']], [2, 1])
downloaded = fdroid.download(source, 'org.example.app', 1)
self.assertTrue(downloaded['verified'])
self.assertEqual(Path(downloaded['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes())
count = self.fetch_mock.call_count
fdroid.download(source, 'org.example.app', 1)
self.assertEqual(self.fetch_mock.call_count, count)
def test_wrong_pin_is_not_saved(self):
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.add_repo(URL, '0' * 64)
self.assertEqual(fdroid.user_repos(), [])
def test_tampered_index_is_not_saved(self):
self.corrupt = 'index-v2.json'
with self.assertRaisesRegex(SourceError, 'SHA-256'):
self.add()
self.assertEqual(fdroid.user_repos(), [])
def test_tampered_apk_is_not_cached(self):
source = self.add()
self.corrupt = 'example2.apk'
with self.assertRaisesRegex(SourceError, 'APK SHA-256'):
fdroid.download(source, 'org.example.app')
self.assertEqual(list(self.root.rglob('*.apk')), [])
self.assertEqual(list(self.root.rglob('*.part')), [])
def test_v1_fallback(self):
self.v1 = True
source = self.add()
self.assertEqual(fdroid.search(source, 'Example')[0]['version_code'], 1)
self.assertTrue(fdroid.download(source, 'org.example.app')['verified'])
def test_bad_v2_never_downgrades(self):
self.corrupt = 'index-v2.json'
with self.assertRaises(SourceError):
self.add()
self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
def test_transient_error_never_downgrades(self):
self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None)
with self.assertRaises(SourceError):
self.add()
self.assertEqual(self.fetch_mock.call_count, 1)
def test_tofu_preserves_pin_and_settings(self):
source = fdroid.add_repo('fdroidrepos://example.org/repo')
self.assertTrue(source['trust_on_first_use'])
self.assertEqual(source['fingerprint'], PIN)
fdroid.add_repo(URL)
self.assertEqual(len(fdroid.user_repos()), 1)
with self.assertRaisesRegex(SourceError, 'different pinned'):
fdroid.add_repo(URL, '0' * 64)
fdroid.set_enabled(source['id'], False)
self.assertEqual(fdroid.search(fdroid.user_repos()[0], ''), [])
with self.assertRaisesRegex(SourceError, 'disabled'):
fdroid.download(fdroid.user_repos()[0], 'org.example.app')
fdroid.set_enabled('fdroid', False)
self.assertFalse(fdroid.sources()[0]['enabled'])
fdroid.remove_repo(source['id'])
self.assertEqual(fdroid.user_repos(), [])
with self.assertRaises(SourceError):
fdroid.remove_repo('fdroid')
def test_urls(self):
self.assertEqual(fdroid._url(URL + '?fingerprint=' + PIN.upper()), (URL, PIN))
for url in ['http://example.org/repo', 'fdroidrepo://example.org', 'https://u:p@example.org', URL+'?other=x']:
with self.subTest(url=url), self.assertRaises(SourceError):
fdroid._url(url)
with self.assertRaisesRegex(SourceError, 'conflicting'):
fdroid._url(URL + '?fingerprint=' + PIN, '0' * 64)
for name in ['../x.apk', '%2e%2e/x.apk', 'https://evil.org/a.apk', '//evil.org/../x', 'x?token=y', 'x\\y']:
with self.subTest(name=name), self.assertRaises(SourceError):
fdroid._child(URL, name)
def test_recorded_real_signature(self):
content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN)
self.assertEqual(fingerprint, fdroid.IZZY_PIN)
self.assertIn('index', json.loads(content))
def test_tampering_each_signature_layer(self):
for member in ['entry.json', 'META-INF/MANIFEST.MF', 'META-INF/TEST.SF', 'META-INF/TEST.RSA']:
stream = io.BytesIO()
with zipfile.ZipFile(FIXTURES / 'entry.jar') as src, zipfile.ZipFile(stream, 'w') as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename == member:
data = data[:-1] + bytes([data[-1] ^ 1])
dst.writestr(item.filename, data)
with self.subTest(member=member), self.assertRaises(SourceError):
fdroid._jar(io.BytesIO(stream.getvalue()), 'entry.json', PIN)
def test_duplicate_jar_member_rejected(self):
stream = io.BytesIO((FIXTURES / 'entry.jar').read_bytes())
import warnings
with warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
with zipfile.ZipFile(stream, 'a') as z:
z.writestr('entry.json', '{}')
stream.seek(0)
with self.assertRaisesRegex(SourceError, 'duplicate'):
fdroid._jar(stream, 'entry.json', PIN)
def test_corrupt_cache_refetches_verified_index(self):
source = self.add()
fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json').write_text('{')
self.assertEqual(len(fdroid.search(source, 'example')), 1)
self.assertEqual(self.fetch_mock.call_count, 4)
def test_cached_index_does_not_cross_pins(self):
source = self.add()
source['fingerprint'] = '0' * 64
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.search(source, '')
if __name__ == '__main__':
unittest.main()
+474
View File
@@ -0,0 +1,474 @@
"""Signed F-Droid repositories. CLI: add|remove|list|search|download."""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from apk_sources import SourceError
import frame_host
from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
_LOCK = threading.RLock()
# Published by the repository operators; a user repository without a pin uses TOFU.
FDROID_PIN = '43238d512c1e5eb2d6569f4a3afbf5523418b82e0a3ed1552770abb9a9c9ccab'
IZZY_PIN = '3bf0d6abfeae2f401707b6d966be743bf0eee49c2561b9ba39073711f628937a'
_DIGESTS = {
'608648016503040201': ('sha256', '3031300d060960864801650304020105000420'),
'608648016503040202': ('sha384', '3041300d060960864801650304020205000430'),
'608648016503040203': ('sha512', '3051300d060960864801650304020305000440'),
'2b0e03021a': ('sha1', '3021300906052b0e03021a05000414'),
}
def _fingerprint(value):
value = re.sub(r'[:\s]', '', value or '').lower()
if not re.fullmatch('[0-9a-f]{64}', value):
raise SourceError('fingerprint must be a SHA-256 certificate fingerprint (64 hex digits)')
return value
def _url(url, fingerprint=None):
if url.startswith('fdroidrepos://'):
url = 'https://' + url[len('fdroidrepos://'):]
p = urllib.parse.urlsplit(url)
if p.scheme != 'https' or not p.hostname or p.username or p.password or p.fragment:
raise SourceError('repository URL must use HTTPS without credentials or a fragment')
params = urllib.parse.parse_qs(p.query)
pins = params.pop('fingerprint', [])
if params or len(pins) > 1:
raise SourceError('only one fingerprint query parameter is supported')
pin = _fingerprint(fingerprint) if fingerprint else None
if pins:
linked = _fingerprint(pins[0])
if pin and pin != linked:
raise SourceError('conflicting fingerprints')
pin = linked
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
def _child(base, name):
name = str(name).lstrip('/')
decoded = urllib.parse.unquote(name)
if not name or '\\' in decoded or any(x in ('.', '..') for x in decoded.split('/')):
raise SourceError('unsafe repository file name')
url = urllib.parse.urljoin(base, name)
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
raise SourceError('repository file is outside its repository')
return url
class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlsplit(newurl).scheme != 'https':
raise SourceError('refusing non-HTTPS redirect')
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _fetch(url, path, maximum):
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
total = 0
while True:
chunk = r.read(1 << 20)
if not chunk:
break
total += len(chunk)
if total > maximum:
raise SourceError('repository file exceeds size limit')
f.write(chunk)
def _children(item):
return _der_parts(item[1])
def _cms(data, content):
outer = _der_parts(data)
if len(outer) != 1:
raise ValueError('invalid CMS wrapper')
wrapper = _children(outer[0])
if wrapper[0][1].hex() != '2a864886f70d010702':
raise ValueError('not CMS SignedData')
fields = _children(_children(wrapper[1])[0])
certs = next(_children(f) for f in fields[3:] if f[0] == 0xa0)
signers = _children(fields[-1])
if len(signers) != 1:
raise ValueError('exactly one repository signer required')
signer = _children(signers[0])
sid = _children(signer[1])
matching = []
for cert in certs:
tbs = _children(_children(cert)[0])
offset = 1 if tbs[0][0] == 0xa0 else 0
if tbs[offset][1] == sid[1][1] and tbs[offset + 2][2] == sid[0][2]:
matching.append(cert[2])
if len(matching) != 1:
raise ValueError('missing or ambiguous signer certificate')
cert = matching[0]
digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()]
at, signed = 3, content
if signer[at][0] == 0xa0:
attrs = {}
for attr in _children(signer[at]):
pair = _children(attr)
oid = pair[0][1].hex()
if oid in attrs:
raise ValueError('duplicate CMS attribute')
attrs[oid] = _children(pair[1])
if attrs['2a864886f70d010904'][0][1] != hashlib.new(digest, content).digest():
raise ValueError('CMS content digest mismatch')
if attrs['2a864886f70d010903'][0][1].hex() != '2a864886f70d010701':
raise ValueError('unexpected CMS content type')
signed = der(0x31, signer[at][1])
at += 1
algorithm = _children(signer[at])[0][1].hex()
allowed = {'sha1': '2a864886f70d010105', 'sha256': '2a864886f70d01010b',
'sha384': '2a864886f70d01010c', 'sha512': '2a864886f70d01010d'}
if algorithm not in ('2a864886f70d010101', allowed[digest]):
raise ValueError('unsupported repository signature algorithm (RSA PKCS#1 required)')
n, e, _ = _cert_key(cert)
sig = signer[at + 1][1]
size = (n.bit_length() + 7) // 8
if not 256 <= size <= 1024 or n % 2 != 1 or not 3 <= e <= 0xffffffff or e % 2 != 1 or len(sig) != size or int.from_bytes(sig, 'big') >= n:
raise ValueError('invalid RSA signature/key size')
value = bytes.fromhex(prefix) + hashlib.new(digest, signed).digest()
expected = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
if pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') != expected:
raise ValueError('repository RSA signature mismatch')
return hashlib.sha256(cert).hexdigest()
def _sections(data):
sections = []
for block in re.split(b'\r?\n\r?\n', data):
if not block:
continue
attrs = {}
for line in re.sub(b'\r?\n ', b'', block).splitlines():
key, value = line.decode('utf-8').split(': ', 1)
key = key.lower()
if key in attrs:
raise ValueError('duplicate manifest attribute')
attrs[key] = value
sections.append(attrs)
return sections
def _digest_check(attrs, suffix, content):
for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')):
if label + suffix in attrs:
if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest():
raise ValueError('JAR digest mismatch')
return
raise ValueError('missing supported JAR digest')
def _jar(path, member, pin):
try:
with zipfile.ZipFile(path) as z:
names = z.namelist()
if len(names) > 64 or len(names) != len(set(names)) or any(
i.file_size > (1024 * 1024 if i.filename.upper().startswith('META-INF/') else 256 * 1024 * 1024)
for i in z.infolist()):
raise ValueError('duplicate or oversized JAR member')
blocks = [n for n in names if n.upper().startswith('META-INF/') and n.upper().endswith('.RSA')]
if len(blocks) != 1:
raise ValueError('exactly one RSA JAR signer required')
sf = z.read(blocks[0][:-4] + '.SF')
fingerprint = _cms(z.read(blocks[0]), sf)
if pin and fingerprint != pin:
raise ValueError('repository fingerprint mismatch')
manifest = z.read('META-INF/MANIFEST.MF')
_digest_check(_sections(sf)[0], '-digest-manifest', manifest)
entries = [s for s in _sections(manifest)[1:] if s.get('name') == member]
if len(entries) != 1:
raise ValueError('index is not uniquely signed')
content = z.read(member)
_digest_check(entries[0], '-digest', content)
return content, fingerprint
except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e:
raise SourceError('invalid signed repository: ' + str(e)) from e
def _storage():
return frame_host.data_dir('apk-repos.json')
def _read():
try:
settings = json.loads(_storage().read_text())
if not isinstance(settings, dict) or not isinstance(settings.get('repos'), list) or not isinstance(settings.get('enabled'), dict):
raise ValueError('invalid settings structure')
return settings
except FileNotFoundError:
return {'repos': [], 'enabled': {}}
except (OSError, ValueError) as e:
raise SourceError('cannot read repository settings: ' + str(e)) from e
def _write(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
try:
with os.fdopen(fd, 'w') as f:
json.dump(value, f, separators=(',', ':'))
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def user_repos():
with _LOCK:
return _read()['repos']
def sources():
builtins = [('fdroid', 'F-Droid', 'https://f-droid.org/repo/', FDROID_PIN),
('fdroid-archive', 'F-Droid archive', 'https://f-droid.org/archive/', FDROID_PIN),
('izzyondroid', 'IzzyOnDroid', 'https://apt.izzysoft.de/fdroid/repo/', IZZY_PIN)]
settings = _read()
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
enabled=settings['enabled'].get(i, True), trust='community')
for i, n, u, p in builtins] + settings['repos']
def _text(value):
if isinstance(value, dict):
return value.get('en-US') or value.get('en') or next(iter(value.values()), '')
return value or ''
def _reduce(path, source):
compatible = _reduce_index(path)
result = {}
with open(path, encoding='utf-8') as f:
reader = _IndexReader(f)
for key in reader.members():
if key != 'packages':
reader.value()
continue
for pkg in reader.members():
item = reader.value()
if pkg not in compatible:
continue
meta = item.get('metadata', {})
files = {v['file'].get('name'): v for v in item.get('versions', {}).values()
if isinstance(v, dict) and isinstance(v.get('file'), dict)}
versions = []
for v in compatible[pkg]:
original = files[v['name']]
versions.append(dict(v, size=original['file'].get('size'), updated=_date(original.get('added'))))
versions.sort(key=lambda v: (v['version_code'], v['abis'] == ['arm64-v8a']), reverse=True)
latest = versions[0]
icon = _text(meta.get('icon'))
result[pkg] = dict(source=source['id'], id=pkg, package=pkg,
name=_text(meta.get('name')) or pkg, summary=_text(meta.get('summary')),
icon=_child(source['url'], icon['name']) if isinstance(icon, dict) and icon.get('name') else None,
page=meta.get('webSite') or source['url'], vr=None, free=True,
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
return result
def _date(value):
return time.strftime('%Y-%m-%d', time.gmtime(value / 1000)) if isinstance(value, (int, float)) else None
def _v1(content, path):
index = json.loads(content)
apps = {a['packageName']: a for a in index['apps']}
packages = {}
for pkg, builds in index['packages'].items():
app = apps.get(pkg, {})
localized = app.get('localized', {})
en = localized.get('en-US') or next(iter(localized.values()), {})
meta = {k: en.get(k) or app.get(k) for k in ('name', 'summary', 'license', 'webSite')}
versions = {}
for i, v in enumerate(builds):
versions[str(i)] = {'manifest': {'versionName': v.get('versionName'), 'versionCode': v['versionCode'],
'usesSdk': {'minSdkVersion': v.get('minSdkVersion', 1)}, 'nativecode': v.get('nativecode', [])},
'file': {'name': v['apkName'], 'sha256': v.get('hash') if v.get('hashType') == 'sha256' else None,
'size': v.get('size')}, 'added': v.get('added')}
packages[pkg] = {'metadata': meta, 'versions': versions}
path.write_text(json.dumps({'packages': packages}))
def _load(source, force=False):
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
raise SourceError('invalid source id')
_url(source['url'], source.get('fingerprint'))
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
with _LOCK:
if not force and cache.exists() and time.time() - cache.stat().st_mtime < 86400:
try:
saved = json.loads(cache.read_text())
if saved.get('fingerprint') == source.get('fingerprint') and saved.get('url') == source['url']:
return saved['apps'], saved['fingerprint']
except (OSError, ValueError, KeyError, AttributeError):
pass
cache.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
except urllib.error.HTTPError as e:
if e.code not in (404, 410):
raise
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
_v1(content, raw)
else:
content, pin = _jar(jar, 'entry.json', source.get('fingerprint'))
entry = json.loads(content)['index']
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
raise SourceError('index SHA-256 or size mismatch')
apps = _reduce(raw, source)
_write(cache, {'url': source['url'], 'fingerprint': pin, 'apps': apps})
return apps, pin
except SourceError:
raise
except (OSError, ValueError, KeyError, TypeError, IndexError) as e:
raise SourceError('cannot load repository: ' + str(e)) from e
def add_repo(url, fingerprint=None, name=None):
url, pin = _url(url, fingerprint)
with _LOCK:
settings = _read()
existing = next((s for s in settings['repos'] if s['url'] == url), None)
if existing:
if pin and pin != existing['fingerprint']:
raise SourceError('repository already has a different pinned fingerprint; remove it first')
pin = existing['fingerprint']
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
_, source['fingerprint'] = _load(source, force=True)
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
return source
def remove_repo(source_id):
with _LOCK:
settings = _read()
if not any(s['id'] == source_id for s in settings['repos']):
raise SourceError('unknown user repository')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
_write(_storage(), settings)
def set_enabled(source_id, enabled):
if not isinstance(enabled, bool):
raise SourceError('enabled must be a boolean')
with _LOCK:
settings = _read()
source = next((s for s in sources() if s['id'] == source_id), None)
if not source:
raise SourceError('unknown repository')
if source['builtin']:
settings['enabled'][source_id] = enabled
else:
for s in settings['repos']:
if s['id'] == source_id:
s['enabled'] = enabled
_write(_storage(), settings)
def search(source, query, limit=50):
if not source.get('enabled', True):
return []
apps, _ = _load(source)
words = query.casefold().split()
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
def details(source, entry_id):
if not source.get('enabled', True):
raise SourceError('repository is disabled')
apps, _ = _load(source)
if entry_id not in apps:
raise SourceError('app has no Lepton-compatible version in this repository')
return apps[entry_id]
def download(source, entry_id, version_code=None):
entry = details(source, entry_id)
version = next((v for v in entry['versions'] if version_code is None or str(v['version_code']) == str(version_code)), None)
if not version or not re.fullmatch('[0-9a-f]{64}', version.get('sha256') or ''):
raise SourceError('version is missing or has no SHA-256 digest')
sha = version['sha256']
path = frame_host.cache_dir('apk-sources', sha + '.apk')
try:
if not path.exists() or _sha256(path) != sha:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
os.close(fd)
try:
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
if _sha256(tmp) != sha:
raise SourceError('APK SHA-256 mismatch; download discarded')
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
os.unlink(tmp)
return {'apk': str(path), 'obb': [], 'sha256': sha, 'verified': True}
except OSError as e:
raise SourceError('cannot download APK: ' + str(e)) from e
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='command', required=True)
add = sub.add_parser('add')
add.add_argument('url')
add.add_argument('--fingerprint')
add.add_argument('--name')
sub.add_parser('list')
remove = sub.add_parser('remove')
remove.add_argument('source')
for command in ('search', 'download'):
p = sub.add_parser(command)
p.add_argument('source')
p.add_argument('query' if command == 'search' else 'package')
args = parser.parse_args()
try:
if args.command == 'add':
result = add_repo(args.url, args.fingerprint, args.name)
elif args.command == 'list':
result = sources()
elif args.command == 'remove':
result = remove_repo(args.source)
else:
source = next((s for s in sources() if s['id'] == args.source), None)
if not source:
raise SourceError('unknown repository id; use list')
result = search(source, args.query) if args.command == 'search' else download(source, args.package)
print(json.dumps(result, indent=2))
except SourceError as e:
parser.exit(1, 'error: ' + str(e) + '\n')
if __name__ == '__main__':
main()