F-Droid: refuse index rollbacks, v1 downgrades after v2, and SHA-1 entry.jar

Each repository's newest accepted index timestamp is stored and older indexes
are refused. index-v1.jar is only a fallback while no v2 index has been
accepted. entry.jar must use SHA-2; the recorded IzzyOnDroid entry.jar is
SHA-256 and still verifies. Tests sign JARs with a throwaway key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 22:08:26 +10:00
1 parent 328b7ed211
commit c68afa6c5d
3 files changed
+150 -12

No files matched your search

+7
View File
@@ -69,6 +69,13 @@ Authenticated reduced indexes and APKs live under
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. `frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours.
The existing catalogue's unverified index cache is never treated as authenticated. The existing catalogue's unverified index cache is never treated as authenticated.
Rollback protection: each repository's newest accepted signed index timestamp
is kept in `apk-repo-state.json` next to the settings, and an older index is
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## Publish your own repository ## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute. Only publish free APKs you own or have the developer's permission to distribute.
+79 -2
View File
@@ -15,6 +15,45 @@ from apk_sources import SourceError, fdroid
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid' FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip() PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
URL = 'https://example.org/repo/' URL = 'https://example.org/repo/'
_KEY = []
def signed_jar(member, content, digest='sha256'):
"""A JAR signed like fdroidserver's (no CMS signed attributes) with a throwaway test key."""
import base64, hashlib
from frame_apk_sign import certificate, der, integer, sequence, signing_key
if not _KEY:
with tempfile.TemporaryDirectory() as tmp:
_KEY.append(signing_key(Path(tmp) / 'key.json'))
key = _KEY[0]
label = 'SHA1' if digest == 'sha1' else 'SHA-256'
b64 = lambda data: base64.b64encode(hashlib.new(digest, data).digest()).decode()
manifest = ('Manifest-Version: 1.0\r\n\r\nName: %s\r\n%s-Digest: %s\r\n\r\n' % (member, label, b64(content))).encode()
sf = ('Signature-Version: 1.0\r\n%s-Digest-Manifest: %s\r\n\r\n' % (label, b64(manifest))).encode()
oid, prefix = next((bytes.fromhex(o), bytes.fromhex(p)) for o, (d, p) in fdroid._DIGESTS.items() if d == digest)
alg = sequence(der(6, oid), der(5, b''))
size = (key['n'].bit_length() + 7) // 8
value = prefix + hashlib.new(digest, sf).digest()
padded = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
signature = pow(int.from_bytes(padded, 'big'), key['d'], key['n']).to_bytes(size, 'big')
cert = certificate(key)
issuer = fdroid._der_parts(fdroid._der_parts(fdroid._der_parts(cert)[0][1])[0][1])[3][2]
signer = sequence(integer(1), sequence(issuer, integer(1)), alg,
sequence(der(6, bytes.fromhex('2a864886f70d010101')), der(5, b'')), der(4, signature))
signed = sequence(integer(1), der(0x31, alg), sequence(der(6, bytes.fromhex('2a864886f70d010701'))),
der(0xa0, cert), der(0x31, signer))
block = sequence(der(6, bytes.fromhex('2a864886f70d010702')), der(0xa0, signed))
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as z:
for name, data in (('META-INF/MANIFEST.MF', manifest), ('META-INF/TEST.SF', sf),
('META-INF/TEST.RSA', block), (member, content)):
z.writestr(name, data)
return stream.getvalue(), fdroid.hashlib.sha256(cert).hexdigest()
def entry_jar(timestamp, digest='sha256'):
entry = json.loads(zipfile.ZipFile(FIXTURES / 'entry.jar').read('entry.json'))
return signed_jar('entry.json', json.dumps(dict(entry, timestamp=timestamp)).encode(), digest)
class Repositories(unittest.TestCase): class Repositories(unittest.TestCase):
@@ -35,12 +74,13 @@ class Repositories(unittest.TestCase):
self.addCleanup(mock.stop) self.addCleanup(mock.stop)
self.v1 = False self.v1 = False
self.corrupt = None self.corrupt = None
self.files = {}
def fetch(self, url, path, maximum): def fetch(self, url, path, maximum):
name = url.rsplit('/', 1)[-1] name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar': if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None) raise urllib.error.HTTPError(url, 404, 'missing', None, None)
payload = (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes() payload = self.files.get(name) or (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
if name == self.corrupt: if name == self.corrupt:
payload += b'tampered' payload += b'tampered'
Path(path).write_bytes(payload) Path(path).write_bytes(payload)
@@ -134,7 +174,7 @@ class Repositories(unittest.TestCase):
fdroid._child(URL, name) fdroid._child(URL, name)
def test_recorded_real_signature(self): def test_recorded_real_signature(self):
content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN) content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN, strong=True)
self.assertEqual(fingerprint, fdroid.IZZY_PIN) self.assertEqual(fingerprint, fdroid.IZZY_PIN)
self.assertIn('index', json.loads(content)) self.assertIn('index', json.loads(content))
@@ -255,6 +295,43 @@ class Repositories(unittest.TestCase):
release.set() release.set()
slow.join() slow.join()
def test_rollback_to_older_index_is_refused(self):
self.files['entry.jar'], pin = entry_jar(2000)
source = fdroid.add_repo(URL)
self.assertEqual(source['fingerprint'], pin)
self.files['entry.jar'], _ = entry_jar(1000)
with self.assertRaisesRegex(SourceError, 'older'):
fdroid._load(source, force=True)
for timestamp in (2000, 3000): # unchanged and newer indexes are fine
self.files['entry.jar'], _ = entry_jar(timestamp)
self.assertEqual(len(fdroid._load(source, force=True)[0]), 1)
self.files['entry.jar'], _ = entry_jar(2000)
with self.assertRaisesRegex(SourceError, 'older'):
fdroid._load(source, force=True)
fdroid.remove_repo(source['id']) # a deliberate re-add starts over
self.assertEqual(fdroid.add_repo(URL)['fingerprint'], pin)
def test_no_v1_fallback_once_v2_accepted(self):
source = self.add()
self.v1 = True
with self.assertRaisesRegex(SourceError, 'v2'):
fdroid._load(source, force=True)
self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
def test_v1_then_v2_upgrade_is_allowed(self):
self.v1 = True
source = self.add()
self.v1 = False
self.assertEqual(fdroid._load(source, force=True)[0]['org.example.app']['version_code'], 2)
def test_sha1_only_entry_jar_rejected(self):
self.files['entry.jar'], _ = entry_jar(1, 'sha1')
with self.assertRaisesRegex(SourceError, 'SHA-1'):
fdroid.add_repo(URL)
self.assertEqual(fdroid.user_repos(), [])
stream = io.BytesIO(self.files['entry.jar'])
self.assertIn(b'index', fdroid._jar(stream, 'entry.json', None)[0]) # index-v1.jar may still use SHA-1
def test_cached_index_does_not_cross_pins(self): def test_cached_index_does_not_cross_pins(self):
source = self.add() source = self.add()
source['fingerprint'] = '0' * 64 source['fingerprint'] = '0' * 64
+64 -10
View File
@@ -102,7 +102,7 @@ def _children(item):
return _der_parts(item[1]) return _der_parts(item[1])
def _cms(data, content): def _cms(data, content, strong=False):
outer = _der_parts(data) outer = _der_parts(data)
if len(outer) != 1: if len(outer) != 1:
raise ValueError('invalid CMS wrapper') raise ValueError('invalid CMS wrapper')
@@ -126,6 +126,8 @@ def _cms(data, content):
raise ValueError('missing or ambiguous signer certificate') raise ValueError('missing or ambiguous signer certificate')
cert = matching[0] cert = matching[0]
digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()] digest, prefix = _DIGESTS[_children(signer[2])[0][1].hex()]
if strong and digest == 'sha1':
raise ValueError('SHA-1 signatures are not accepted for v2 indexes')
at, signed = 3, content at, signed = 3, content
if signer[at][0] == 0xa0: if signer[at][0] == 0xa0:
attrs = {} attrs = {}
@@ -174,16 +176,17 @@ def _sections(data):
return sections return sections
def _digest_check(attrs, suffix, content): def _digest_check(attrs, suffix, content, strong=False):
for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')): for label, digest in (('sha-512', 'sha512'), ('sha-384', 'sha384'), ('sha-256', 'sha256'), ('sha1', 'sha1'), ('sha-1', 'sha1')):
if label + suffix in attrs: if label + suffix in attrs and not (strong and digest == 'sha1'):
if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest(): if base64.b64decode(attrs[label + suffix], validate=True) != hashlib.new(digest, content).digest():
raise ValueError('JAR digest mismatch') raise ValueError('JAR digest mismatch')
return return
raise ValueError('missing supported JAR digest') raise ValueError('missing supported JAR digest')
def _jar(path, member, pin): def _jar(path, member, pin, strong=False):
"""strong: SHA-2 only (v2 entry.jar); index-v1.jar may still be SHA-1 signed."""
try: try:
with zipfile.ZipFile(path) as z: with zipfile.ZipFile(path) as z:
names = z.namelist() names = z.namelist()
@@ -195,16 +198,16 @@ def _jar(path, member, pin):
if len(blocks) != 1: if len(blocks) != 1:
raise ValueError('exactly one RSA JAR signer required') raise ValueError('exactly one RSA JAR signer required')
sf = z.read(blocks[0][:-4] + '.SF') sf = z.read(blocks[0][:-4] + '.SF')
fingerprint = _cms(z.read(blocks[0]), sf) fingerprint = _cms(z.read(blocks[0]), sf, strong)
if pin and fingerprint != pin: if pin and fingerprint != pin:
raise ValueError('repository fingerprint mismatch') raise ValueError('repository fingerprint mismatch')
manifest = z.read('META-INF/MANIFEST.MF') manifest = z.read('META-INF/MANIFEST.MF')
_digest_check(_sections(sf)[0], '-digest-manifest', manifest) _digest_check(_sections(sf)[0], '-digest-manifest', manifest, strong)
entries = [s for s in _sections(manifest)[1:] if s.get('name') == member] entries = [s for s in _sections(manifest)[1:] if s.get('name') == member]
if len(entries) != 1: if len(entries) != 1:
raise ValueError('index is not uniquely signed') raise ValueError('index is not uniquely signed')
content = z.read(member) content = z.read(member)
_digest_check(entries[0], '-digest', content) _digest_check(entries[0], '-digest', content, strong)
return content, fingerprint return content, fingerprint
except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e: except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e:
raise SourceError('invalid signed repository: ' + str(e)) from e raise SourceError('invalid signed repository: ' + str(e)) from e
@@ -238,6 +241,44 @@ def _write(path, value):
os.unlink(tmp) os.unlink(tmp)
def _state_path():
return frame_host.data_dir('apk-repo-state.json')
def _states():
try:
states = json.loads(_state_path().read_text())
if not isinstance(states, dict):
raise ValueError('invalid state structure')
return states
except FileNotFoundError:
return {}
except (OSError, ValueError) as e:
raise SourceError('cannot read repository state: ' + str(e)) from e
def _state(source):
"""Newest accepted index timestamp and whether a v2 index was ever accepted (rollback protection)."""
with _LOCK:
state = _states().get(source['id'])
return state if isinstance(state, dict) and state.get('url') == source['url'] else {}
def _check_timestamp(source, timestamp):
last = _state(source).get('timestamp')
if last is not None and (type(timestamp) is not int or timestamp < last):
raise SourceError('repository index is older than the one already accepted (possible rollback); refused')
def _accept(source, timestamp, v2):
with _LOCK:
states = _states()
state = _state(source)
states[source['id']] = {'url': source['url'], 'v2': bool(v2 or state.get('v2')),
'timestamp': timestamp if type(timestamp) is int else state.get('timestamp')}
_write(_state_path(), states)
def user_repos(): def user_repos():
with _LOCK: with _LOCK:
return _read()['repos'] return _read()['repos']
@@ -381,6 +422,7 @@ def _v1(content, path):
'size': v.get('size')}, 'added': v.get('added')} 'size': v.get('size')}, 'added': v.get('added')}
packages[pkg] = {'metadata': meta, 'versions': versions} packages[pkg] = {'metadata': meta, 'versions': versions}
path.write_text(json.dumps({'packages': packages})) path.write_text(json.dumps({'packages': packages}))
return (index.get('repo') or {}).get('timestamp')
def _source_lock(source_id): def _source_lock(source_id):
@@ -406,22 +448,31 @@ def _load(source, force=False):
try: try:
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp: with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json' jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try: try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024) _fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if e.code not in (404, 410): if e.code not in (404, 410):
raise raise
if _state(source).get('v2'):
raise SourceError('repository no longer serves its signed v2 index; '
'refusing to fall back to the older v1 index') from e
v2 = False
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024) _fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint')) content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
_v1(content, raw) timestamp = _v1(content, raw)
_check_timestamp(source, timestamp)
else: else:
content, pin = _jar(jar, 'entry.json', source.get('fingerprint')) content, pin = _jar(jar, 'entry.json', source.get('fingerprint'), strong=True)
entry = json.loads(content)['index'] signed = json.loads(content)
timestamp, entry = signed.get('timestamp'), signed['index']
_check_timestamp(source, timestamp)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024) _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']): 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') raise SourceError('index SHA-256 or size mismatch')
apps = _reduce(raw, source) apps = _reduce(raw, source)
_write(cache, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps}) _write(cache, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps})
_accept(source, timestamp, v2)
return apps, pin return apps, pin
except SourceError: except SourceError:
raise raise
@@ -459,6 +510,9 @@ def remove_repo(source_id):
raise SourceError('unknown user repository') raise SourceError('unknown user repository')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id] settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
_write(_storage(), settings) _write(_storage(), settings)
states = _states()
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
_write(_state_path(), states)
def set_enabled(source_id, enabled): def set_enabled(source_id, enabled):