mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
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:
1 parent
328b7ed211
commit
c68afa6c5d
3 files changed
+150
-12
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user