diff --git a/docs/apk-repos.md b/docs/apk-repos.md index 6e87879..ae3b535 100644 --- a/docs/apk-repos.md +++ b/docs/apk-repos.md @@ -69,6 +69,13 @@ 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. +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 Only publish free APKs you own or have the developer's permission to distribute. diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py index 04069bb..afcd140 100644 --- a/tests/test_fdroid_sources.py +++ b/tests/test_fdroid_sources.py @@ -15,6 +15,45 @@ from apk_sources import SourceError, fdroid FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid' PIN = (FIXTURES / 'fingerprint.txt').read_text().strip() 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): @@ -35,12 +74,13 @@ class Repositories(unittest.TestCase): self.addCleanup(mock.stop) self.v1 = False self.corrupt = None + self.files = {} 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() + payload = self.files.get(name) or (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes() if name == self.corrupt: payload += b'tampered' Path(path).write_bytes(payload) @@ -134,7 +174,7 @@ class Repositories(unittest.TestCase): fdroid._child(URL, name) 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.assertIn('index', json.loads(content)) @@ -255,6 +295,43 @@ class Repositories(unittest.TestCase): release.set() 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): source = self.add() source['fingerprint'] = '0' * 64 diff --git a/ui/apk_sources/fdroid.py b/ui/apk_sources/fdroid.py index f182a1e..9244a8a 100644 --- a/ui/apk_sources/fdroid.py +++ b/ui/apk_sources/fdroid.py @@ -102,7 +102,7 @@ def _children(item): return _der_parts(item[1]) -def _cms(data, content): +def _cms(data, content, strong=False): outer = _der_parts(data) if len(outer) != 1: raise ValueError('invalid CMS wrapper') @@ -126,6 +126,8 @@ def _cms(data, content): raise ValueError('missing or ambiguous signer certificate') cert = matching[0] 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 if signer[at][0] == 0xa0: attrs = {} @@ -174,16 +176,17 @@ def _sections(data): 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')): - 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(): raise ValueError('JAR digest mismatch') return 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: with zipfile.ZipFile(path) as z: names = z.namelist() @@ -195,16 +198,16 @@ def _jar(path, member, pin): 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) + fingerprint = _cms(z.read(blocks[0]), sf, strong) 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) + _digest_check(_sections(sf)[0], '-digest-manifest', manifest, strong) 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) + _digest_check(entries[0], '-digest', content, strong) return content, fingerprint except (ValueError, KeyError, IndexError, StopIteration, RuntimeError, NotImplementedError, zipfile.BadZipFile) as e: raise SourceError('invalid signed repository: ' + str(e)) from e @@ -238,6 +241,44 @@ def _write(path, value): 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(): with _LOCK: return _read()['repos'] @@ -381,6 +422,7 @@ def _v1(content, path): 'size': v.get('size')}, 'added': v.get('added')} packages[pkg] = {'metadata': meta, 'versions': versions} path.write_text(json.dumps({'packages': packages})) + return (index.get('repo') or {}).get('timestamp') def _source_lock(source_id): @@ -406,22 +448,31 @@ def _load(source, force=False): try: with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp: jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json' + v2 = True try: _fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024) except urllib.error.HTTPError as e: if e.code not in (404, 410): 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) content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint')) - _v1(content, raw) + timestamp = _v1(content, raw) + _check_timestamp(source, timestamp) else: - content, pin = _jar(jar, 'entry.json', source.get('fingerprint')) - entry = json.loads(content)['index'] + content, pin = _jar(jar, 'entry.json', source.get('fingerprint'), strong=True) + 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) 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, {'version': CACHE_VERSION, 'url': source['url'], 'fingerprint': pin, 'apps': apps}) + _accept(source, timestamp, v2) return apps, pin except SourceError: raise @@ -459,6 +510,9 @@ def remove_repo(source_id): raise SourceError('unknown user repository') settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id] _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):