From fa8db97feecfbac7d18ed5745141aeeead1afc58 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Sat, 12 Mar 2011 18:14:33 +0000 Subject: [PATCH] Added TrustMgr object This moves the logic for fetching keys from the key server out of Handler. --- tests/basetest.py | 5 ++- tests/testdownload.py | 19 ++++----- zeroinstall/cmd/import.py | 2 +- zeroinstall/helpers.py | 7 ++-- zeroinstall/injector/config.py | 27 ++++++++++++- zeroinstall/injector/fetch.py | 21 +++------- zeroinstall/injector/handler.py | 90 +++-------------------------------------- zeroinstall/injector/trust.py | 88 ++++++++++++++++++++++++++++++++++++++-- 8 files changed, 141 insertions(+), 118 deletions(-) diff --git a/tests/basetest.py b/tests/basetest.py index 2bc8397..3a504e6 100755 --- a/tests/basetest.py +++ b/tests/basetest.py @@ -12,7 +12,7 @@ os.environ['LANGUAGE'] = 'C' sys.path.insert(0, '..') from zeroinstall.injector import qdom -from zeroinstall.injector import iface_cache, download, distro, model, handler, policy, reader +from zeroinstall.injector import iface_cache, download, distro, model, handler, policy, reader, trust from zeroinstall.zerostore import NotStored, Store, Stores; Store._add_with_helper = lambda *unused: False from zeroinstall import support from zeroinstall.support import basedir, tasks @@ -148,12 +148,15 @@ class TestConfig: freshness = 0 help_with_testing = False network_use = model.network_full + key_info_server = None def __init__(self): self.iface_cache = iface_cache.IfaceCache() self.handler = DummyHandler() self.stores = Stores() self.fetcher = TestFetcher(self) + self.trust_db = trust.trust_db + self.trust_mgr = trust.TrustMgr(self) class BaseTest(unittest.TestCase): def setUp(self): diff --git a/tests/testdownload.py b/tests/testdownload.py index 046f7c9..9c64623 100755 --- a/tests/testdownload.py +++ b/tests/testdownload.py @@ -19,8 +19,6 @@ from zeroinstall.injector import fetch import data import my_dbus -fetch.DEFAULT_KEY_LOOKUP_SERVER = 'http://localhost:3333/key-info' - import server ran_gui = False @@ -70,6 +68,8 @@ class TestDownload(BaseTest): BaseTest.setUp(self) self.config.handler.allow_downloads = True + self.config.key_info_server = 'http://localhost:3333/key-info' + self.config.fetcher = fetch.Fetcher(self.config) stream = tempfile.TemporaryFile() @@ -167,7 +167,7 @@ class TestDownload(BaseTest): assert False except NotStored: pass - cli.main(['--download-only', 'selections.xml']) + cli.main(['--download-only', 'selections.xml'], config = self.config) path = self.config.stores.lookup_any(sels.selections['http://example.com:8000/Hello.xml'].digests) assert os.path.exists(os.path.join(path, 'HelloWorld', 'main')) @@ -179,7 +179,7 @@ class TestDownload(BaseTest): with output_suppressed(): self.child = server.handle_requests('Hello.xml', '6FCF121BE2390E0B.gpg', '/key-info/key/DE937DD411906ACF7C263B396FCF121BE2390E0B', 'HelloWorld.tgz') sys.stdin = Reply("Y\n") - sels = helpers.ensure_cached('http://example.com:8000/Hello.xml') + sels = helpers.ensure_cached('http://example.com:8000/Hello.xml', config = self.config) path = self.config.stores.lookup_any(sels.selections['http://example.com:8000/Hello.xml'].digests) assert os.path.exists(os.path.join(path, 'HelloWorld', 'main')) assert sels.download_missing(self.config) is None @@ -324,7 +324,7 @@ class TestDownload(BaseTest): trust.trust_db.trust_key('DE937DD411906ACF7C263B396FCF121BE2390E0B', 'example.com:8000') self.child = server.handle_requests(server.Give404('/Hello.xml'), 'latest.xml', '/0mirror/keys/6FCF121BE2390E0B.gpg') policy = Policy('http://example.com:8000/Hello.xml', config = self.config) - policy.fetcher.feed_mirror = 'http://example.com:8000/0mirror' + self.config.feed_mirror = 'http://example.com:8000/0mirror' refreshed = policy.solve_with_downloads() policy.handler.wait_for_blocker(refreshed) @@ -344,7 +344,7 @@ class TestDownload(BaseTest): trust.trust_db.trust_key('DE937DD411906ACF7C263B396FCF121BE2390E0B', 'example.com:8000') self.child = server.handle_requests(server.Give404('/Hello.xml'), 'latest.xml', '/0mirror/keys/6FCF121BE2390E0B.gpg', 'Hello.xml') policy = Policy('http://example.com:8000/Hello.xml', config = self.config) - policy.fetcher.feed_mirror = 'http://example.com:8000/0mirror' + self.config.feed_mirror = 'http://example.com:8000/0mirror' # Update from mirror (should ignore out-of-date timestamp) refreshed = policy.fetcher.download_and_import_feed(iface.uri, self.config.iface_cache) @@ -398,8 +398,9 @@ class TestDownload(BaseTest): raise SystemExit(code) # But, child download processes are OK old_exit(code) - key_info = fetch.DEFAULT_KEY_LOOKUP_SERVER - fetch.DEFAULT_KEY_LOOKUP_SERVER = None + from zeroinstall.injector import config + key_info = config.DEFAULT_KEY_LOOKUP_SERVER + config.DEFAULT_KEY_LOOKUP_SERVER = None try: try: os._exit = my_exit @@ -409,7 +410,7 @@ class TestDownload(BaseTest): self.assertEquals(1, ex.code) finally: os._exit = old_exit - fetch.DEFAULT_KEY_LOOKUP_SERVER = key_info + config.DEFAULT_KEY_LOOKUP_SERVER = key_info finally: sys.stdout = old_out assert ran_gui diff --git a/zeroinstall/cmd/import.py b/zeroinstall/cmd/import.py index f8af19b..61c73a4 100644 --- a/zeroinstall/cmd/import.py +++ b/zeroinstall/cmd/import.py @@ -46,7 +46,7 @@ def handle(config, options, args): yield keys_downloaded.finished tasks.check(keys_downloaded.finished) if not config.iface_cache.update_feed_if_trusted(uri, pending.sigs, pending.new_xml): - blocker = h.confirm_keys(pending, config.fetcher.fetch_key_info) + blocker = config.trust_mgr.confirm_keys(pending) if blocker: yield blocker tasks.check(blocker) diff --git a/zeroinstall/helpers.py b/zeroinstall/helpers.py index 4807ad0..10c3bd0 100644 --- a/zeroinstall/helpers.py +++ b/zeroinstall/helpers.py @@ -99,7 +99,7 @@ def get_selections_gui(iface_uri, gui_args, test_callback = None): return sels -def ensure_cached(uri, command = 'run'): +def ensure_cached(uri, command = 'run', config = None): """Ensure that an implementation of uri is cached. If not, it downloads one. It uses the GUI if a display is available, or the console otherwise. @@ -109,9 +109,10 @@ def ensure_cached(uri, command = 'run'): @rtype: L{zeroinstall.injector.selections.Selections} """ from zeroinstall.injector import policy, selections - from zeroinstall.injector.config import load_config - config = load_config() + if config is None: + from zeroinstall.injector.config import load_config + config = load_config() p = policy.Policy(uri, command = command, config = config) p.freshness = 0 # Don't check for updates diff --git a/zeroinstall/injector/config.py b/zeroinstall/injector/config.py index 823f379..f3b979a 100644 --- a/zeroinstall/injector/config.py +++ b/zeroinstall/injector/config.py @@ -15,19 +15,30 @@ from zeroinstall.injector.model import network_levels, network_full from zeroinstall.injector.namespaces import config_site, config_prog from zeroinstall.support import basedir +DEFAULT_FEED_MIRROR = "http://roscidus.com/0mirror" +DEFAULT_KEY_LOOKUP_SERVER = 'https://keylookup.appspot.com' + class Config(object): """ @ivar handler: handler for main-loop integration @type handler: L{handler.Handler} + @ivar key_info_server: the base URL of a key information server + @type key_info_server: str + @ivar feed_mirror: the base URL of a mirror site for keys and feeds + @type feed_mirror: str | None """ - __slots__ = ['help_with_testing', 'freshness', 'network_use', '_fetcher', '_stores', '_iface_cache', '_handler'] + __slots__ = ['help_with_testing', 'freshness', 'network_use', 'feed_mirror', 'key_info_server', + '_fetcher', '_stores', '_iface_cache', '_handler', '_trust_mgr'] + def __init__(self, handler = None): self.help_with_testing = False self.freshness = 60 * 60 * 24 * 30 self.network_use = network_full self._handler = handler - self._fetcher = self._stores = self._iface_cache = None + self._fetcher = self._stores = self._iface_cache = self._trust_mgr = None + self.feed_mirror = DEFAULT_FEED_MIRROR + self.key_info_server = DEFAULT_KEY_LOOKUP_SERVER @property def stores(self): @@ -51,6 +62,18 @@ class Config(object): return self._fetcher @property + def trust_mgr(self): + if not self._trust_mgr: + from zeroinstall.injector import trust + self._trust_mgr = trust.TrustMgr(self) + return self._trust_mgr + + @property + def trust_db(self): + from zeroinstall.injector import trust + self._trust_db = trust.trust_db + + @property def handler(self): if not self._handler: from zeroinstall.injector import handler diff --git a/zeroinstall/injector/fetch.py b/zeroinstall/injector/fetch.py index edd7a43..bb6edcc 100644 --- a/zeroinstall/injector/fetch.py +++ b/zeroinstall/injector/fetch.py @@ -16,9 +16,6 @@ from zeroinstall.injector.iface_cache import PendingFeed, ReplayAttack from zeroinstall.injector.handler import NoTrustedKeys from zeroinstall.injector import download -DEFAULT_FEED_MIRROR = "http://roscidus.com/0mirror" -DEFAULT_KEY_LOOKUP_SERVER = 'https://keylookup.appspot.com' - def _escape_slashes(path): return path.replace('/', '%23') @@ -87,18 +84,12 @@ class Fetcher(object): @type config: L{config.Config} @ivar key_info: caches information about GPG keys @type key_info: {str: L{KeyInfoFetcher}} - @ivar key_info_server: the base URL of a key information server - @type key_info_server: str - @ivar feed_mirror: the base URL of a mirror site for keys and feeds - @type feed_mirror: str | None """ - __slots__ = ['config', 'feed_mirror', 'key_info_server', 'key_info'] + __slots__ = ['config', 'key_info'] def __init__(self, config): assert config.handler, "API change!" self.config = config - self.feed_mirror = DEFAULT_FEED_MIRROR - self.key_info_server = DEFAULT_KEY_LOOKUP_SERVER self.key_info = {} @property @@ -150,12 +141,12 @@ class Fetcher(object): def get_feed_mirror(self, url): """Return the URL of a mirror for this feed.""" - if self.feed_mirror is None: + if self.config.feed_mirror is None: return None import urlparse if urlparse.urlparse(url).hostname == 'localhost': return None - return '%s/%s/latest.xml' % (self.feed_mirror, _get_feed_dir(url)) + return '%s/%s/latest.xml' % (self.config.feed_mirror, _get_feed_dir(url)) @tasks.async def get_packagekit_feed(self, feed_url): @@ -286,7 +277,7 @@ class Fetcher(object): if use_mirror: # If we got the feed from a mirror, get the key from there too - key_mirror = self.feed_mirror + '/keys/' + key_mirror = self.config.feed_mirror + '/keys/' else: key_mirror = None @@ -295,7 +286,7 @@ class Fetcher(object): tasks.check(keys_downloaded.finished) if not self.config.iface_cache.update_feed_if_trusted(pending.url, pending.sigs, pending.new_xml): - blocker = self.handler.confirm_keys(pending, self.fetch_key_info) + blocker = self.config.trust_mgr.confirm_keys(pending) if blocker: yield blocker tasks.check(blocker) @@ -311,7 +302,7 @@ class Fetcher(object): return self.key_info[fingerprint] except KeyError: self.key_info[fingerprint] = key_info = KeyInfoFetcher(self.handler, - self.key_info_server, fingerprint) + self.config.key_info_server, fingerprint) return key_info def download_impl(self, impl, retrieval_method, stores, force = False): diff --git a/zeroinstall/injector/handler.py b/zeroinstall/injector/handler.py index 3170496..e44cae0 100644 --- a/zeroinstall/injector/handler.py +++ b/zeroinstall/injector/handler.py @@ -18,15 +18,13 @@ from zeroinstall import NeedDownload, SafeException from zeroinstall.support import tasks from zeroinstall.injector import download -KEY_INFO_TIMEOUT = 10 # Maximum time to wait for response from key-info-server - class NoTrustedKeys(SafeException): """Thrown by L{Handler.confirm_import_feed} on failure.""" pass class Handler(object): """ - This implementation uses the GLib mainloop. Note that QT4 can use the GLib mainloop too. + A Handler is used to interact with the user (e.g. to confirm keys, display download progress, etc). @ivar monitored_downloads: dict of downloads in progress @type monitored_downloads: {URL: L{download.Download}} @@ -38,14 +36,13 @@ class Handler(object): @type dry_run: bool """ - __slots__ = ['monitored_downloads', 'dry_run', 'total_bytes_downloaded', 'n_completed_downloads', '_current_confirm'] + __slots__ = ['monitored_downloads', 'dry_run', 'total_bytes_downloaded', 'n_completed_downloads'] def __init__(self, mainloop = None, dry_run = False): self.monitored_downloads = {} self.dry_run = dry_run self.n_completed_downloads = 0 self.total_bytes_downloaded = 0 - self._current_confirm = None def monitor_download(self, dl): """Called when a new L{download} is started. @@ -108,91 +105,16 @@ class Handler(object): self.monitor_download(dl) return dl - def confirm_keys(self, pending, fetch_key_info): - """We don't trust any of the signatures yet. Ask the user. - When done update the L{trust} database, and then call L{trust.TrustDB.notify}. - This method starts downloading information about the signing keys and calls L{confirm_import_feed}. - @since: 0.42 - @arg pending: an object holding details of the updated feed - @type pending: L{PendingFeed} - @arg fetch_key_info: a function which can be used to fetch information about a key fingerprint - @type fetch_key_info: str -> L{Blocker} - @return: A blocker that triggers when the user has chosen, or None if already done. - @rtype: None | L{Blocker}""" - - assert pending.sigs - - from zeroinstall.injector import gpg - valid_sigs = [s for s in pending.sigs if isinstance(s, gpg.ValidSig)] - if not valid_sigs: - def format_sig(sig): - msg = str(sig) - if sig.messages: - msg += "\nMessages from GPG:\n" + sig.messages - return msg - raise SafeException(_('No valid signatures found on "%(url)s". Signatures:%(signatures)s') % - {'url': pending.url, 'signatures': ''.join(['\n- ' + format_sig(s) for s in pending.sigs])}) - - # Start downloading information about the keys... - kfs = {} - for sig in valid_sigs: - kfs[sig] = fetch_key_info(sig.fingerprint) - - return self._queue_confirm_import_feed(pending, kfs) - - @tasks.async - def _queue_confirm_import_feed(self, pending, valid_sigs): - # Wait up to KEY_INFO_TIMEOUT seconds for key information to arrive. Avoids having the dialog - # box update while the user is looking at it, and may allow it to be skipped completely in some - # cases. - timeout = tasks.TimeoutBlocker(KEY_INFO_TIMEOUT, "key info timeout") - while True: - key_info_blockers = [sig_info.blocker for sig_info in valid_sigs.values() if sig_info.blocker is not None] - if not key_info_blockers: - break - info("Waiting for response from key-info server: %s", key_info_blockers) - yield [timeout] + key_info_blockers - if timeout.happened: - info("Timeout waiting for key info response") - break - - # If we're already confirming something else, wait for that to finish... - while self._current_confirm is not None: - info("Waiting for previous key confirmations to finish") - yield self._current_confirm - - # Check whether we still need to confirm. The user may have - # already approved one of the keys while dealing with another - # feed. - from zeroinstall.injector import trust - domain = trust.domain_from_url(pending.url) - for sig in valid_sigs: - is_trusted = trust.trust_db.is_trusted(sig.fingerprint, domain) - if is_trusted: - return - - # Take the lock and confirm this feed - self._current_confirm = lock = tasks.Blocker('confirm key lock') - try: - done = self.confirm_import_feed(pending, valid_sigs) - if done is not None: - yield done - tasks.check(done) - finally: - self._current_confirm = None - lock.trigger() - @tasks.async def confirm_import_feed(self, pending, valid_sigs): """Sub-classes should override this method to interact with the user about new feeds. - If multiple feeds need confirmation, L{confirm_keys} will only invoke one instance of this + If multiple feeds need confirmation, L{trust.TrustMgr.confirm_keys} will only invoke one instance of this method at a time. @param pending: the new feed to be imported @type pending: L{PendingFeed} @param valid_sigs: maps signatures to a list of fetchers collecting information about the key @type valid_sigs: {L{gpg.ValidSig} : L{fetch.KeyInfoFetcher}} - @since: 0.42 - @see: L{confirm_keys}""" + @since: 0.42""" from zeroinstall.injector import trust assert valid_sigs @@ -240,6 +162,8 @@ class Handler(object): if stdin.happened: print >>sys.stderr, _("Skipping remaining key lookups due to input from user") break + if not shown: + print >>sys.stderr, _("Warning: Nothing known about this key!") if len(valid_sigs) == 1: print >>sys.stderr, _("Do you want to trust this key to sign feeds from '%s'?") % domain @@ -257,8 +181,6 @@ class Handler(object): print >>sys.stderr, _("Trusting %(key_fingerprint)s for %(domain)s") % {'key_fingerprint': key.fingerprint, 'domain': domain} trust.trust_db.trust_key(key.fingerprint, domain) - confirm_import_feed.original = True - @tasks.async def confirm_install(self, msg): """We need to check something with the user before continuing with the install. diff --git a/zeroinstall/injector/trust.py b/zeroinstall/injector/trust.py index 776d6c8..083218e 100644 --- a/zeroinstall/injector/trust.py +++ b/zeroinstall/injector/trust.py @@ -10,12 +10,15 @@ in some cases and not others. # Copyright (C) 2009, Thomas Leonard # See the README file for details, or visit http://0install.net. -from zeroinstall import _ +from zeroinstall import _, SafeException import os +from logging import info -from zeroinstall.support import basedir +from zeroinstall.support import basedir, tasks from .namespaces import config_site, config_prog, XMLNS_TRUST +KEY_INFO_TIMEOUT = 10 # Maximum time to wait for response from key-info-server + class TrustDB(object): """A database of trusted keys. @ivar keys: maps trusted key fingerprints to a set of domains for which where it is trusted @@ -154,7 +157,6 @@ def domain_from_url(url): @since: 0.27 @raise SafeException: the URL can't be parsed""" import urlparse - from zeroinstall import SafeException if os.path.isabs(url): raise SafeException(_("Can't get domain from a local path: '%s'") % url) domain = urlparse.urlparse(url)[1] @@ -163,3 +165,83 @@ def domain_from_url(url): raise SafeException(_("Can't extract domain from URL '%s'") % url) trust_db = TrustDB() + +class TrustMgr(object): + """A TrustMgr handles the process of deciding whether to trust new keys + (contacting the key information server, prompting the user, accepting automatically, etc) + @since: 0.53""" + + __slots__ = ['config', '_current_confirm'] + + def __init__(self, config): + self.config = config + self._current_confirm = None # (a lock to prevent asking the user multiple questions at once) + + @tasks.async + def confirm_keys(self, pending): + """We don't trust any of the signatures yet. Collect information about them and add the keys to the + trusted list, possibly after confirming with the user (via config.handler). + Updates the L{trust} database, and then calls L{trust.TrustDB.notify}. + @since: 0.53 + @arg pending: an object holding details of the updated feed + @type pending: L{PendingFeed} + @return: A blocker that triggers when the user has chosen, or None if already done. + @rtype: None | L{Blocker}""" + + assert pending.sigs + + from zeroinstall.injector import gpg + valid_sigs = [s for s in pending.sigs if isinstance(s, gpg.ValidSig)] + if not valid_sigs: + def format_sig(sig): + msg = str(sig) + if sig.messages: + msg += "\nMessages from GPG:\n" + sig.messages + return msg + raise SafeException(_('No valid signatures found on "%(url)s". Signatures:%(signatures)s') % + {'url': pending.url, 'signatures': ''.join(['\n- ' + format_sig(s) for s in pending.sigs])}) + + # Start downloading information about the keys... + fetcher = self.config.fetcher + kfs = {} + for sig in valid_sigs: + kfs[sig] = fetcher.fetch_key_info(sig.fingerprint) + + # Wait up to KEY_INFO_TIMEOUT seconds for key information to arrive. Avoids having the dialog + # box update while the user is looking at it, and may allow it to be skipped completely in some + # cases. + timeout = tasks.TimeoutBlocker(KEY_INFO_TIMEOUT, "key info timeout") + while True: + key_info_blockers = [sig_info.blocker for sig_info in kfs.values() if sig_info.blocker is not None] + if not key_info_blockers: + break + info("Waiting for response from key-info server: %s", key_info_blockers) + yield [timeout] + key_info_blockers + if timeout.happened: + info("Timeout waiting for key info response") + break + + # If we're already confirming something else, wait for that to finish... + while self._current_confirm is not None: + info("Waiting for previous key confirmations to finish") + yield self._current_confirm + + # Check whether we still need to confirm. The user may have + # already approved one of the keys while dealing with another + # feed. + domain = domain_from_url(pending.url) + for sig in kfs: + is_trusted = trust_db.is_trusted(sig.fingerprint, domain) + if is_trusted: + return + + # Take the lock and confirm this feed + self._current_confirm = lock = tasks.Blocker('confirm key lock') + try: + done = self.config.handler.confirm_import_feed(pending, kfs) + if done is not None: + yield done + tasks.check(done) + finally: + self._current_confirm = None + lock.trigger() -- 2.11.4.GIT