comparison src/memory/sqlite.py @ 1030:15f43b54d697

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
author souliane <souliane@mailoo.org>
date Wed, 07 May 2014 16:02:23 +0200
parents 6a16ec17a458
children 65fffdcb97f1
comparison
equal deleted inserted replaced
1029:f6182f6418ea 1030:15f43b54d697
16 16
17 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 from sat.core.i18n import _ 20 from sat.core.i18n import _
21 from sat.core.constants import Const as C
21 from sat.core import exceptions 22 from sat.core import exceptions
22 from sat.core.log import getLogger 23 from sat.core.log import getLogger
23 log = getLogger(__name__) 24 log = getLogger(__name__)
25 from sat.memory.crypto import BlockCipher, PasswordHasher
24 from twisted.enterprise import adbapi 26 from twisted.enterprise import adbapi
25 from twisted.internet import defer 27 from twisted.internet import defer
26 from collections import OrderedDict 28 from collections import OrderedDict
27 from time import time 29 from time import time
28 import re 30 import re
29 import os.path 31 import os.path
30 import cPickle as pickle 32 import cPickle as pickle
31 import hashlib 33 import hashlib
32 34
33 CURRENT_DB_VERSION = 1 35 CURRENT_DB_VERSION = 2
34 36
35 # XXX: DATABASE schemas are used in the following way: 37 # XXX: DATABASE schemas are used in the following way:
36 # - 'current' key is for the actual database schema, for a new base 38 # - 'current' key is for the actual database schema, for a new base
37 # - x(int) is for update needed between x-1 and x. All number are needed between y and z to do an update 39 # - x(int) is for update needed between x-1 and x. All number are needed between y and z to do an update
38 # e.g.: if CURRENT_DB_VERSION is 6, 'current' is the actuel DB, and to update from version 3, numbers 4, 5 and 6 are needed 40 # e.g.: if CURRENT_DB_VERSION is 6, 'current' is the actuel DB, and to update from version 3, numbers 4, 5 and 6 are needed
39 # a 'current' data dict can contains the keys: 41 # a 'current' data dict can contains the keys:
40 # - '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 42 # - '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
41 # - '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) 43 # - '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)
42 # 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. 44 # 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.
43 45
44 DATABASE_SCHEMAS = { 46 DATABASE_SCHEMAS = {
45 "current": {'CREATE': OrderedDict(( 47 "current": {'CREATE': OrderedDict((
46 ('profiles', (("id INTEGER PRIMARY KEY ASC", "name TEXT"), 48 ('profiles', (("id INTEGER PRIMARY KEY ASC", "name TEXT"),
47 ("UNIQUE (name)",))), 49 ("UNIQUE (name)",))),
57 ("PRIMARY KEY (namespace, key)",))), 59 ("PRIMARY KEY (namespace, key)",))),
58 ('private_ind', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value TEXT"), 60 ('private_ind', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value TEXT"),
59 ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))), 61 ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))),
60 ('private_gen_bin', (("namespace TEXT", "key TEXT", "value BLOB"), 62 ('private_gen_bin', (("namespace TEXT", "key TEXT", "value BLOB"),
61 ("PRIMARY KEY (namespace, key)",))), 63 ("PRIMARY KEY (namespace, key)",))),
62
63 ('private_ind_bin', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value BLOB"), 64 ('private_ind_bin', (("namespace TEXT", "key TEXT", "profile_id INTEGER", "value BLOB"),
64 ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE"))) 65 ("PRIMARY KEY (namespace, key, profile_id)", "FOREIGN KEY(profile_id) REFERENCES profiles(id) ON DELETE CASCADE")))
65 )), 66 )),
66 'INSERT': OrderedDict(( 67 'INSERT': OrderedDict((
67 ('message_types', (("'chat'",), ("'error'",), ("'groupchat'",), ("'headline'",), ("'normal'",))), 68 ('message_types', (("'chat'",), ("'error'",), ("'groupchat'",), ("'headline'",), ("'normal'",))),
68 )), 69 )),
69 }, 70 },
71 2: {'specific': 'update2raw_v2'
72 },
70 1: {'cols create': {'history': ('extra BLOB',)} 73 1: {'cols create': {'history': ('extra BLOB',)}
71 }, 74 },
72 } 75 }
73 76
74 77
506 d.addCallback(lambda result: [row[0] for row in result]) 509 d.addCallback(lambda result: [row[0] for row in result])
507 return d 510 return d
508 511
509 @defer.inlineCallbacks 512 @defer.inlineCallbacks
510 def checkUpdates(self): 513 def checkUpdates(self):
511 """ Check is database schema update is needed, according to DATABASE_SCHEMAS 514 """ Check if a database schema/content update is needed, according to DATABASE_SCHEMAS
512 @return: deferred which fire a list of SQL update statements, or None if no update is needed 515 @return: deferred which fire a list of SQL update statements, or None if no update is needed
513 516
514 """ 517 """
515 local_version = yield self.getLocalVersion() 518 local_version = yield self.getLocalVersion()
516 raw_local_sch = yield self.getLocalSchema() 519 raw_local_sch = yield self.getLocalSchema()
517 local_sch = self.rawStatements2data(raw_local_sch) 520 local_sch = self.rawStatements2data(raw_local_sch)
518 current_sch = DATABASE_SCHEMAS['current']['CREATE'] 521 current_sch = DATABASE_SCHEMAS['current']['CREATE']
519 local_hash = self.statementHash(local_sch) 522 local_hash = self.statementHash(local_sch)
520 current_hash = self.statementHash(current_sch) 523 current_hash = self.statementHash(current_sch)
521 524
522 if local_hash == current_hash: 525 # Force the update if the schemas are unchanged but a specific update is needed
526 force_update = local_hash == current_hash and local_version < CURRENT_DB_VERSION \
527 and 'specific' in DATABASE_SCHEMAS[CURRENT_DB_VERSION]
528
529 if local_hash == current_hash and not force_update:
523 if local_version != CURRENT_DB_VERSION: 530 if local_version != CURRENT_DB_VERSION:
524 log.warning(_("Your local schema is up-to-date, but database versions mismatch, fixing it...")) 531 log.warning(_("Your local schema is up-to-date, but database versions mismatch, fixing it..."))
525 yield self._setLocalVersion(CURRENT_DB_VERSION) 532 yield self._setLocalVersion(CURRENT_DB_VERSION)
526 else: 533 else:
527 # an update is needed 534 # an update is needed
530 # Database mismatch and we have the latest version 537 # Database mismatch and we have the latest version
531 if self._sat_version.endswith('D'): 538 if self._sat_version.endswith('D'):
532 # we are in a development version 539 # we are in a development version
533 update_data = self.generateUpdateData(local_sch, current_sch, False) 540 update_data = self.generateUpdateData(local_sch, current_sch, False)
534 log.warning(_("There is a schema mismatch, but as we are on a dev version, database will be updated")) 541 log.warning(_("There is a schema mismatch, but as we are on a dev version, database will be updated"))
535 update_raw = self.update2raw(update_data, True) 542 update_raw = yield self.update2raw(update_data, True)
536 defer.returnValue(update_raw) 543 defer.returnValue(update_raw)
537 else: 544 else:
538 log.error(_(u"schema version is up-to-date, but local schema differ from expected current schema")) 545 log.error(_(u"schema version is up-to-date, but local schema differ from expected current schema"))
539 update_data = self.generateUpdateData(local_sch, current_sch, True) 546 update_data = self.generateUpdateData(local_sch, current_sch, True)
540 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)))) 547 update_raw = yield self.update2raw(update_data)
548 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))
541 raise exceptions.DatabaseError("Database mismatch") 549 raise exceptions.DatabaseError("Database mismatch")
542 else: 550 else:
543 # Database is not up-to-date, we'll do the update 551 # Database is not up-to-date, we'll do the update
544 log.info(_("Database schema has changed, local database will be updated")) 552 if force_update:
553 log.info(_("Database content needs a specific processing, local database will be updated"))
554 else:
555 log.info(_("Database schema has changed, local database will be updated"))
545 update_raw = [] 556 update_raw = []
546 for version in xrange(local_version + 1, CURRENT_DB_VERSION + 1): 557 for version in xrange(local_version + 1, CURRENT_DB_VERSION + 1):
547 try: 558 try:
548 update_data = DATABASE_SCHEMAS[version] 559 update_data = DATABASE_SCHEMAS[version]
549 except KeyError: 560 except KeyError:
550 raise exceptions.InternalError("Missing update definition (version %d)" % version) 561 raise exceptions.InternalError("Missing update definition (version %d)" % version)
551 update_raw.extend(self.update2raw(update_data)) 562 update_raw_step = yield self.update2raw(update_data)
563 update_raw.extend(update_raw_step)
552 update_raw.append("PRAGMA user_version=%d" % CURRENT_DB_VERSION) 564 update_raw.append("PRAGMA user_version=%d" % CURRENT_DB_VERSION)
553 defer.returnValue(update_raw) 565 defer.returnValue(update_raw)
554 566
555 @staticmethod 567 @staticmethod
556 def createData2Raw(data): 568 def createData2Raw(data):
691 'cols create': create_cols_data, 703 'cols create': create_cols_data,
692 'cols delete': delete_cols_data, 704 'cols delete': delete_cols_data,
693 'cols modify': modify_cols_data 705 'cols modify': modify_cols_data
694 } 706 }
695 707
708 @defer.inlineCallbacks
696 def update2raw(self, update, dev_version=False): 709 def update2raw(self, update, dev_version=False):
697 """ Transform update data to raw SQLite statements 710 """ Transform update data to raw SQLite statements
698 @param upadte: update data as returned by generateUpdateData 711 @param upadte: update data as returned by generateUpdateData
699 @param dev_version: if True, update will be done in dev mode: no deletion will be done, instead a message will be shown. This prevent accidental lost of data while working on the code/database. 712 @param dev_version: if True, update will be done in dev mode: no deletion will be done, instead a message will be shown. This prevent accidental lost of data while working on the code/database.
700 @return: list of string with SQL statements needed to update the base 713 @return: list of string with SQL statements needed to update the base
729 ret.append(self.DROP_SQL % self.TMP_TABLE) 742 ret.append(self.DROP_SQL % self.TMP_TABLE)
730 743
731 insert = update.get('insert', {}) 744 insert = update.get('insert', {})
732 ret.extend(self.insertData2Raw(insert)) 745 ret.extend(self.insertData2Raw(insert))
733 746
734 return ret 747 specific = update.get('specific', None)
748 if specific:
749 cmds = yield getattr(self, specific)()
750 ret.extend(cmds)
751 defer.returnValue(ret)
752
753 def update2raw_v2(self):
754 """Update the database from v1 to v2 (add passwords encryptions):
755 - the XMPP password value is re-used for the profile password (new parameter)
756 - the profile password is stored hashed
757 - the XMPP password is stored encrypted, with the profile password as key
758 - as there are no other stored passwords yet, it is enough, otherwise we
759 would need to encrypt the other passwords as it's done for XMPP password
760 """
761 xmpp_pass_path = ('Connection', 'Password')
762
763 def encrypt_values(values):
764 ret = []
765 list_ = []
766 for profile_id, xmpp_password in values:
767 def prepare_queries(result):
768 try:
769 id_ = result[0][0]
770 except IndexError:
771 log.error("Profile of id %d is referenced in 'param_ind' but it doesn't exist!" % profile_id)
772 return
773
774 sat_password = xmpp_password
775 d1 = PasswordHasher.hash(sat_password)
776 personal_key = BlockCipher.getRandomKey(base64=True)
777 d2 = BlockCipher.encrypt(sat_password, personal_key)
778 d3 = BlockCipher.encrypt(personal_key, xmpp_password)
779
780 def gotValues(res):
781 sat_cipher, personal_cipher, xmpp_cipher = res[0][1], res[1][1], res[2][1]
782 ret.append("INSERT INTO param_ind(category,name,profile_id,value) VALUES ('%s','%s',%s,'%s')" %
783 (C.PROFILE_PASS_PATH[0], C.PROFILE_PASS_PATH[1], id_, sat_cipher))
784
785 ret.append("INSERT INTO private_ind(namespace,key,profile_id,value) VALUES ('%s','%s',%s,'%s')" %
786 (C.MEMORY_CRYPTO_NAMESPACE, C.MEMORY_CRYPTO_KEY, id_, personal_cipher))
787
788 ret.append("REPLACE INTO param_ind(category,name,profile_id,value) VALUES ('%s','%s',%s,'%s')" %
789 (xmpp_pass_path[0], xmpp_pass_path[1], id_, xmpp_cipher))
790
791 return defer.DeferredList([d1, d2, d3]).addCallback(gotValues)
792
793 d = self.dbpool.runQuery("SELECT id FROM profiles WHERE id=?", (profile_id,))
794 d.addCallback(prepare_queries)
795 list_.append(d)
796
797 d_list = defer.DeferredList(list_)
798 d_list.addCallback(lambda dummy: ret)
799 return d_list
800
801 d = self.dbpool.runQuery("SELECT profile_id,value FROM param_ind WHERE category=? AND name=?", xmpp_pass_path)
802 d.addCallback(encrypt_values)
803 return d