Store: add repositories in a background job and show the TOFU fingerprint

Adding a repository downloads and verifies its whole index, so it now runs as a
job (runJob in the UI) and reports 'Trusted on first use: <fingerprint>' when
no pin was given. fdroidrepos:// links pass the server check, as documented.
Jobs report SourceError messages without a 'SourceError:' prefix.

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:13:04 +10:00
1 parent ddcf3b2ad2
commit a9d78679ec
5 files changed
+62 -13

No files matched your search

+4 -2
View File
@@ -53,8 +53,10 @@ Adding fetches and validates the complete index **before saving** the source.
Without a fingerprint, Frame Control verifies the JAR signature and remembers Without a fingerprint, Frame Control verifies the JAR signature and remembers
its signer: trust on first use (TOFU). This establishes continuity with the its signer: trust on first use (TOFU). This establishes continuity with the
first server response, not independent publisher identity. Obtain the published first server response, not independent publisher identity. Obtain the published
fingerprint through a trusted channel when possible. Re-adding an existing URL fingerprint through a trusted channel when possible; the store's Add a source
preserves its pin; changing it requires deliberately removing and re-adding it. form shows the pinned one ("Trusted on first use: …") so you can compare it.
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`: 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)`, `add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
+25 -2
View File
@@ -243,9 +243,32 @@ class EndpointTests(SettingsTest):
self.assertEqual(code, 400) self.assertEqual(code, 400)
self.assertIn('not available', reply['error']) self.assertIn('not available', reply['error'])
mod = fake('fdroid') mod = fake('fdroid')
mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(), Mock(), Mock() added = {'id': 'fdroid-user-1', 'name': 'repo.example', 'fingerprint': 'ab' * 32, 'trust_on_first_use': True}
mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(return_value=added), Mock(), Mock()
with patch.object(search, 'modules', return_value=([mod], [])): with patch.object(search, 'modules', return_value=([mod], [])):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})[0], 200) code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
self.assertEqual(code, 200)
job = self.wait(reply['job'])
self.assertEqual(job['message'], 'Added repo.example. Trusted on first use: ' + 'AB' * 32)
self.assertEqual(job['result']['source']['fingerprint'], 'ab' * 32)
mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None) mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None)
link = 'fdroidrepos://repo.example/repo?fingerprint=' + 'ab' * 32
mod.add_repo.return_value = dict(added, trust_on_first_use=False)
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['message'], 'Added repo.example')
mod.add_repo.assert_called_with(url=link, fingerprint=None, name=None)
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200) self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid') mod.remove_repo.assert_called_once_with(source_id='fdroid')
def wait(self, job_id):
for _ in range(200):
job = self.request('GET', '/api/job?id=' + job_id)[1]
if job['done']:
return job
time.sleep(.01)
self.fail('job did not finish')
+9 -3
View File
@@ -98,10 +98,16 @@ def set_enabled(source_id, enabled):
return {'message': source['name'] + (' enabled' if enabled else ' disabled')} return {'message': source['name'] + (' enabled' if enabled else ' disabled')}
def repo_module():
module = next((m for m in modules()[0] if m.KIND == 'fdroid'), None)
if module is None or not hasattr(module, 'add_repo'):
raise SourceError('User repositories are not available in this build')
return module
def manage_repo(action, **kwargs): def manage_repo(action, **kwargs):
mods, _ = modules() module = repo_module()
module = next((m for m in mods if m.KIND == 'fdroid'), None) if not hasattr(module, action):
if module is None or not hasattr(module, action):
raise SourceError('User repositories are not available in this build') raise SourceError('User repositories are not available in this build')
return getattr(module, action)(**kwargs) return getattr(module, action)(**kwargs)
+8 -1
View File
@@ -2080,7 +2080,14 @@ $("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();}; $("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});}; $("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});}; $("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);changeSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});}; async function addSource(body) {
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
let error="";
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
}
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close()); document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;}); $('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();}); document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
+16 -5
View File
@@ -167,6 +167,8 @@ def start_job(label, work, progress=False):
fields = {"message": result.get("message") or f"{label}: done", "result": result} fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e: except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)} fields = {"error": unreachable(str(e)) or str(e)}
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e: except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"} fields = {"error": f"{type(e).__name__}: {e}"}
finally: finally:
@@ -1278,11 +1280,20 @@ def source_manage(body):
return apk_search.set_enabled(source_text(body, 'source'), body['enabled']) return apk_search.set_enabled(source_text(body, 'source'), body['enabled'])
if action == 'add': if action == 'add':
url = source_text(body, 'url') url = source_text(body, 'url')
if urlparse(url).scheme != 'https' or not urlparse(url).hostname or urlparse(url).username: fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
raise Failure('Use an HTTPS repository URL without credentials', 400) parts = urlparse(url)
apk_search.manage_repo('add_repo', url=url, fingerprint=source_text(body, 'fingerprint', True), if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
name=source_text(body, 'name', True)) raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
return {'message': 'Repository added'} apk_search.repo_module() # fail now if this build can't manage repositories
def add(): # downloads and verifies the whole index: a job, not a request
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
message = 'Added ' + source['name']
if source.get('trust_on_first_use'):
message += '. Trusted on first use: ' + source['fingerprint'].upper()
return {'message': message, 'source': {k: source.get(k) for k in
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
return start_job('Add repository', add)
if action == 'remove': if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source')) apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'} return {'message': 'Repository removed'}