""" Manages the interface cache. @var iface_cache: A singleton cache object. You should normally use this rather than creating new cache objects. """ # Copyright (C) 2006, Thomas Leonard # See the README file for details, or visit http://0install.net. # Note: # # We need to know the modification time of each interface, because we refuse # to update to an older version (this prevents an attack where the attacker # sends back an old version which is correctly signed but has a known bug). # # The way we store this is a bit complicated due to backward compatibility: # # - GPG-signed interfaces have their signatures removed and a last-modified # attribute is stored containing the date from the signature. # # - XML-signed interfaces are stored unmodified with their signatures. The # date is extracted from the signature when needed. # # - Older versions used to add the last-modified attribute even to files # with XML signatures - these files therefore have invalid signatures and # we extract from the attribute for these. # # Eventually, support for the first and third cases will be removed. import os, sys, time from logging import debug, info, warn from cStringIO import StringIO from zeroinstall.injector import reader, basedir from zeroinstall.injector.namespaces import * from zeroinstall.injector.model import * from zeroinstall import zerostore def _pretty_time(t): assert isinstance(t, (int, long)) return time.strftime('%Y-%m-%d %H:%M:%S UTC', time.localtime(t)) class PendingFeed(object): """A feed that has been downloaded but not yet added to the interface cache. Feeds remain in this state until the user confirms that they trust at least one of the signatures. @ivar url: URL for the feed @type url: str @ivar signed_data: the untrusted data @type signed_data: stream @ivar sigs: signatures extracted from signed_data @type sigs: [L{gpg.Signature}] @ivar new_xml: the payload of the signed_data, or the whole thing if XML @type new_xml: str @since: 0.25""" __slots__ = ['url', 'signed_data', 'sigs', 'new_xml', 'downloads', 'download_callback'] def __init__(self, url, signed_data): """Downloaded data is a GPG-signed message. @param url: the URL of the downloaded feed @type url: str @param signed_data: the downloaded data (not yet trusted) @type signed_data: stream @raise SafeException: if the data is not signed, and logs the actual data""" self.url = url self.signed_data = signed_data self.downloads = [] self.recheck() def begin_key_downloads(self, handler, callback): """Start downloading any required GPG keys not already on our keyring. When all downloads are done (successful or otherwise), add any new keys to the keyring, L{recheck}, and invoke the callback. If we are already downloading, return and do nothing else. Otherwise, if nothing needs to be downloaded, the callback is invoked immediately. @param handler: handler to manage the download @type handler: L{handler.Handler} @param callback: callback to invoke when done @type callback: function() """ if self.downloads: return assert callback self.download_callback = callback for x in self.sigs: key_id = x.need_key() if key_id: import urlparse key_url = urlparse.urljoin(self.url, '%s.gpg' % key_id) info("Fetching key from %s", key_url) dl = handler.get_download(key_url) self.downloads.append(dl) dl.on_success.append(lambda stream: self._downloaded_key(dl, stream)) if not self.downloads: self.download_callback() def _downloaded_key(self, dl, stream): import shutil, tempfile from zeroinstall.injector import gpg self.downloads.remove(dl) info("Importing key for feed '%s'", self.url) # Python2.4: can't call fileno() on stream, so save to tmp file instead tmpfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-') try: shutil.copyfileobj(stream, tmpfile) tmpfile.flush() try: tmpfile.seek(0) gpg.import_key(tmpfile) except Exception, ex: warn("Failed to import key for '%s': %s", self.url, str(ex)) finally: tmpfile.close() if not self.downloads: # All complete self.recheck() self.download_callback() def recheck(self): """Set new_xml and sigs by reading signed_data. You need to call this when previously-missing keys are added to the GPG keyring.""" import gpg try: self.signed_data.seek(0) stream, sigs = gpg.check_stream(self.signed_data) assert sigs data = stream.read() if stream is not self.signed_data: stream.close() self.new_xml = data self.sigs = sigs except: self.signed_data.seek(0) info("Failed to check GPG signature. Data received was:\n" + `self.signed_data.read()`) raise class IfaceCache(object): """ The interface cache stores downloaded and verified interfaces in ~/.cache/0install.net/interfaces (by default). There are methods to query the cache, add to it, check signatures, etc. When updating the cache, the normal sequence is as follows: 1. When the data arrives, L{add_pending} is called. 2. Later, L{policy.Policy.process_pending} notices the pending feed and starts processing it. 3. It checks the signatures using L{PendingFeed.sigs}. 4. If any required GPG keys are missing, L{download_key} is used to fetch them first. 5. If none of the keys are trusted, L{handler.Handler.confirm_trust_keys} is called. 6. L{update_interface_if_trusted} is called to update the cache. Whenever something needs to be done before the feed can move from the pending state, the process is resumed after the required activity by calling L{policy.Policy.process_pending}. @ivar watchers: objects requiring notification of cache changes. @ivar pending: downloaded feeds which are not yet trusted @type pending: str -> PendingFeed @see: L{iface_cache} - the singleton IfaceCache instance. """ __slots__ = ['watchers', '_interfaces', 'stores', 'pending'] def __init__(self): self.watchers = [] self._interfaces = {} self.pending = {} self.stores = zerostore.Stores() def add_watcher(self, w): """Call C{w.interface_changed(iface)} each time L{update_interface_from_network} changes an interface in the cache.""" assert w not in self.watchers self.watchers.append(w) def add_pending(self, pending): """Add a PendingFeed to the pending dict. @param pending: the untrusted download to add @type pending: PendingFeed @since: 0.25""" assert isinstance(pending, PendingFeed) self.pending[pending.url] = pending def update_interface_if_trusted(self, interface, sigs, xml): """Update a cached interface (using L{update_interface_from_network}) if we trust the signatures, and remove it from L{pending}. If we don't trust any of the signatures, do nothing. @param interface: the interface being updated @type interface: L{model.Interface} @param sigs: signatures from L{gpg.check_stream} @type sigs: [L{gpg.Signature}] @param xml: the downloaded replacement interface document @type xml: str @return: True if the interface was updated @rtype: bool @precondition: call L{add_pending} """ import trust updated = self._oldest_trusted(sigs, trust.domain_from_url(interface.uri)) if updated is None: return False # None are trusted if interface.uri in self.pending: del self.pending[interface.uri] else: raise Exception("update_interface_if_trusted, but '%s' not pending!" % interface.uri) self.update_interface_from_network(interface, xml, updated) return True def download_key(self, interface, key_id): """Download a GPG key. The location of the key is calculated from the uri of the interface. @param interface: the interface which needs the key @param key_id: the GPG long id of the key @todo: This method blocks. It should start a download and return. @deprecated: see PendingFeed """ assert interface assert key_id import urlparse, urllib2, shutil, tempfile key_url = urlparse.urljoin(interface.uri, '%s.gpg' % key_id) info("Fetching key from %s", key_url) try: stream = urllib2.urlopen(key_url) # Python2.4: can't call fileno() on stream, so save to tmp file instead tmpfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-') shutil.copyfileobj(stream, tmpfile) tmpfile.flush() stream.close() except Exception, ex: raise SafeException("Failed to download key from '%s': %s" % (key_url, str(ex))) import gpg tmpfile.seek(0) gpg.import_key(tmpfile) tmpfile.close() def update_interface_from_network(self, interface, new_xml, modified_time): """Update a cached interface. Called by L{update_interface_if_trusted} if we trust this data. After a successful update, L{writer} is used to update the interface's last_checked time and then all the L{watchers} are notified. @param interface: the interface being updated @type interface: L{model.Interface} @param new_xml: the downloaded replacement interface document @type new_xml: str @param modified_time: the timestamp of the oldest trusted signature (used as an approximation to the interface's modification time) @type modified_time: long @raises SafeException: if modified_time is older than the currently cached time """ debug("Updating '%s' from network; modified at %s" % (interface.name or interface.uri, _pretty_time(modified_time))) if '\n