# HG changeset patch # User souliane # Date 1399471343 -7200 # Node ID 15f43b54d69793683797ea94d41a0eb2eb089aec # Parent f6182f6418ead0c6f155954223cc3a09438f05b3 core, memory, bridge: added profile password + password encryption: /!\ This changeset updates the database version to 2 and modify the database content! Description: - new parameter General / Password to store the profile password - profile password is initialized with XMPP password value, it is stored hashed - bridge methods asyncCreateProfile/asyncConnect takes a new argument "password" (default = "") - bridge method asyncConnect returns a boolean (True = connection already established, False = connection initiated) - profile password is checked before initializing the XMPP connection - new private individual parameter to store the personal encryption key of each profile - personal key is randomly generated and encrypted with the profile password - personal key is decrypted after profile authentification and stored in a Sessions instance - personal key is used to encrypt/decrypt other passwords when they need to be retrieved/modified - modifying the profile password re-encrypt the personal key - Memory.setParam now returns a Deferred (the bridge method "setParam" is unchanged) - Memory.asyncGetParamA eventually decrypts the password, Memory.getParamA would fail on a password parameter TODO: - if profile authentication is OK but XMPP authentication is KO, prompt the user for another XMPP password - fix the method "registerNewAccount" (and move it to a plugin) - remove bridge method "connect", sole "asyncConnect" should be used diff -r f6182f6418ea -r 15f43b54d697 frontends/src/bridge/DBus.py --- a/frontends/src/bridge/DBus.py Sat May 10 17:37:32 2014 +0200 +++ b/frontends/src/bridge/DBus.py Wed May 07 16:02:23 2014 +0200 @@ -99,11 +99,11 @@ def addContact(self, entity_jid, profile_key="@DEFAULT@"): return self.db_core_iface.addContact(entity_jid, profile_key) - def asyncConnect(self, profile_key="@DEFAULT@", callback=None, errback=None): - return self.db_core_iface.asyncConnect(profile_key, reply_handler=callback, error_handler=lambda err:errback(err._dbus_error_name[len(const_ERROR_PREFIX)+1:])) + def asyncConnect(self, profile_key="@DEFAULT@", password='', callback=None, errback=None): + return self.db_core_iface.asyncConnect(profile_key, password, reply_handler=callback, error_handler=lambda err:errback(err._dbus_error_name[len(const_ERROR_PREFIX)+1:])) - def asyncCreateProfile(self, profile, callback=None, errback=None): - return self.db_core_iface.asyncCreateProfile(profile, reply_handler=callback, error_handler=lambda err:errback(err._dbus_error_name[len(const_ERROR_PREFIX)+1:])) + def asyncCreateProfile(self, profile, password='', callback=None, errback=None): + return self.db_core_iface.asyncCreateProfile(profile, password, reply_handler=callback, error_handler=lambda err:errback(err._dbus_error_name[len(const_ERROR_PREFIX)+1:])) def asyncDeleteProfile(self, profile, callback=None, errback=None): return self.db_core_iface.asyncDeleteProfile(profile, reply_handler=callback, error_handler=lambda err:errback(err._dbus_error_name[len(const_ERROR_PREFIX)+1:])) @@ -114,8 +114,8 @@ def confirmationAnswer(self, id, accepted, data, profile): return self.db_core_iface.confirmationAnswer(id, accepted, data, profile) - def connect(self, profile_key="@DEFAULT@"): - return self.db_core_iface.connect(profile_key) + def connect(self, profile_key="@DEFAULT@", password=''): + return self.db_core_iface.connect(profile_key, password) def delContact(self, entity_jid, profile_key="@DEFAULT@"): return self.db_core_iface.delContact(entity_jid, profile_key) diff -r f6182f6418ea -r 15f43b54d697 src/bridge/DBus.py --- a/src/bridge/DBus.py Sat May 10 17:37:32 2014 +0200 +++ b/src/bridge/DBus.py Wed May 07 16:02:23 2014 +0200 @@ -194,16 +194,16 @@ return self._callback("addContact", unicode(entity_jid), unicode(profile_key)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, - in_signature='s', out_signature='', + in_signature='ss', out_signature='b', async_callbacks=('callback', 'errback')) - def asyncConnect(self, profile_key="@DEFAULT@", callback=None, errback=None): - return self._callback("asyncConnect", unicode(profile_key), callback=callback, errback=errback) + def asyncConnect(self, profile_key="@DEFAULT@", password='', callback=None, errback=None): + return self._callback("asyncConnect", unicode(profile_key), unicode(password), callback=callback, errback=errback) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, - in_signature='s', out_signature='', + in_signature='ss', out_signature='', async_callbacks=('callback', 'errback')) - def asyncCreateProfile(self, profile, callback=None, errback=None): - return self._callback("asyncCreateProfile", unicode(profile), callback=callback, errback=errback) + def asyncCreateProfile(self, profile, password='', callback=None, errback=None): + return self._callback("asyncCreateProfile", unicode(profile), unicode(password), callback=callback, errback=errback) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='s', out_signature='', @@ -224,10 +224,10 @@ return self._callback("confirmationAnswer", unicode(id), accepted, data, unicode(profile)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, - in_signature='s', out_signature='', + in_signature='ss', out_signature='b', async_callbacks=None) - def connect(self, profile_key="@DEFAULT@"): - return self._callback("connect", unicode(profile_key)) + def connect(self, profile_key="@DEFAULT@", password=''): + return self._callback("connect", unicode(profile_key), unicode(password)) @dbus.service.method(const_INT_PREFIX+const_CORE_SUFFIX, in_signature='ss', out_signature='', diff -r f6182f6418ea -r 15f43b54d697 src/bridge/bridge_constructor/bridge_template.ini --- a/src/bridge/bridge_constructor/bridge_template.ini Sat May 10 17:37:32 2014 +0200 +++ b/src/bridge/bridge_constructor/bridge_template.ini Wed May 07 16:02:23 2014 +0200 @@ -204,10 +204,12 @@ async= type=method category=core -sig_in=s +sig_in=ss sig_out= +param_1_default='' doc=Create a new profile doc_param_0=%(doc_profile)s +doc_param_1=password: password of the profile doc_return=callback is called when profile actually exists in database and memory errback is called with error constant as parameter: - ConflictError: the profile name already exists @@ -243,21 +245,35 @@ [connect] type=method category=core -sig_in=s -sig_out= +sig_in=ss +sig_out=b param_0_default="@DEFAULT@" +param_1_default='' doc=Connect a profile doc_param_0=%(doc_profile_key)s +doc_param_1=password: the SàT profile password +doc_return=a deferred boolean or failure: + - boolean if the profile authentication succeed: + - True if the XMPP connection was already established + - False if the XMPP connection has been initiated (it may still fail) + - failure if the profile authentication failed [asyncConnect] async= type=method category=core -sig_in=s -sig_out= +sig_in=ss +sig_out=b param_0_default="@DEFAULT@" +param_1_default='' doc=Connect a profile doc_param_0=%(doc_profile_key)s +doc_param_1=password: the SàT profile password +doc_return=a deferred boolean or failure: + - boolean if the profile authentication succeed: + - True if the XMPP connection was already established + - False if the XMPP connection has been initiated (it may still fail) + - failure if the profile authentication failed [disconnect] type=method diff -r f6182f6418ea -r 15f43b54d697 src/core/constants.py --- a/src/core/constants.py Sat May 10 17:37:32 2014 +0200 +++ b/src/core/constants.py Wed May 07 16:02:23 2014 +0200 @@ -40,6 +40,10 @@ NO_SECURITY_LIMIT = -1 INDIVIDUAL = "individual" GENERAL = "general" + # Parameters related to encryption + PROFILE_PASS_PATH = ('General', 'Password') + MEMORY_CRYPTO_NAMESPACE = 'crypto' # for the private persistent binary dict + MEMORY_CRYPTO_KEY = 'personal_key' # Parameters for static blog pages STATIC_BLOG_KEY = "Blog page" STATIC_BLOG_PARAM_TITLE = "Title" diff -r f6182f6418ea -r 15f43b54d697 src/core/exceptions.py --- a/src/core/exceptions.py Sat May 10 17:37:32 2014 +0200 +++ b/src/core/exceptions.py Wed May 07 16:02:23 2014 +0200 @@ -88,3 +88,7 @@ class DatabaseError(Exception): pass + + +class PasswordError(Exception): + pass diff -r f6182f6418ea -r 15f43b54d697 src/core/sat_main.py --- a/src/core/sat_main.py Sat May 10 17:37:32 2014 +0200 +++ b/src/core/sat_main.py Wed May 07 16:02:23 2014 +0200 @@ -31,6 +31,7 @@ log = getLogger(__name__) from sat.core.constants import Const as C from sat.memory.memory import Memory +from sat.memory.crypto import PasswordHasher from sat.tools.misc import TriggerManager from sat.stdui import ui_contact_list from glob import glob @@ -198,13 +199,25 @@ self.plugins[import_name].is_handler = False #TODO: test xmppclient presence and register handler parent - def connect(self, profile_key=C.PROF_KEY_NONE): - """Connect to jabber server""" - self.asyncConnect(profile_key) + def connect(self, profile_key=C.PROF_KEY_NONE, password=''): + """Connect to jabber server + + @param password (string): the SàT profile password + @param profile_key: %(doc_profile_key)s + """ + self.asyncConnect(profile_key, password) - def asyncConnect(self, profile_key=C.PROF_KEY_NONE): - """Connect to jabber server with asynchronous reply - @param profile_key: %(doc_profile)s + def asyncConnect(self, profile_key=C.PROF_KEY_NONE, password=''): + """Retrieve the individual parameters, authenticate the profile + and initiate the connection to the associated XMPP server. + + @param password (string): the SàT profile password + @param profile_key: %(doc_profile_key)s + @return: Deferred: + - a deferred boolean if the profile authentication succeed: + - True if the XMPP connection was already established + - False if the XMPP connection has been initiated (it may still fail) + - a Failure if the profile authentication failed """ def backendInitialised(dummy): profile = self.memory.getProfileName(profile_key) @@ -212,84 +225,110 @@ log.error(_('Trying to connect a non-existant profile')) raise exceptions.ProfileUnknownError(profile_key) - if self.isConnected(profile): - log.info(_("already connected !")) - return defer.succeed("None") + def connectXMPPClient(dummy=None): + if self.isConnected(profile): + log.info(_("already connected !")) + return True + self.memory.startProfileSession(profile) + d = self.memory.loadIndividualParams(profile) + d.addCallback(lambda dummy: self._connectXMPPClient(profile)) + return d.addCallback(lambda dummy: False) - def afterMemoryInit(ignore): - """This part must be called when we have loaded individual parameters from memory""" - try: - port = int(self.memory.getParamA("Port", "Connection", profile_key=profile)) - except ValueError: - log.error(_("Can't parse port value, using default value")) - port = 5222 + d = self._authenticateProfile(password, profile) + d.addCallback(connectXMPPClient) + return d + + return self._initialised.addCallback(backendInitialised) - current = self.profiles[profile] = xmpp.SatXMPPClient( - self, profile, - jid.JID(self.memory.getParamA("JabberID", "Connection", profile_key=profile), profile), - self.memory.getParamA("Password", "Connection", profile_key=profile), - self.memory.getParamA("Server", "Connection", profile_key=profile), - port) + @defer.inlineCallbacks + def _connectXMPPClient(self, profile): + """This part is called from asyncConnect when we have loaded individual parameters from memory""" + try: + port = int(self.memory.getParamA("Port", "Connection", profile_key=profile)) + except ValueError: + log.error(_("Can't parse port value, using default value")) + port = 5222 - current.messageProt = xmpp.SatMessageProtocol(self) - current.messageProt.setHandlerParent(current) + password = yield self.memory.asyncGetParamA("Password", "Connection", profile_key=profile) + current = self.profiles[profile] = xmpp.SatXMPPClient(self, profile, + jid.JID(self.memory.getParamA("JabberID", "Connection", profile_key=profile)), + password, self.memory.getParamA("Server", "Connection", profile_key=profile), port) - current.roster = xmpp.SatRosterProtocol(self) - current.roster.setHandlerParent(current) + current.messageProt = xmpp.SatMessageProtocol(self) + current.messageProt.setHandlerParent(current) - current.presence = xmpp.SatPresenceProtocol(self) - current.presence.setHandlerParent(current) + current.roster = xmpp.SatRosterProtocol(self) + current.roster.setHandlerParent(current) - current.fallBack = xmpp.SatFallbackHandler(self) - current.fallBack.setHandlerParent(current) + current.presence = xmpp.SatPresenceProtocol(self) + current.presence.setHandlerParent(current) + + current.fallBack = xmpp.SatFallbackHandler(self) + current.fallBack.setHandlerParent(current) - current.versionHandler = xmpp.SatVersionHandler(C.APP_NAME_FULL, - C.APP_VERSION) - current.versionHandler.setHandlerParent(current) + current.versionHandler = xmpp.SatVersionHandler(C.APP_NAME_FULL, + C.APP_VERSION) + current.versionHandler.setHandlerParent(current) - current.identityHandler = xmpp.SatIdentityHandler() - current.identityHandler.setHandlerParent(current) + current.identityHandler = xmpp.SatIdentityHandler() + current.identityHandler.setHandlerParent(current) + + log.debug(_("setting plugins parents")) - log.debug(_("setting plugins parents")) + plugin_conn_cb = [] + for plugin in self.plugins.iteritems(): + if plugin[1].is_handler: + plugin[1].getHandler(profile).setHandlerParent(current) + connected_cb = getattr(plugin[1], "profileConnected", None) + if connected_cb: + plugin_conn_cb.append((plugin[0], connected_cb)) - plugin_conn_cb = [] - for plugin in self.plugins.iteritems(): - if plugin[1].is_handler: - plugin[1].getHandler(profile).setHandlerParent(current) - connected_cb = getattr(plugin[1], "profileConnected", None) - if connected_cb: - plugin_conn_cb.append((plugin[0], connected_cb)) + current.startService() + + yield current.getConnectionDeferred() + yield current.roster.got_roster # we want to be sure that we got the roster - current.startService() - - d = current.getConnectionDeferred() - d.addCallback(lambda dummy: current.roster.got_roster) # we want to be sure that we got the roster + # Call profileConnected callback for all plugins, and print error message if any of them fails + conn_cb_list = [] + for dummy, callback in plugin_conn_cb: + conn_cb_list.append(defer.maybeDeferred(callback, profile)) + list_d = defer.DeferredList(conn_cb_list) - def pluginsConnection(dummy): - """Call profileConnected callback for all plugins, and print error message if any of them fails""" - conn_cb_list = [] - for dummy, callback in plugin_conn_cb: - conn_cb_list.append(defer.maybeDeferred(callback, profile)) - list_d = defer.DeferredList(conn_cb_list) + def logPluginResults(results): + all_succeed = all([success for success, result in results]) + if not all_succeed: + log.error(_("Plugins initialisation error")) + for idx, (success, result) in enumerate(results): + if not success: + log.error("error (plugin %(name)s): %(failure)s" % + {'name': plugin_conn_cb[idx][0], 'failure': result}) + + yield list_d.addCallback(logPluginResults) + + def _authenticateProfile(self, password, profile): + """Authenticate the profile. - def logPluginResults(results): - all_succeed = all([success for success, result in results]) - if not all_succeed: - log.error(_("Plugins initialisation error")) - for idx, (success, result) in enumerate(results): - if not success: - log.error("error (plugin %(name)s): %(failure)s" % {'name': plugin_conn_cb[idx][0], - 'failure': result}) + @param password (string): the SàT profile password + @param profile_key: %(doc_profile_key)s + @return: Deferred: a deferred None in case of success, a failure otherwise. + """ + session_data = self.memory.auth_sessions.profileGetUnique(profile) + if not password and session_data: + # XXX: this allows any frontend to connect with the empty password as soon as + # the profile has been authenticated at least once before. It is OK as long as + # submitting a form with empty passwords is restricted to local frontends. + return defer.succeed(None) - list_d.addCallback(logPluginResults) - return list_d + def check_result(result): + if not result: + log.warning(_('Authentication failure of profile %s') % profile) + raise exceptions.PasswordError(_("The provided profile password doesn't match.")) + if not session_data: # avoid to create two profile sessions when password if specified + return self.memory.newAuthSession(password, profile) - d.addCallback(pluginsConnection) - return d - - self.memory.startProfileSession(profile) - return self.memory.loadIndividualParams(profile).addCallback(afterMemoryInit) - return self._initialised.addCallback(backendInitialised) + d = self.memory.asyncGetParamA(C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile) + d.addCallback(lambda sat_cipher: PasswordHasher.verify(password, sat_cipher)) + return d.addCallback(check_result) def disconnect(self, profile_key): """disconnect from jabber server""" @@ -388,21 +427,24 @@ def registerNewAccountCB(self, data, profile): # FIXME: to be removed/redone elsewhere user = jid.parse(self.memory.getParamA("JabberID", "Connection", profile_key=profile))[0] - password = self.memory.getParamA("Password", "Connection", profile_key=profile) server = self.memory.getParamA("Server", "Connection", profile_key=profile) + d = self.memory.asyncGetParamA("Password", "Connection", profile_key=profile) - if not user or not password or not server: - raise exceptions.DataError(_("No user, password or server given, can't register new account.")) + def gotPassword(password): + if not user or not password or not server: + raise exceptions.DataError(_("No user, password or server given, can't register new account.")) - # FIXME: to be fixed with XMLUI dialogs once their implemented - confirm_id = sat_next_id() - self.__private_data[confirm_id] = (id, profile) + # FIXME: to be fixed with XMLUI dialogs once their implemented + confirm_id = sat_next_id() + self.__private_data[confirm_id] = (id, profile) - self.askConfirmation( - confirm_id, "YES/NO", - {"message": _("Are you sure to register new account [%(user)s] to server %(server)s ?") % {'user': user, 'server': server, 'profile': profile}}, - self.regisConfirmCB, profile) - print "===============+++++++++++ REGISTER NEW ACCOUNT++++++++++++++============" + self.askConfirmation( + confirm_id, "YES/NO", + {"message": _("Are you sure to register new account [%(user)s] to server %(server)s ?") % {'user': user, 'server': server, 'profile': profile}}, + self.regisConfirmCB, profile) + print "===============+++++++++++ REGISTER NEW ACCOUNT++++++++++++++============" + + d.addCallback(gotPassword) def regisConfirmCB(self, id, accepted, data, profile): print _("register Confirmation CB ! (%s)") % str(accepted) @@ -410,9 +452,9 @@ del self.__private_data[id] if accepted: user = jid.parse(self.memory.getParamA("JabberID", "Connection", profile_key=profile))[0] - password = self.memory.getParamA("Password", "Connection", profile_key=profile) server = self.memory.getParamA("Server", "Connection", profile_key=profile) - self.registerNewAccount(user, password, None, server, id=action_id) + d = self.memory.asyncGetParamA("Password", "Connection", profile_key=profile) + d.addCallback(lambda password: self.registerNewAccount(user, password, None, server, id=action_id)) else: self.actionResult(action_id, "SUPPRESS", {}, profile) @@ -420,18 +462,17 @@ def setParam(self, name, value, category, security_limit, profile_key): """set wanted paramater and notice observers""" - log.info(_("setting param: %(name)s=%(value)s in category %(category)s") % {'name': name, 'value': value, 'category': category}) self.memory.setParam(name, value, category, security_limit, profile_key) def isConnected(self, profile_key): """Return connection status of profile @param profile_key: key_word or profile name to determine profile name - @return True if connected + @return: True if connected """ profile = self.memory.getProfileName(profile_key) if not profile: log.error(_('asking connection status for a non-existant profile')) - return + raise exceptions.ProfileUnknownError(profile_key) if profile not in self.profiles: return False return self.profiles[profile].isConnected() diff -r f6182f6418ea -r 15f43b54d697 src/memory/memory.py --- a/src/memory/memory.py Sat May 10 17:37:32 2014 +0200 +++ b/src/memory/memory.py Wed May 07 16:02:23 2014 +0200 @@ -34,6 +34,7 @@ from sat.memory.persistent import PersistentDict from sat.memory.params import Params from sat.memory.disco import Discovery +from sat.memory.crypto import BlockCipher class Sessions(object): @@ -210,6 +211,7 @@ self._entities_cache = {} # XXX: keep presence/last resource/other data in cache # /!\ an entity is not necessarily in roster self.subscriptions = {} + self.auth_sessions = ProfileSessions() # remember the authenticated profiles self.disco = Discovery(host) fixLocalDir(False) # XXX: tmp update code, will be removed in the future self.config = self.parseMainConf() @@ -259,7 +261,7 @@ """Load parameters template from xml file @param filename (str): input file - @return bool: True in case of success + @return: bool: True in case of success """ if not filename: return False @@ -289,6 +291,24 @@ log.info(_("[%s] Profile session started" % profile)) self._entities_cache[profile] = {} + def newAuthSession(self, key, profile): + """Start a new session for the authenticated profile. + + The personal key is loaded encrypted from a PersistentDict before being decrypted. + + @param key: the key to decrypt the personal key + @param profile: %(doc_profile)s + @return: a deferred None value + """ + def gotPersonalKey(personal_key): + """Create the session for this profile and store the personal key""" + self.auth_sessions.newSession({C.MEMORY_CRYPTO_KEY: personal_key}, profile) + log.debug('auth session created for profile %s' % profile) + + d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load() + d.addCallback(lambda data: BlockCipher.decrypt(key, data[C.MEMORY_CRYPTO_KEY])) + return d.addCallback(gotPersonalKey) + def purgeProfileSession(self, profile): """Delete cache of data of profile @param profile: %(doc_profile)s""" @@ -303,7 +323,7 @@ """Save parameters template to xml file @param filename (str): output file - @return bool: True in case of success + @return: bool: True in case of success """ if not filename: return False @@ -320,17 +340,23 @@ def getProfilesList(self): return self.storage.getProfilesList() - def getProfileName(self, profile_key, return_profile_keys = False): + def getProfileName(self, profile_key, return_profile_keys=False): """Return name of profile from keyword @param profile_key: can be the profile name or a keywork (like @DEFAULT@) @return: profile name or None if it doesn't exist""" return self.params.getProfileName(profile_key, return_profile_keys) - def asyncCreateProfile(self, name): + def asyncCreateProfile(self, name, password=''): """Create a new profile - @param name: Profile name + @param name: profile name + @param password: profile password + @return: Deferred """ - return self.params.asyncCreateProfile(name) + personal_key = BlockCipher.getRandomKey(base64=True) # generated once for all and saved in a PersistentDict + self.auth_sessions.newSession({C.MEMORY_CRYPTO_KEY: personal_key}, name) # will be encrypted by setParam + d = self.params.asyncCreateProfile(name) + d.addCallback(lambda dummy: self.setParam(C.PROFILE_PASS_PATH[1], password, C.PROFILE_PASS_PATH[0], profile_key=name)) + return d def asyncDeleteProfile(self, name, force=False): """Delete an existing profile @@ -355,7 +381,6 @@ jid_ = jid.JID(jid_s) return self.getLastResource(jid_, profile_key) or "" - def getLastResource(self, entity_jid, profile_key): """Return the last resource used by an entity @param entity_jid: entity jid @@ -502,7 +527,6 @@ """ return self.getEntityData(entity_jid, (key,), profile_key)[key] - def delEntityCache(self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE): """Remove cached data for entity @param entity_jid: JID of the entity to delete @@ -527,6 +551,32 @@ except KeyError: log.debug("Can't delete entity [%s]: not in cache" % entity.full()) + def encryptPersonalData(self, data_key, data_value, crypto_key, profile): + """Re-encrypt a personal data (saved to a PersistentDict). + + @param data_key: key for the individual PersistentDict instance + @param data_value: the value to be encrypted + @param crypto_key: the key to encrypt the value + @param profile: %(profile_doc)s + @return: a deferred None value + """ + + def gotIndMemory(data): + d = BlockCipher.encrypt(crypto_key, data_value) + + def cb(cipher): + data[data_key] = cipher + return data.force(data_key) + + return d.addCallback(cb) + + def done(dummy): + log.debug(_('Personal data (%(ns)s, %(key)s) has been successfuly encrypted') % + {'ns': C.MEMORY_CRYPTO_NAMESPACE, 'key': data_key}) + + d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load() + return d.addCallback(gotIndMemory).addCallback(done) + def addWaitingSub(self, type_, entity_jid, profile_key): """Called when a subcription request is received""" profile = self.getProfileName(profile_key) diff -r f6182f6418ea -r 15f43b54d697 src/memory/params.py --- a/src/memory/params.py Sat May 10 17:37:32 2014 +0200 +++ b/src/memory/params.py Wed May 07 16:02:23 2014 +0200 @@ -21,6 +21,7 @@ from sat.core import exceptions from sat.core.constants import Const as C +from sat.memory.crypto import BlockCipher, PasswordHasher from xml.dom import minidom, NotFoundErr from sat.core.log import getLogger log = getLogger(__name__) @@ -41,6 +42,9 @@ + + + @@ -57,6 +61,7 @@ """ % { + 'category_general': _("General"), 'category_connection': _("Connection"), 'label_NewAccount': _("Register new account"), 'label_autoconnect': _('Connect on frontend startup'), @@ -311,11 +316,16 @@ d.addCallback(self.__default_ok, name, category) d.addErrback(errback or self.__default_ko, name, category) - def _getAttr(self, node, attr, value): - """ get attribute value + def _getAttr_internal(self, node, attr, value): + """Get attribute value. + + /!\ This method would return encrypted password values. + @param node: XML param node @param attr: name of the attribute to get (e.g.: 'value' or 'type') - @param value: user defined value""" + @param value: user defined value + @return: str + """ if attr == 'value': value_to_use = value if value is not None else node.getAttribute(attr) # we use value (user defined) if it exist, else we use node's default value if node.getAttribute('type') == 'bool': @@ -323,6 +333,54 @@ return value_to_use return node.getAttribute(attr) + def _getAttr(self, node, attr, value): + """Get attribute value (synchronous). + + /!\ This method can not be used to retrieve password values. + + @param node: XML param node + @param attr: name of the attribute to get (e.g.: 'value' or 'type') + @param value: user defined value + @return: str + """ + if attr == 'value' and node.getAttribute('type') == 'password': + raise exceptions.InternalError('To retrieve password values, use _asyncGetAttr instead of _getAttr') + return self._getAttr_internal(node, attr, value) + + def _asyncGetAttr(self, node, attr, value, profile=None): + """Get attribute value. + + Profile passwords are returned hashed (if not empty), + other passwords are returned decrypted (if not empty). + + @param node: XML param node + @param attr: name of the attribute to get (e.g.: 'value' or 'type') + @param value: user defined value + @param profile: %(doc_profile)s + @return: a deferred str + """ + value = self._getAttr_internal(node, attr, value) + if attr != 'value' or node.getAttribute('type') != 'password': + return defer.succeed(value) + param_cat = node.parentNode.getAttribute('name') + param_name = node.getAttribute('name') + if ((param_cat, param_name) == C.PROFILE_PASS_PATH) or not value: + return defer.succeed(value) # profile password and empty passwords are returned "as is" + if not profile: + raise exceptions.ProfileUnknownError('The profile is needed to decrypt a password') + try: + personal_key = self.host.memory.auth_sessions.profileGetUnique(profile)[C.MEMORY_CRYPTO_KEY] + except TypeError: + raise exceptions.InternalError(_('Trying to decrypt a password while the personal key is undefined!')) + d = BlockCipher.decrypt(personal_key, value) + + def gotPlainPassword(password): + if password is None: # empty value means empty password, None means decryption failure + raise exceptions.InternalError(_('The stored password could not be decrypted!')) + return password + + return d.addCallback(gotPlainPassword) + def __type_to_string(self, result): """ convert result to string, according to its type """ if isinstance(result, bool): @@ -334,19 +392,26 @@ return self.__type_to_string(self.getParamA(name, category, attr, profile_key)) def getParamA(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE): - """Helper method to get a specific attribute - @param name: name of the parameter - @param category: category of the parameter - @param attr: name of the attribute (default: "value") - @param profile: owner of the param (@ALL@ for everyone) + """Helper method to get a specific attribute. + + /!\ This method would return encrypted password values, + to get the plain values you have to use _asyncGetParamA. - @return: attribute""" + @param name: name of the parameter + @param category: category of the parameter + @param attr: name of the attribute (default: "value") + @param profile: owner of the param (@ALL@ for everyone) + @return: attribute + """ #FIXME: looks really dirty and buggy, need to be reviewed/refactored node = self._getParamNode(name, category) if not node: log.error(_("Requested param [%(name)s] in category [%(category)s] doesn't exist !") % {'name': name, 'category': category}) raise exceptions.NotFound + if attr == 'value' and node[1].getAttribute('type') == 'password': + raise exceptions.InternalError('To retrieve password values, use asyncGetParamA instead of getParamA') + if node[0] == C.GENERAL: value = self._getParam(category, name, C.GENERAL) return self._getAttr(node[1], attr, value) @@ -372,41 +437,42 @@ return d def asyncGetParamA(self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): - """Helper method to get a specific attribute - @param name: name of the parameter - @param category: category of the parameter - @param attr: name of the attribute (default: "value") - @param profile: owner of the param (@ALL@ for everyone)""" + """Helper method to get a specific attribute. + @param name: name of the parameter + @param category: category of the parameter + @param attr: name of the attribute (default: "value") + @param profile: owner of the param (@ALL@ for everyone) + @return: Deferred + """ node = self._getParamNode(name, category) if not node: log.error(_("Requested param [%(name)s] in category [%(category)s] doesn't exist !") % {'name': name, 'category': category}) - return None + return defer.succeed(None) if not self.checkSecurityLimit(node[1], security_limit): log.warning(_("Trying to get parameter '%(param)s' in category '%(cat)s' without authorization!!!" % {'param': name, 'cat': category})) - return None + return defer.succeed(None) if node[0] == C.GENERAL: value = self._getParam(category, name, C.GENERAL) - return defer.succeed(self._getAttr(node[1], attr, value)) + return self._asyncGetAttr(node[1], attr, value) assert node[0] == C.INDIVIDUAL profile = self.getProfileName(profile_key) if not profile: - log.error(_('Requesting a param for a non-existant profile')) - return defer.fail() + raise exceptions.InternalError(_('Requesting a param for a non-existant profile')) if attr != "value": return defer.succeed(node[1].getAttribute(attr)) try: value = self._getParam(category, name, profile=profile) - return defer.succeed(self._getAttr(node[1], attr, value)) + return self._asyncGetAttr(node[1], attr, value, profile) except exceptions.ProfileNotInCacheError: #We have to ask data to the storage manager d = self.storage.getIndParam(category, name, profile) - return d.addCallback(lambda value: self._getAttr(node[1], attr, value)) + return d.addCallback(lambda value: self._asyncGetAttr(node[1], attr, value, profile)) def _getParam(self, category, name, type_=C.INDIVIDUAL, cache=None, profile=C.PROF_KEY_NONE): """Return the param, or None if it doesn't exist @@ -625,24 +691,39 @@ return categories def setParam(self, name, value, category, security_limit=C.NO_SECURITY_LIMIT, profile_key=C.PROF_KEY_NONE): - """Set a parameter, return None if the parameter is not in param xml""" - #TODO: use different behaviour depending of the data type (e.g. password encrypted) + """Set a parameter, return None if the parameter is not in param xml. + + Parameter of type 'password' that are not the SàT profile password are + stored encrypted (if not empty). The profile password is stored hashed + (if not empty). + + @param name (str): the parameter name + @param value (str): the new value + @param category (str): the parameter category + @param security_limit (int) + @param profile_key (str): %(doc_profile_key)s + @return: a deferred None value when everything is done + """ if profile_key != C.PROF_KEY_NONE: profile = self.getProfileName(profile_key) if not profile: log.error(_('Trying to set parameter for an unknown profile')) - return # TODO: throw an error + raise exceptions.ProfileUnknownError node = self._getParamNode(name, category, '@ALL@') if not node: log.error(_('Requesting an unknown parameter (%(category)s/%(name)s)') % {'category': category, 'name': name}) - return + return defer.succeed(None) if not self.checkSecurityLimit(node[1], security_limit): log.warning(_("Trying to set parameter '%(param)s' in category '%(cat)s' without authorization!!!" % {'param': name, 'cat': category})) - return + return defer.succeed(None) + + type_ = node[1].getAttribute("type") + log.info(_("Setting parameter (%(category)s, %(name)s) = %(value)s") % + {'category': category, 'name': name, 'value': value if type_ != 'password' else '********'}) if node[0] == C.GENERAL: self.params_gen[(category, name)] = value @@ -651,20 +732,67 @@ if self.host.isConnected(profile): self.host.bridge.paramUpdate(name, value, category, profile) self.host.trigger.point("paramUpdateTrigger", name, value, category, node[0], profile) - return + return defer.succeed(None) assert (node[0] == C.INDIVIDUAL) assert (profile_key != C.PROF_KEY_NONE) - type_ = node[1].getAttribute("type") + d_list = [] if type_ == "button": - print "clique", node.toxml() + log.debug("Clicked param button %s" % node.toxml()) + return defer.succeed(None) + elif type_ == "password": + try: + personal_key = self.host.memory.auth_sessions.profileGetUnique(profile)[C.MEMORY_CRYPTO_KEY] + except TypeError: + raise exceptions.InternalError(_('Trying to encrypt a password while the personal key is undefined!')) + if (category, name) == C.PROFILE_PASS_PATH: + # using 'value' as the encryption key to encrypt another encryption key... could be confusing! + d_list.append(self.host.memory.encryptPersonalData(data_key=C.MEMORY_CRYPTO_KEY, + data_value=personal_key, + crypto_key=value, + profile=profile)) + d = PasswordHasher.hash(value) # profile password is hashed (empty value stays empty) + elif value: # other non empty passwords are encrypted with the personal key + d = BlockCipher.encrypt(personal_key, value) else: + d = defer.succeed(value) + + def gotFinalValue(value): if self.host.isConnected(profile): # key can not exists if profile is not connected self.params[profile][(category, name)] = value self.host.bridge.paramUpdate(name, value, category, profile) self.host.trigger.point("paramUpdateTrigger", name, value, category, node[0], profile) - self.storage.setIndParam(category, name, value, profile) + d_list.append(self.storage.setIndParam(category, name, value, profile)) + + d.addCallback(gotFinalValue) + return defer.DeferredList(d_list).addCallback(lambda dummy: None) + + def _getNodesOfTypes(self, attr_type, node_type="@ALL@"): + """Return all the nodes matching the given types. + + TODO: using during the dev but not anymore... remove if not needed + + @param attr_type (str): the attribute type (string, text, password, bool, button, list) + @param node_type (str): keyword for filtering: + @ALL@ search everywhere + @GENERAL@ only search in general type + @INDIVIDUAL@ only search in individual type + @return: dict{tuple: node}: a dict {key, value} where: + - key is a couple (attribute category, attribute name) + - value is a node + """ + ret = {} + for type_node in self.dom.documentElement.childNodes: + if (((node_type == "@ALL@" or node_type == "@GENERAL@") and type_node.nodeName == C.GENERAL) or + ((node_type == "@ALL@" or node_type == "@INDIVIDUAL@") and type_node.nodeName == C.INDIVIDUAL)): + for cat_node in type_node.getElementsByTagName('category'): + cat = cat_node.getAttribute('name') + params = cat_node.getElementsByTagName("param") + for param in params: + if param.getAttribute("type") == attr_type: + ret[(cat, param.getAttribute("name"))] = param + return ret def checkSecurityLimit(self, node, security_limit): """Check the given node against the given security limit. diff -r f6182f6418ea -r 15f43b54d697 src/memory/sqlite.py --- a/src/memory/sqlite.py Sat May 10 17:37:32 2014 +0200 +++ b/src/memory/sqlite.py Wed May 07 16:02:23 2014 +0200 @@ -18,9 +18,11 @@ # along with this program. If not, see . from sat.core.i18n import _ +from sat.core.constants import Const as C from sat.core import exceptions from sat.core.log import getLogger log = getLogger(__name__) +from sat.memory.crypto import BlockCipher, PasswordHasher from twisted.enterprise import adbapi from twisted.internet import defer from collections import OrderedDict @@ -30,7 +32,7 @@ import cPickle as pickle import hashlib -CURRENT_DB_VERSION = 1 +CURRENT_DB_VERSION = 2 # XXX: DATABASE schemas are used in the following way: # - 'current' key is for the actual database schema, for a new base @@ -39,7 +41,7 @@ # a 'current' data dict can contains the keys: # - 'CREATE': it contains an Ordered dict with table to create as keys, and a len 2 tuple as value, where value[0] are the columns definitions and value[1] are the table constraints # - 'INSERT': it contains an Ordered dict with table where values have to be inserted, and many tuples containing values to insert in the order of the rows (#TODO: manage named columns) -# an update data dict (the ones with a number) can contains the keys 'create', 'delete', 'cols create', 'cols delete', 'cols modify' or 'insert'. See Updater.generateUpdateData for more infos. This metho can be used to autogenerate update_data, to ease the work of the developers. +# an update data dict (the ones with a number) can contains the keys 'create', 'delete', 'cols create', 'cols delete', 'cols modify', 'insert' or 'specific'. See Updater.generateUpdateData for more infos. This method can be used to autogenerate update_data, to ease the work of the developers. DATABASE_SCHEMAS = { "current": {'CREATE': OrderedDict(( @@ -59,7 +61,6 @@ ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))), ('private_gen_bin', (("namespace TEXT", "key TEXT", "value BLOB"), ("PRIMARY KEY (namespace, key)",))), - ('private_ind_bin', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value BLOB"), ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))) )), @@ -67,6 +68,8 @@ ('message_types', (("'chat'",), ("'error'",), ("'groupchat'",), ("'headline'",), ("'normal'",))), )), }, + 2: {'specific': 'update2raw_v2' + }, 1: {'cols create': {'history': ('extra BLOB',)} }, } @@ -508,7 +511,7 @@ @defer.inlineCallbacks def checkUpdates(self): - """ Check is database schema update is needed, according to DATABASE_SCHEMAS + """ Check if a database schema/content update is needed, according to DATABASE_SCHEMAS @return: deferred which fire a list of SQL update statements, or None if no update is needed """ @@ -519,7 +522,11 @@ local_hash = self.statementHash(local_sch) current_hash = self.statementHash(current_sch) - if local_hash == current_hash: + # Force the update if the schemas are unchanged but a specific update is needed + force_update = local_hash == current_hash and local_version < CURRENT_DB_VERSION \ + and 'specific' in DATABASE_SCHEMAS[CURRENT_DB_VERSION] + + if local_hash == current_hash and not force_update: if local_version != CURRENT_DB_VERSION: log.warning(_("Your local schema is up-to-date, but database versions mismatch, fixing it...")) yield self._setLocalVersion(CURRENT_DB_VERSION) @@ -532,23 +539,28 @@ # we are in a development version update_data = self.generateUpdateData(local_sch, current_sch, False) log.warning(_("There is a schema mismatch, but as we are on a dev version, database will be updated")) - update_raw = self.update2raw(update_data, True) + update_raw = yield self.update2raw(update_data, True) defer.returnValue(update_raw) else: log.error(_(u"schema version is up-to-date, but local schema differ from expected current schema")) update_data = self.generateUpdateData(local_sch, current_sch, True) - log.warning(_(u"Here are the commands that should fix the situation, use at your own risk (do a backup before modifying database), you can go to SàT's MUC room at sat@chat.jabberfr.org for help\n### SQL###\n%s\n### END SQL ###\n") % u'\n'.join(("%s;" % statement for statement in self.update2raw(update_data)))) + update_raw = yield self.update2raw(update_data) + log.warning(_(u"Here are the commands that should fix the situation, use at your own risk (do a backup before modifying database), you can go to SàT's MUC room at sat@chat.jabberfr.org for help\n### SQL###\n%s\n### END SQL ###\n") % u'\n'.join("%s;" % statement for statement in update_raw)) raise exceptions.DatabaseError("Database mismatch") else: # Database is not up-to-date, we'll do the update - log.info(_("Database schema has changed, local database will be updated")) + if force_update: + log.info(_("Database content needs a specific processing, local database will be updated")) + else: + log.info(_("Database schema has changed, local database will be updated")) update_raw = [] for version in xrange(local_version + 1, CURRENT_DB_VERSION + 1): try: update_data = DATABASE_SCHEMAS[version] except KeyError: raise exceptions.InternalError("Missing update definition (version %d)" % version) - update_raw.extend(self.update2raw(update_data)) + update_raw_step = yield self.update2raw(update_data) + update_raw.extend(update_raw_step) update_raw.append("PRAGMA user_version=%d" % CURRENT_DB_VERSION) defer.returnValue(update_raw) @@ -693,6 +705,7 @@ 'cols modify': modify_cols_data } + @defer.inlineCallbacks def update2raw(self, update, dev_version=False): """ Transform update data to raw SQLite statements @param upadte: update data as returned by generateUpdateData @@ -731,4 +744,60 @@ insert = update.get('insert', {}) ret.extend(self.insertData2Raw(insert)) - return ret + specific = update.get('specific', None) + if specific: + cmds = yield getattr(self, specific)() + ret.extend(cmds) + defer.returnValue(ret) + + def update2raw_v2(self): + """Update the database from v1 to v2 (add passwords encryptions): + - the XMPP password value is re-used for the profile password (new parameter) + - the profile password is stored hashed + - the XMPP password is stored encrypted, with the profile password as key + - as there are no other stored passwords yet, it is enough, otherwise we + would need to encrypt the other passwords as it's done for XMPP password + """ + xmpp_pass_path = ('Connection', 'Password') + + def encrypt_values(values): + ret = [] + list_ = [] + for profile_id, xmpp_password in values: + def prepare_queries(result): + try: + id_ = result[0][0] + except IndexError: + log.error("Profile of id %d is referenced in 'param_ind' but it doesn't exist!" % profile_id) + return + + sat_password = xmpp_password + d1 = PasswordHasher.hash(sat_password) + personal_key = BlockCipher.getRandomKey(base64=True) + d2 = BlockCipher.encrypt(sat_password, personal_key) + d3 = BlockCipher.encrypt(personal_key, xmpp_password) + + def gotValues(res): + sat_cipher, personal_cipher, xmpp_cipher = res[0][1], res[1][1], res[2][1] + ret.append("INSERT INTO param_ind(category,name,profile_id,value) VALUES ('%s','%s',%s,'%s')" % + (C.PROFILE_PASS_PATH[0], C.PROFILE_PASS_PATH[1], id_, sat_cipher)) + + ret.append("INSERT INTO private_ind(namespace,key,profile_id,value) VALUES ('%s','%s',%s,'%s')" % + (C.MEMORY_CRYPTO_NAMESPACE, C.MEMORY_CRYPTO_KEY, id_, personal_cipher)) + + ret.append("REPLACE INTO param_ind(category,name,profile_id,value) VALUES ('%s','%s',%s,'%s')" % + (xmpp_pass_path[0], xmpp_pass_path[1], id_, xmpp_cipher)) + + return defer.DeferredList([d1, d2, d3]).addCallback(gotValues) + + d = self.dbpool.runQuery("SELECT id FROM profiles WHERE id=?", (profile_id,)) + d.addCallback(prepare_queries) + list_.append(d) + + d_list = defer.DeferredList(list_) + d_list.addCallback(lambda dummy: ret) + return d_list + + d = self.dbpool.runQuery("SELECT profile_id,value FROM param_ind WHERE category=? AND name=?", xmpp_pass_path) + d.addCallback(encrypt_values) + return d