diff --git a/data/glade/chat_control_popup_menu.glade b/data/glade/chat_control_popup_menu.glade index c0ce8e45a..cb4801076 100644 --- a/data/glade/chat_control_popup_menu.glade +++ b/data/glade/chat_control_popup_menu.glade @@ -35,7 +35,7 @@ True - gtk-missing-image + gtk-file 1 @@ -49,6 +49,15 @@ + + + True + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + Toggle End to End Encryption + True + + + True diff --git a/src/chat_control.py b/src/chat_control.py index 35ba4cc62..8c595744e 100644 --- a/src/chat_control.py +++ b/src/chat_control.py @@ -953,11 +953,11 @@ class ChatControl(ChatControlBase): TYPE_ID = message_control.TYPE_CHAT old_msg_kind = None # last kind of the printed message CHAT_CMDS = ['clear', 'compact', 'help', 'me', 'ping', 'say'] - - def __init__(self, parent_win, contact, acct, resource = None): + + def __init__(self, parent_win, contact, acct, session, resource = None): ChatControlBase.__init__(self, self.TYPE_ID, parent_win, 'chat_child_vbox', contact, acct, resource) - + # for muc use: # widget = self.xml.get_widget('muc_window_actions_button') widget = self.xml.get_widget('message_window_actions_button') @@ -973,7 +973,7 @@ class ChatControl(ChatControlBase): # it is on enter-notify and leave-notify so no need to be per jid self.show_bigger_avatar_timeout_id = None self.bigger_avatar_window = None - self.show_avatar(self.contact.resource) + self.show_avatar(self.contact.resource) # chatstate timers and state self.reset_kbd_mouse_timeout_vars() @@ -987,7 +987,7 @@ class ChatControl(ChatControlBase): id = message_tv_buffer.connect('changed', self._on_message_tv_buffer_changed) self.handlers[id] = message_tv_buffer - + widget = self.xml.get_widget('avatar_eventbox') id = widget.connect('enter-notify-event', self.on_avatar_eventbox_enter_notify_event) @@ -1007,7 +1007,12 @@ class ChatControl(ChatControlBase): if self.contact.jid in gajim.encrypted_chats[self.account]: self.xml.get_widget('gpg_togglebutton').set_active(True) - + + self.session = session + + # does this window have an existing, active esession? + self.esessioned = False + self.status_tooltip = gtk.Tooltips() self.update_ui() # restore previous conversation @@ -1300,13 +1305,13 @@ class ChatControl(ChatControlBase): contact = self.contact + encrypted = bool(self.session) and self.session.enable_encryption + keyID = '' - encrypted = False if self.xml.get_widget('gpg_togglebutton').get_active(): keyID = contact.keyID encrypted = True - chatstates_on = gajim.config.get('outgoing_chat_state_notifications') != \ 'disabled' composing_xep = contact.composing_xep @@ -1318,7 +1323,7 @@ class ChatControl(ChatControlBase): # this is here (and not in send_chatstate) # because we want it sent with REAL message # (not standlone) eg. one that has body - + if contact.our_chatstate: # We already asked for xep 85, don't ask it twice composing_xep = 'asked_once' @@ -1417,18 +1422,46 @@ class ChatControl(ChatControlBase): kind = 'info' name = '' else: - ec = gajim.encrypted_chats[self.account] - if encrypted and jid not in ec: - msg = _('Encryption enabled') + # ESessions + if self.session and self.session.enable_encryption: + if not self.esessioned: + msg = _('Encryption enabled') + ChatControlBase.print_conversation_line(self, msg, + 'status', '', tim) + + if self.session.loggable: + msg = _('Session WILL be logged') + else: + msg = _('Session WILL NOT be logged') + + ChatControlBase.print_conversation_line(self, msg, + 'status', '', tim) + + self.esessioned = True + elif not encrypted: + msg = _('The following message was NOT encrypted') + ChatControlBase.print_conversation_line(self, msg, + 'status', '', tim) + elif self.esessioned: + msg = _('Encryption disabled') ChatControlBase.print_conversation_line(self, msg, 'status', '', tim) - ec.append(jid) - elif not encrypted and jid in ec: - msg = _('Encryption disabled') - ChatControlBase.print_conversation_line(self, msg, - 'status', '', tim) - ec.remove(jid) - self.xml.get_widget('gpg_togglebutton').set_active(encrypted) + self.esessioned = False + else: + # GPG encryption + ec = gajim.encrypted_chats[self.account] + if encrypted and jid not in ec: + msg = _('Encryption enabled') + ChatControlBase.print_conversation_line(self, msg, + 'status', '', tim) + ec.append(jid) + elif not encrypted and jid in ec: + msg = _('Encryption disabled') + ChatControlBase.print_conversation_line(self, msg, + 'status', '', tim) + ec.remove(jid) + self.xml.get_widget('gpg_togglebutton').set_active(encrypted) + if not frm: kind = 'incoming' name = contact.get_shown_name() @@ -1538,6 +1571,7 @@ class ChatControl(ChatControlBase): history_menuitem = xml.get_widget('history_menuitem') toggle_gpg_menuitem = xml.get_widget('toggle_gpg_menuitem') + toggle_e2e_menuitem = xml.get_widget('toggle_e2e_menuitem') add_to_roster_menuitem = xml.get_widget('add_to_roster_menuitem') send_file_menuitem = xml.get_widget('send_file_menuitem') information_menuitem = xml.get_widget('information_menuitem') @@ -1557,6 +1591,10 @@ class ChatControl(ChatControlBase): toggle_gpg_menuitem.set_active(isactive) toggle_gpg_menuitem.set_property('sensitive', is_sensitive) + # TODO: check that the remote client supports e2e + isactive = int(self.session != None and self.session.enable_encryption) + toggle_e2e_menuitem.set_active(isactive) + # If we don't have resource, we can't do file transfer # in transports, contact holds our info we need to disable it too if self.TYPE_ID == message_control.TYPE_PM and self.gc_contact.jid and \ @@ -1591,6 +1629,8 @@ class ChatControl(ChatControlBase): self.handlers[id] = add_to_roster_menuitem id = toggle_gpg_menuitem.connect('activate', self._on_toggle_gpg_menuitem_activate) + id = toggle_e2e_menuitem.connect('activate', + self._on_toggle_e2e_menuitem_activate) self.handlers[id] = toggle_gpg_menuitem id = information_menuitem.connect('activate', self._on_contact_information_menuitem_activate) @@ -1908,6 +1948,9 @@ class ChatControl(ChatControlBase): encrypted = data[4], subject = data[1], xhtml = data[7]) if len(data) > 6 and isinstance(data[6], int): message_ids.append(data[6]) + + if len(data) > 8: + self.set_session(data[8]) if message_ids: gajim.logger.set_read_messages(message_ids) gajim.events.remove_events(self.account, jid_with_resource, @@ -2035,6 +2078,28 @@ class ChatControl(ChatControlBase): '''user want to invite some friends to chat''' dialogs.TransformChatToMUC(self.account, [self.contact.jid]) + def _on_toggle_e2e_menuitem_activate(self, widget): + if self.session and self.session.enable_encryption: + self.session.terminate_e2e() + + msg = _('Encryption disabled') + ChatControlBase.print_conversation_line(self, msg, + 'status', '', None) + self.esessioned = False + + jid = str(self.session.jid) + + gajim.connections[self.account].delete_session(jid, + self.session.thread_id) + + self.session = gajim.connections[self.account].make_new_session(jid) + else: + if not self.session: + self.session = gajim.connections[self.account].make_new_session( + self.contact.jid) + + # XXX decide whether to use 4 or 3 message negotiation + self.session.negotiate_e2e(False) def got_connected(self): ChatControlBase.got_connected(self) diff --git a/src/common/check_paths.py b/src/common/check_paths.py index b7f06046d..5265c0e30 100644 --- a/src/common/check_paths.py +++ b/src/common/check_paths.py @@ -122,7 +122,7 @@ def check_and_possibly_create_paths(): print _('%s is a directory but should be a file') % LOG_DB_PATH print _('Gajim will now exit') sys.exit() - + else: # dot_gajim doesn't exist if dot_gajim: # is '' on win9x so avoid that create_path(dot_gajim) diff --git a/src/common/config.py b/src/common/config.py index cb04d2a6a..082c31be0 100644 --- a/src/common/config.py +++ b/src/common/config.py @@ -175,6 +175,7 @@ class Config: 'tabs_always_visible': [opt_bool, False, _('Show tab when only one conversation?')], 'tabs_border': [opt_bool, False, _('Show tabbed notebook border in chat windows?')], 'tabs_close_button': [opt_bool, True, _('Show close button in tab?')], + 'log_encrypted_sessions': [opt_bool, False, _('When negotiating an encrypted session, should Gajim assume you want your messages to be logged?')], 'chat_avatar_width': [opt_int, 52], 'chat_avatar_height': [opt_int, 52], 'roster_avatar_width': [opt_int, 32], diff --git a/src/common/configpaths.py b/src/common/configpaths.py index 06e7b8d96..f7dbb49d4 100644 --- a/src/common/configpaths.py +++ b/src/common/configpaths.py @@ -105,6 +105,7 @@ class ConfigPaths: def init_profile(self, profile = ''): conffile = windowsify(u'config') pidfile = windowsify(u'gajim') + secretsfile = windowsify(u'secrets') if len(profile) > 0: conffile += u'.' + profile @@ -112,6 +113,7 @@ class ConfigPaths: pidfile += u'.pid' self.add_from_root('CONFIG_FILE', conffile) self.add_from_root('PID_FILE', pidfile) + self.add_from_root('SECRETS_FILE', secretsfile) # for k, v in paths.iteritems(): # print "%s: %s" % (repr(k), repr(v)) diff --git a/src/common/connection.py b/src/common/connection.py index 8c7c72a49..6aa38f411 100644 --- a/src/common/connection.py +++ b/src/common/connection.py @@ -22,6 +22,8 @@ import os import random import socket +import time + try: randomsource = random.SystemRandom() except: @@ -819,7 +821,7 @@ class Connection(ConnectionHandlers): def send_message(self, jid, msg, keyID, type = 'chat', subject='', chatstate = None, msg_id = None, composing_xep = None, resource = None, - user_nick = None, xhtml = None, forward_from = None): + user_nick = None, xhtml = None, session = None, forward_from = None): if not self.connection: return 1 if msg and not xhtml and gajim.config.get('rst_formatting_outgoing_messages'): @@ -868,7 +870,7 @@ class Connection(ConnectionHandlers): msg_iq.setTag('nick', namespace = common.xmpp.NS_NICK).setData( user_nick) - # chatstates - if peer supports jep85 or jep22, send chatstates + # chatstates - if peer supports xep85 or xep22, send chatstates # please note that the only valid tag inside a message containing a # tag is the active event if chatstate is not None: @@ -893,6 +895,15 @@ class Connection(ConnectionHandlers): namespace=common.xmpp.NS_ADDRESS) addresses.addChild('address', attrs = {'type': 'ofrom', 'jid': forward_from}) + if session: + # XEP-0201 + session.last_send = time.time() + msg_iq.setThread(session.thread_id) + + # XEP-0200 + if session.enable_encryption: + msg_iq = session.encrypt_stanza(msg_iq) + self.connection.send(msg_iq) if not forward_from: no_log_for = gajim.config.get_per('accounts', self.name, 'no_log_for')\ @@ -912,6 +923,18 @@ class Connection(ConnectionHandlers): except exceptions.PysqliteOperationalError, e: self.dispatch('ERROR', (_('Disk Write Error'), str(e))) self.dispatch('MSGSENT', (jid, msg, keyID)) + + if session.is_loggable(): + log_msg = msg + if subject: + log_msg = _('Subject: %s\n%s') % (subject, msg) + if log_msg: + if type == 'chat': + kind = 'chat_msg_sent' + else: + kind = 'single_msg_sent' + gajim.logger.write(kind, jid, log_msg) + self.dispatch('MSGSENT', (jid, msg, keyID)) def send_stanza(self, stanza): ''' send a stanza untouched ''' diff --git a/src/common/connection_handlers.py b/src/common/connection_handlers.py index b2f0466b0..ee9a56836 100644 --- a/src/common/connection_handlers.py +++ b/src/common/connection_handlers.py @@ -24,7 +24,7 @@ import socket import sys from time import (altzone, daylight, gmtime, localtime, mktime, strftime, - time as time_time, timezone, tzname) + time as time_time, timezone, tzname) from calendar import timegm import socks5 @@ -39,6 +39,8 @@ from common.commands import ConnectionCommands from common.pubsub import ConnectionPubSub from common.caps import ConnectionCaps +from common.stanza_session import EncryptedStanzaSession + STATUS_LIST = ['offline', 'connecting', 'online', 'chat', 'away', 'xa', 'dnd', 'invisible', 'error'] # kind of events we can wait for an answer @@ -57,7 +59,7 @@ except: class ConnectionBytestream: def __init__(self): self.files_props = {} - + def is_transfer_stoped(self, file_props): if file_props.has_key('error') and file_props['error'] != 0: return True @@ -346,7 +348,7 @@ class ConnectionBytestream: iq.setID(auth_id) query = iq.setTag('query') query.setNamespace(common.xmpp.NS_BYTESTREAM) - query.setAttr('sid', proxy['sid']) + query.setAttr('sid', proxy['sid']) activate = query.setTag('activate') activate.setData(file_props['proxy_receiver']) iq.setID(auth_id) @@ -437,7 +439,7 @@ class ConnectionBytestream: gajim.proxy65_manager.resolve_result(frm, query) try: - streamhost = query.getTag('streamhost-used') + streamhost = query.getTag('streamhost-used') except: # this bytestream result is not what we need pass id = real_id[3:] @@ -703,7 +705,7 @@ class ConnectionDisco: def _DiscoverItemsGetCB(self, con, iq_obj): gajim.log.debug('DiscoverItemsGetCB') node = iq_obj.getTagAttr('query', 'node') - if node is None: + if node is None: result = iq_obj.buildReply('result') self.connection.send(result) raise common.xmpp.NodeProcessed @@ -738,6 +740,7 @@ class ConnectionDisco: q.addChild('feature', attrs = {'var': common.xmpp.NS_MUC}) q.addChild('feature', attrs = {'var': common.xmpp.NS_COMMANDS}) q.addChild('feature', attrs = {'var': common.xmpp.NS_DISCO_INFO}) + q.addChild('feature', attrs = {'var': common.xmpp.NS_ESESSION_INIT}) if (node is None or extension == 'cstates') and gajim.config.get('outgoing_chat_state_notifactions') != 'disabled': q.addChild('feature', attrs = {'var': common.xmpp.NS_CHATSTATES}) @@ -779,12 +782,12 @@ class ConnectionDisco: for key in i.getAttrs().keys(): attr[key] = i.getAttr(key) if attr.has_key('category') and \ - attr['category'] in ('gateway', 'headline') and \ - attr.has_key('type'): + attr['category'] in ('gateway', 'headline') and \ + attr.has_key('type'): transport_type = attr['type'] if attr.has_key('category') and \ - attr['category'] == 'conference' and \ - attr.has_key('type') and attr['type'] == 'text': + attr['category'] == 'conference' and \ + attr.has_key('type') and attr['type'] == 'text': is_muc = True identities.append(attr) elif i.getName() == 'feature': @@ -1193,14 +1196,17 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, # keep the jids we auto added (transports contacts) to not send the # SUBSCRIBED event to gui self.automatically_added = [] - # keep the latest subscribed event for each jid to prevent loop when we + # keep the latest subscribed event for each jid to prevent loop when we # acknoledge presences self.subscribed_events = {} + + # keep track of sessions this connection has with other JIDs + self.sessions = {} try: idle.init() except: HAS_IDLE = False - + def build_http_auth_answer(self, iq_obj, answer): if answer == 'yes': self.connection.send(iq_obj.buildReply('result')) @@ -1222,6 +1228,29 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, self.dispatch('HTTP_AUTH', (method, url, id, iq_obj, msg)); raise common.xmpp.NodeProcessed + def _FeatureNegCB(self, con, stanza, session): + gajim.log.debug('FeatureNegCB') + feature = stanza.getTag(name='feature', namespace=common.xmpp.NS_FEATURE) + form = common.xmpp.DataForm(node=feature.getTag('x')) + + if form['FORM_TYPE'] == 'urn:xmpp:ssn': + self.dispatch('SESSION_NEG', (stanza.getFrom(), session, form)) + else: + reply = stanza.buildReply() + reply.setType('error') + + reply.addChild(feature) + reply.addChild(node=xmpp.ErrorNode('service-unavailable', typ='cancel')) + + con.send(reply) + + def _InitE2ECB(self, con, stanza, session): + gajim.log.debug('InitE2ECB') + init = stanza.getTag(name='init', namespace=common.xmpp.NS_ESESSION_INIT) + form = common.xmpp.DataForm(node=init.getTag('x')) + + self.dispatch('SESSION_NEG', (stanza.getFrom(), session, form)) + def _ErrorCB(self, con, iq_obj): gajim.log.debug('ErrorCB') if iq_obj.getQueryNS() == common.xmpp.NS_VERSION: @@ -1298,10 +1327,10 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, def _rosterSetCB(self, con, iq_obj): gajim.log.debug('rosterSetCB') for item in iq_obj.getTag('query').getChildren(): - jid = helpers.parse_jid(item.getAttr('jid')) + jid = helpers.parse_jid(item.getAttr('jid')) name = item.getAttr('name') - sub = item.getAttr('subscription') - ask = item.getAttr('ask') + sub = item.getAttr('subscription') + ask = item.getAttr('ask') groups = [] for group in item.getTags('group'): groups.append(group.getData()) @@ -1384,7 +1413,7 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, gajim.log.debug('TimeRevisedCB') iq_obj = iq_obj.buildReply('result') qp = iq_obj.setTag('time', - namespace=common.xmpp.NS_TIME_REVISED) + namespace=common.xmpp.NS_TIME_REVISED) qp.setTagData('utc', strftime('%Y-%m-%dT%TZ', gmtime())) zone = -(timezone, altzone)[daylight] / 60 tzo = (zone / 60, abs(zone % 60)) @@ -1437,6 +1466,19 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, def _messageCB(self, con, msg): '''Called when we receive a message''' + frm = helpers.get_full_jid_from_iq(msg) + mtype = msg.getType() + thread_id = msg.getThread() + + if not mtype: + mtype = 'normal' + + if not mtype == 'groupchat': + session = self.get_session(frm, thread_id, mtype) + + if thread_id and not session.received_thread_id: + session.received_thread_id = True + # check if the message is pubsub#event if msg.getTag('event') is not None: self._pubsubEventCB(con, msg) @@ -1446,9 +1488,30 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, common.xmpp.NS_HTTP_AUTH: self._HttpAuthCB(con, msg) return + if msg.getTag('feature') and msg.getTag('feature').namespace == \ + common.xmpp.NS_FEATURE: + self._FeatureNegCB(con, msg, session) + return + if msg.getTag('init') and msg.getTag('init').namespace == \ + common.xmpp.NS_ESESSION_INIT: + self._InitE2ECB(con, msg, session) + + encrypted = False + tim = msg.getTimestamp() + tim = strptime(tim, '%Y%m%dT%H:%M:%S') + tim = localtime(timegm(tim)) + + e2e_tag = msg.getTag('c', namespace = common.xmpp.NS_STANZA_CRYPTO) + if e2e_tag: + encrypted = True + + try: + msg = session.decrypt_stanza(msg) + except: + self.dispatch('FAILED_DECRYPT', (frm, tim)) + msgtxt = msg.getBody() msghtml = msg.getXHTML() - mtype = msg.getType() subject = msg.getSubject() # if not there, it's None tim = msg.getTimestamp() tim = helpers.datetime_tuple(tim) @@ -1518,7 +1581,7 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, if encTag and GnuPG.USE_GPG: #decrypt encmsg = encTag.getData() - + keyID = gajim.config.get_per('accounts', self.name, 'keyid') if keyID: decmsg = self.gpg.decrypt(encmsg, keyID) @@ -1530,7 +1593,7 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, if not error_msg: error_msg = msgtxt msgtxt = None - if self.name not in no_log_for: + if session.is_loggable(): try: gajim.logger.write('error', frm, error_msg, tim = tim, subject = subject) @@ -1568,8 +1631,7 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, elif mtype == 'chat': # it's type 'chat' if not msg.getTag('body') and chatstate is None: #no return - if msg.getTag('body') and self.name not in no_log_for and jid not in\ - no_log_for and msgtxt: + if msg.getTag('body') and session.is_loggable() and msgtxt: try: msg_id = gajim.logger.write('chat_msg_recv', frm, msgtxt, tim = tim, subject = subject) @@ -1588,7 +1650,7 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, self.dispatch('GC_INVITATION',(frm, jid_from, reason, password, is_continued)) return - if self.name not in no_log_for and jid not in no_log_for and msgtxt: + if session.is_loggable()and msgtxt: try: gajim.logger.write('single_msg_recv', frm, msgtxt, tim = tim, subject = subject) @@ -1599,9 +1661,81 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, if treat_as: mtype = treat_as self.dispatch('MSG', (frm, msgtxt, tim, encrypted, mtype, - subject, chatstate, msg_id, composing_xep, user_nick, msghtml)) + subject, chatstate, msg_id, composing_xep, user_nick, msghtml, + session)) # END messageCB + def get_session(self, jid, thread_id, type): + '''returns an existing session between this connection and 'jid', returns a new one if none exist.''' + session = self.find_session(jid, thread_id, type) + + if session: + return session + else: + # it's possible we initiated a session with a bare JID and this is the + # first time we've seen a resource + bare_jid = gajim.get_jid_without_resource(jid) + if bare_jid != jid: + session = self.find_session(bare_jid, thread_id, type) + if session: + if not session.received_thread_id: + thread_id = session.thread_id + + self.move_session(bare_jid, thread_id, jid.split("/")[1]) + return session + + return self.make_new_session(jid, thread_id, type) + + def find_session(self, jid, thread_id, type): + try: + if type == 'chat' and not thread_id: + return self.find_null_session(jid) + else: + return self.sessions[jid][thread_id] + except KeyError: + return None + + def delete_session(self, jid, thread_id): + del self.sessions[jid][thread_id] + + if not self.sessions[jid]: + del self.sessions[jid] + + def move_session(self, original_jid, thread_id, to_resource): + '''moves a session to another resource.''' + session = self.sessions[original_jid][thread_id] + + del self.sessions[original_jid][thread_id] + + new_jid = gajim.get_jid_without_resource(original_jid) + '/' + to_resource + session.jid = common.xmpp.JID(new_jid) + + if not new_jid in self.sessions: + self.sessions[new_jid] = {} + + self.sessions[new_jid][thread_id] = session + + def find_null_session(self, jid): + '''finds all of the sessions between us and jid that jid hasn't sent a thread_id in yet. + +returns the session that we last sent a message to.''' + + sessions_with_jid = self.sessions[jid].values() + no_threadid_sessions = filter(lambda s: not s.received_thread_id, sessions_with_jid) + no_threadid_sessions.sort(key=lambda s: s.last_send) + + return no_threadid_sessions[-1] + + def make_new_session(self, jid, thread_id = None, type = 'chat'): + sess = EncryptedStanzaSession(self, common.xmpp.JID(jid), thread_id, type) + + if not jid in self.sessions: + self.sessions[jid] = {} + + self.sessions[jid][sess.thread_id] = sess + + return sess + def _pubsubEventCB(self, con, msg): ''' Called when we receive with pubsub event. ''' # TODO: Logging? (actually services where logging would be useful, should diff --git a/src/common/dh.py b/src/common/dh.py new file mode 100644 index 000000000..8dd73f669 --- /dev/null +++ b/src/common/dh.py @@ -0,0 +1,207 @@ +import string + +# This file defines a number of constants; specifically, large primes suitable for +# use with the Diffie-Hellman key exchange. +# +# These constants have been obtained from RFC2409 and RFC3526. + +generators = [ None, # one to get the right offset + 2, + 2, + None, + None, + 2, + None, + None, + None, + None, + None, + None, + None, + None, + 2, # group 14 + 2, + 2, + 2, + 2, + ] + +hex_primes = [ None, + +# group 1 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A63A3620 FFFFFFFF FFFFFFFF''', + +# group 2 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED +EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE65381 +FFFFFFFF FFFFFFFF''', + +# XXX how do I obtain these? +None, +None, + +# group 5 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED +EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D +C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F +83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D +670C354E 4ABC9804 F1746C08 CA237327 FFFFFFFF FFFFFFFF''', + +None, +None, +None, +None, +None, +None, +None, +None, + +# group 14 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED +EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D +C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F +83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D +670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B +E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 +DE2BCBF6 95581718 3995497C EA956AE5 15D22618 98FA0510 +15728E5A 8AACAA68 FFFFFFFF FFFFFFFF''', + +# group 15 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED +EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D +C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F +83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D +670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B +E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 +DE2BCBF6 95581718 3995497C EA956AE5 15D22618 98FA0510 +15728E5A 8AAAC42D AD33170D 04507A33 A85521AB DF1CBA64 +ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 +ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B +F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C +BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 +43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF''', + +# group 16 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED +EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D +C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F +83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D +670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B +E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 +DE2BCBF6 95581718 3995497C EA956AE5 15D22618 98FA0510 +15728E5A 8AAAC42D AD33170D 04507A33 A85521AB DF1CBA64 +ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 +ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B +F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C +BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 +43DB5BFC E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 +88719A10 BDBA5B26 99C32718 6AF4E23C 1A946834 B6150BDA +2583E9CA 2AD44CE8 DBBBC2DB 04DE8EF9 2E8EFC14 1FBECAA6 +287C5947 4E6BC05D 99B2964F A090C3A2 233BA186 515BE7ED +1F612970 CEE2D7AF B81BDD76 2170481C D0069127 D5B05AA9 +93B4EA98 8D8FDDC1 86FFFB7DC 90A6C08F 4DF435C9 34063199 +FFFFFFFF FFFFFFFF''', + +# group 17 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 +8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B +302B0A6D F25F1437 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 +A637ED6B 0BFF5CB6 F406B7ED EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 +49286651 ECE45B3D C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 +FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D +670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B E39E772C +180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 +3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D +04507A33 A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D +B3970F85 A6E1E4C7 ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 +1AD2EE6B F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C +BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 43DB5BFC +E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 88719A10 BDBA5B26 +99C32718 6AF4E23C 1A946834 B6150BDA 2583E9CA 2AD44CE8 DBBBC2DB +04DE8EF9 2E8EFC14 1FBECAA6 287C5947 4E6BC05D 99B2964F A090C3A2 +233BA186 515BE7ED 1F612970 CEE2D7AF B81BDD76 2170481C D0069127 +D5B05AA9 93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34028492 +36C3FAB4 D27C7026 C1D4DCB2 602646DE C9751E76 3DBA37BD F8FF9406 +AD9E530E E5DB382F 413001AE B06A53ED 9027D831 179727B0 865A8918 +DA3EDBEB CF9B14ED 44CE6CBA CED4BB1B DB7F1447 E6CC254B 33205151 +2BD7AF42 6FB8F401 378CD2BF 5983CA01 C64B92EC F032EA15 D1721D03 +F482D7CE 6E74FEF6 D55E702F 46980C82 B5A84031 900B1C9E 59E7C97F +BEC7E8F3 23A97A7E 36CC88BE 0F1D45B7 FF585AC5 4BD407B2 2B4154AA +CC8F6D7E BF48E1D8 14CC5ED2 0F8037E0 A79715EE F29BE328 06A1D58B +B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C DA56C9EC 2EF29632 +387FE8D7 6E3C0468 043E8F66 3F4860EE 12BF2D5B 0B7474D6 E694F91E +6DCC4024 FFFFFFFF FFFFFFFF''', + +# group 18 +'''FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 +29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD +EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 +E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED +EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D +C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F +83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D +670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B +E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 +DE2BCBF6 95581718 3995497C EA956AE5 15D22618 98FA0510 +15728E5A 8AAAC42D AD33170D 04507A33 A85521AB DF1CBA64 +ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 +ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B +F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C +BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31 +43DB5BFC E0FD108E 4B82D120 A9210801 1A723C12 A787E6D7 +88719A10 BDBA5B26 99C32718 6AF4E23C 1A946834 B6150BDA +2583E9CA 2AD44CE8 DBBBC2DB 04DE8EF9 2E8EFC14 1FBECAA6 +287C5947 4E6BC05D 99B2964F A090C3A2 233BA186 515BE7ED +1F612970 CEE2D7AF B81BDD76 2170481C D0069127 D5B05AA9 +93B4EA98 8D8FDDC1 86FFB7DC 90A6C08F 4DF435C9 34028492 +36C3FAB4 D27C7026 C1D4DCB2 602646DE C9751E76 3DBA37BD +F8FF9406 AD9E530E E5DB382F 413001AE B06A53ED 9027D831 +179727B0 865A8918 DA3EDBEB CF9B14ED 44CE6CBA CED4BB1B +DB7F1447 E6CC254B 33205151 2BD7AF42 6FB8F401 378CD2BF +5983CA01 C64B92EC F032EA15 D1721D03 F482D7CE 6E74FEF6 +D55E702F 46980C82 B5A84031 900B1C9E 59E7C97F BEC7E8F3 +23A97A7E 36CC88BE 0F1D45B7 FF585AC5 4BD407B2 2B4154AA +CC8F6D7E BF48E1D8 14CC5ED2 0F8037E0 A79715EE F29BE328 +06A1D58B B7C5DA76 F550AA3D 8A1FBFF0 EB19CCB1 A313D55C +DA56C9EC 2EF29632 387FE8D7 6E3C0468 043E8F66 3F4860EE +12BF2D5B 0B7474D6 E694F91E 6DBE1159 74A3926F 12FEE5E4 +38777CB6 A932DF8C D8BEC4D0 73B931BA 3BC832B6 8D9DD300 +741FA7BF 8AFC47ED 2576F693 6BA42466 3AAB639C 5AE4F568 +3423B474 2BF1C978 238F16CB E39D652D E3FDB8BE FC848AD9 +22222E04 A4037C07 13EB57A8 1A23F0C7 3473FC64 6CEA306B +4BCBC886 2F8385DD FA9D4B7F A2C087E8 79683303 ED5BDD3A +062B3CF5 B3A278A6 6D2A13F8 3F44F82D DF310EE0 74AB6A36 +4597E899 A0255DC1 64F31CC5 0846851D F9AB4819 5DED7EA1 +B1D510BD 7EE74D73 FAF36BC3 1ECFA268 359046F4 EB879F92 +4009438B 481C6CD7 889A002E D5EE382B C9190DA6 FC026E47 +9558E447 5677E9AA 9E3050E2 765694DF C81F56E8 80B96E71 +60C980DD 98EDD3DF FFFFFFFF FFFFFFFF''' +] + +all_ascii = ''.join(map(chr, range(256))) + +def hex_to_decimal(stripee): + if not stripee: + return None + + return int(stripee.translate(all_ascii, string.whitespace), 16) + +primes = map(hex_to_decimal, hex_primes) diff --git a/src/common/exceptions.py b/src/common/exceptions.py index 12d674535..79deaf04c 100644 --- a/src/common/exceptions.py +++ b/src/common/exceptions.py @@ -54,8 +54,16 @@ class SessionBusNotPresent(Exception): def __str__(self): return _('Session bus is not available.\nTry reading http://trac.gajim.org/wiki/GajimDBus') +class NegotiationError(Exception): + '''A session negotiation failed''' + pass + +class DecryptionError(Exception): + '''A message couldn't be decrypted into usable XML''' + pass + class GajimGeneralException(Exception): - '''This exception ir our general exception''' + '''This exception is our general exception''' def __init__(self, text=''): Exception.__init__(self) self.text = text diff --git a/src/common/stanza_session.py b/src/common/stanza_session.py new file mode 100644 index 000000000..e1ffc53f9 --- /dev/null +++ b/src/common/stanza_session.py @@ -0,0 +1,927 @@ +from common import gajim + +from common import xmpp +from common import helpers +from common import exceptions + +import random +import string + +import math +import os +import time + +from common import dh +import xmpp.c14n + +from Crypto.Cipher import AES +from Crypto.Hash import HMAC, SHA256 +from Crypto.PublicKey import RSA + +import base64 + +XmlDsig = 'http://www.w3.org/2000/09/xmldsig#' + +class StanzaSession(object): + def __init__(self, conn, jid, thread_id, type): + self.conn = conn + + self.jid = jid + + self.type = type + + if thread_id: + self.received_thread_id = True + self.thread_id = thread_id + else: + self.received_thread_id = False + if type == 'normal': + self.thread_id = None + else: + self.thread_id = self.generate_thread_id() + + self.last_send = 0 + self.status = None + self.negotiated = {} + + def generate_thread_id(self): + return "".join([random.choice(string.letters) for x in xrange(0,32)]) + + def send(self, msg): + if self.thread_id: + msg.NT.thread = self.thread_id + + msg.setAttr('to', self.jid) + self.conn.send_stanza(msg) + + if isinstance(msg, xmpp.Message): + self.last_send = time.time() + + def reject_negotiation(self, body = None): + msg = xmpp.Message() + feature = msg.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + x = xmpp.DataForm(typ='submit') + x.addChild(node=xmpp.DataField(name='FORM_TYPE', value='urn:xmpp:ssn')) + x.addChild(node=xmpp.DataField(name='accept', value='0')) + + feature.addChild(node=x) + + if body: + msg.setBody(body) + + self.send(msg) + + self.cancelled_negotiation() + + def cancelled_negotiation(self): + '''A negotiation has been cancelled, so reset this session to its default state.''' + self.status = None + self.negotiated = {} + + def terminate(self): + msg = xmpp.Message() + feature = msg.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + x = xmpp.DataForm(typ='submit') + x.addChild(node=xmpp.DataField(name='FORM_TYPE', value='urn:xmpp:ssn')) + x.addChild(node=xmpp.DataField(name='terminate', value='1')) + + feature.addChild(node=x) + + self.send(msg) + + self.status = None + + def acknowledge_termination(self): + # we could send an acknowledgement message here, but we won't. + self.status = None + +# an encrypted stanza negotiation has several states. i've represented them as the following values in the 'status' +# attribute of the session object: + +# 1. None: +# default state +# 2. 'requested-e2e': +# this client has initiated an esession negotiation and is waiting for +# a response +# 3. 'responded-e2e': +# this client has responded to an esession negotiation request and is +# waiting for the initiator to identify itself and complete the +# negotiation +# 4. 'identified-alice': +# this client identified itself and is waiting for the responder to +# identify itself and complete the negotiation +# 5. 'active': +# an encrypted session has been successfully negotiated. messages of +# any of the types listed in 'encryptable_stanzas' should be encrypted +# before they're sent. + +# the transition between these states is handled in gajim.py's +# handle_session_negotiation method. + +class EncryptedStanzaSession(StanzaSession): + def __init__(self, conn, jid, thread_id, type = 'chat'): + StanzaSession.__init__(self, conn, jid, thread_id, type = 'chat') + + self.loggable = True + + self.xes = {} + self.es = {} + + self.n = 128 + + self.enable_encryption = False + + # _s denotes 'self' (ie. this client) + self._kc_s = None + + # _o denotes 'other' (ie. the client at the other end of the session) + self._kc_o = None + + # keep the encrypter updated with my latest cipher key + def set_kc_s(self, value): + self._kc_s = value + self.encrypter = self.cipher.new(self._kc_s, self.cipher.MODE_CTR, counter=self.encryptcounter) + + def get_kc_s(self): + return self._kc_s + + # keep the decrypter updated with the other party's latest cipher key + def set_kc_o(self, value): + self._kc_o = value + self.decrypter = self.cipher.new(self._kc_o, self.cipher.MODE_CTR, counter=self.decryptcounter) + + def get_kc_o(self): + return self._kc_o + + kc_s = property(get_kc_s, set_kc_s) + kc_o = property(get_kc_o, set_kc_o) + + # convert a large integer to a big-endian bitstring + def encode_mpi(self, n): + if n >= 256: + return self.encode_mpi(n / 256) + chr(n % 256) + else: + return chr(n) + + # convert a large integer to a big-endian bitstring, padded with \x00s to 16 bytes + def encode_mpi_with_padding(self, n): + ret = self.encode_mpi(n) + + mod = len(ret) % 16 + if mod != 0: + ret = ((16 - mod) * '\x00') + ret + + return ret + + # convert a big-endian bitstring to an integer + def decode_mpi(self, s): + if len(s) == 0: + return 0 + else: + return 256 * self.decode_mpi(s[:-1]) + ord(s[-1]) + + def encryptcounter(self): + self.c_s = (self.c_s + 1) % (2 ** self.n) + return self.encode_mpi_with_padding(self.c_s) + + def decryptcounter(self): + self.c_o = (self.c_o + 1) % (2 ** self.n) + return self.encode_mpi_with_padding(self.c_o) + + def sign(self, string): + if self.negotiated['sign_algs'] == (XmlDsig + 'rsa-sha256'): + hash = self.sha256(string) + return self.encode_mpi(gajim.interface.pubkey.sign(hash, '')[0]) + + def encrypt_stanza(self, stanza): + encryptable = filter(lambda x: x.getName() not in ('error', 'amp', 'thread'), stanza.getChildren()) + + # XXX can also encrypt contents of elements in stanzas @type = 'error' + # (except for child elements) + + old_en_counter = self.c_s + + for element in encryptable: + stanza.delChild(element) + + plaintext = ''.join(map(str, encryptable)) + + m_compressed = self.compress(plaintext) + m_final = self.encrypt(m_compressed) + + c = stanza.NT.c + c.setNamespace('http://www.xmpp.org/extensions/xep-0200.html#ns') + c.NT.data = base64.b64encode(m_final) + + # XXX check for rekey request, handle elements + + m_content = ''.join(map(str, c.getChildren())) + c.NT.mac = base64.b64encode(self.hmac(self.km_s, m_content + self.encode_mpi(old_en_counter))) + + return stanza + + def hmac(self, key, content): + return HMAC.new(key, content, self.hash_alg).digest() + + def sha256(self, string): + sh = SHA256.new() + sh.update(string) + return sh.digest() + + base28_chr = "acdefghikmopqruvwxy123456789" + + def sas_28x5(self, m_a, form_b): + sha = self.sha256(m_a + form_b + 'Short Authentication String') + lsb24 = self.decode_mpi(sha[-3:]) + return self.base28(lsb24) + + def base28(self, n): + if n >= 28: + return self.base28(n / 28) + self.base28_chr[n % 28] + else: + return self.base28_chr[n] + + def generate_initiator_keys(self, k): + return (self.hmac(k, 'Initiator Cipher Key'), + self.hmac(k, 'Initiator MAC Key'), + self.hmac(k, 'Initiator SIGMA Key') ) + + def generate_responder_keys(self, k): + return (self.hmac(k, 'Responder Cipher Key'), + self.hmac(k, 'Responder MAC Key'), + self.hmac(k, 'Responder SIGMA Key') ) + + def compress(self, plaintext): + if self.compression == None: + return plaintext + + def decompress(self, compressed): + if self.compression == None: + return compressed + + def encrypt(self, encryptable): + len_padding = 16 - (len(encryptable) % 16) + if len_padding != 16: + encryptable += len_padding * ' ' + + return self.encrypter.encrypt(encryptable) + + # FIXME: use a real PRNG + def random_bytes(self, bytes): + return os.urandom(bytes) + + def generate_nonce(self): + return self.random_bytes(8) + + def decrypt_stanza(self, stanza): + c = stanza.getTag(name='c', namespace='http://www.xmpp.org/extensions/xep-0200.html#ns') + + stanza.delChild(c) + + # contents of , minus , minus whitespace + macable = ''.join(map(str, filter(lambda x: x.getName() != 'mac', c.getChildren()))) + + received_mac = base64.b64decode(c.getTagData('mac')) + calculated_mac = self.hmac(self.km_o, macable + self.encode_mpi_with_padding(self.c_o)) + + if not calculated_mac == received_mac: + raise exceptions.DecryptionError, 'bad signature' + + m_final = base64.b64decode(c.getTagData('data')) + m_compressed = self.decrypt(m_final) + plaintext = self.decompress(m_compressed) + + try: + parsed = xmpp.Node(node='' + plaintext + '') + except: + raise exceptions.DecryptionError, 'decrypted not parseable as XML' + + for child in parsed.getChildren(): + stanza.addChild(node=child) + + return stanza + + def decrypt(self, ciphertext): + return self.decrypter.decrypt(ciphertext) + + def logging_preference(self): + if gajim.config.get('log_encrypted_sessions'): + return ["may", "mustnot"] + else: + return ["mustnot", "may"] + + def get_shared_secret(self, e, y, p): + if (not 1 < e < (p - 1)): + raise exceptions.NegotiationError, "invalid DH value" + + return self.sha256(self.encode_mpi(self.powmod(e, y, p))) + + def c7lize_mac_id(self, form): + kids = form.getChildren() + macable = filter(lambda x: x.getVar() not in ('mac', 'identity'), kids) + return ''.join(map(lambda el: xmpp.c14n.c14n(el), macable)) + + def verify_identity(self, form, dh_i, sigmai, i_o): + m_o = base64.b64decode(form['mac']) + id_o = base64.b64decode(form['identity']) + + m_o_calculated = self.hmac(self.km_o, self.encode_mpi(self.c_o) + id_o) + + if m_o_calculated != m_o: + raise exceptions.NegotiationError, 'calculated m_%s differs from received m_%s' % (i_o, i_o) + + if i_o == 'a' and self.sas_algs == 'sas28x5': + # XXX not necessary if there's a verified retained secret + self.sas = self.sas_28x5(m_o, self.form_s) + + if self.negotiated['recv_pubkey']: + plaintext = self.decrypt(id_o) + parsed = xmpp.Node(node='' + plaintext + '') + + if self.negotiated['recv_pubkey'] == 'hash': + fingerprint = parsed.getTagData('fingerprint') + + # XXX find stored pubkey or terminate session + raise 'unimplemented' + else: + if self.negotiated['sign_algs'] == (XmlDsig + 'rsa-sha256'): + keyvalue = parsed.getTag(name='RSAKeyValue', namespace=XmlDsig) + + n, e = map(lambda x: self.decode_mpi(base64.b64decode(keyvalue.getTagData(x))), ('Modulus', 'Exponent')) + eir_pubkey = RSA.construct((n,long(e))) + + pubkey_o = xmpp.c14n.c14n(keyvalue) + else: + # XXX DSA, etc. + raise 'unimplemented' + + enc_sig = parsed.getTag(name='SignatureValue', namespace=XmlDsig).getData() + signature = (self.decode_mpi(base64.b64decode(enc_sig)),) + else: + mac_o = self.decrypt(id_o) + pubkey_o = '' + + c7l_form = self.c7lize_mac_id(form) + + content = self.n_s + self.n_o + self.encode_mpi(dh_i) + pubkey_o + + if sigmai: + self.form_o = c7l_form + content += self.form_o + else: + form_o2 = c7l_form + content += self.form_o + form_o2 + + mac_o_calculated = self.hmac(self.ks_o, content) + + if self.negotiated['recv_pubkey']: + hash = self.sha256(mac_o_calculated) + + if not eir_pubkey.verify(hash, signature): + raise exceptions.NegotiationError, 'public key signature verification failed!' + + elif mac_o_calculated != mac_o: + raise exceptions.NegotiationError, 'calculated mac_%s differs from received mac_%s' % (i_o, i_o) + + def make_identity(self, form, dh_i): + if self.negotiated['send_pubkey']: + if self.negotiated['sign_algs'] == (XmlDsig + 'rsa-sha256'): + fields = (gajim.interface.pubkey.n, gajim.interface.pubkey.e) + + cb_fields = map(lambda f: base64.b64encode(self.encode_mpi(f)), fields) + + pubkey_s = '%s%s' % tuple(cb_fields) + else: + pubkey_s = '' + + form_s2 = ''.join(map(lambda el: xmpp.c14n.c14n(el), form.getChildren())) + + old_c_s = self.c_s + content = self.n_o + self.n_s + self.encode_mpi(dh_i) + pubkey_s + self.form_s + form_s2 + + mac_s = self.hmac(self.ks_s, content) + + if self.negotiated['send_pubkey']: + signature = self.sign(mac_s) + + sign_s = '%s' % base64.b64encode(signature) + + if self.negotiated['send_pubkey'] == 'hash': + b64ed = base64.b64encode(self.hash(pubkey_s)) + pubkey_s = '%s' % b64ed + + id_s = self.encrypt(pubkey_s + sign_s) + else: + id_s = self.encrypt(mac_s) + + m_s = self.hmac(self.km_s, self.encode_mpi(old_c_s) + id_s) + + if self.status == 'requested-e2e' and self.sas_algs == 'sas28x5': + # we're alice; check for a retained secret + # if none exists, prompt the user with the SAS + self.sas = self.sas_28x5(m_s, self.form_o) + + if self.sigmai: + self.check_identity() + + return (xmpp.DataField(name='identity', value=base64.b64encode(id_s)), \ + xmpp.DataField(name='mac', value=base64.b64encode(m_s))) + + def negotiate_e2e(self, sigmai): + self.negotiated = {} + + request = xmpp.Message() + feature = request.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + x = xmpp.DataForm(typ='form') + + x.addChild(node=xmpp.DataField(name='FORM_TYPE', value='urn:xmpp:ssn', typ='hidden')) + x.addChild(node=xmpp.DataField(name='accept', value='1', typ='boolean', required=True)) + + # this field is incorrectly called 'otr' in XEPs 0116 and 0217 + x.addChild(node=xmpp.DataField(name='logging', typ='list-single', options=self.logging_preference(), required=True)) + + # unsupported options: 'disabled', 'enabled' + x.addChild(node=xmpp.DataField(name='disclosure', typ='list-single', options=['never'], required=True)) + x.addChild(node=xmpp.DataField(name='security', typ='list-single', options=['e2e'], required=True)) + x.addChild(node=xmpp.DataField(name='crypt_algs', value='aes128-ctr', typ='hidden')) + x.addChild(node=xmpp.DataField(name='hash_algs', value='sha256', typ='hidden')) + x.addChild(node=xmpp.DataField(name='compress', value='none', typ='hidden')) + + # unsupported options: 'iq', 'presence' + x.addChild(node=xmpp.DataField(name='stanzas', typ='list-multi', options=['message'])) + + x.addChild(node=xmpp.DataField(name='init_pubkey', options=['none', 'key', 'hash'], typ='list-single')) + + # XXX store key, use hash + x.addChild(node=xmpp.DataField(name='resp_pubkey', options=['none', 'key'], typ='list-single')) + + x.addChild(node=xmpp.DataField(name='ver', value='1.0', typ='hidden')) + + x.addChild(node=xmpp.DataField(name='rekey_freq', value='4294967295', typ='hidden')) + + x.addChild(node=xmpp.DataField(name='sas_algs', value='sas28x5', typ='hidden')) + x.addChild(node=xmpp.DataField(name='sign_algs', value='http://www.w3.org/2000/09/xmldsig#rsa-sha256', typ='hidden')) + + self.n_s = self.generate_nonce() + + x.addChild(node=xmpp.DataField(name='my_nonce', value=base64.b64encode(self.n_s), typ='hidden')) + + modp_options = [ 5, 14, 2, 1 ] + + x.addChild(node=xmpp.DataField(name='modp', typ='list-single', options=map(lambda x: [ None, x ], modp_options))) + + x.addChild(node=self.make_dhfield(modp_options, sigmai)) + self.sigmai = sigmai + + self.form_s = ''.join(map(lambda el: xmpp.c14n.c14n(el), x.getChildren())) + + feature.addChild(node=x) + + self.status = 'requested-e2e' + + self.send(request) + + # 4.3 esession response (bob) + def verify_options_bob(self, form): + negotiated = {'recv_pubkey': None, 'send_pubkey': None} + not_acceptable = [] + ask_user = {} + + fixed = { 'disclosure': 'never', + 'security': 'e2e', + 'crypt_algs': 'aes128-ctr', + 'hash_algs': 'sha256', + 'compress': 'none', + 'stanzas': 'message', + 'init_pubkey': 'none', + 'resp_pubkey': 'none', + 'ver': '1.0', + 'sas_algs': 'sas28x5' } + + self.encryptable_stanzas = ['message'] + + self.sas_algs = 'sas28x5' + self.cipher = AES + self.hash_alg = SHA256 + self.compression = None + + for name, field in map(lambda name: (name, form.getField(name)), form.asDict().keys()): + options = map(lambda x: x[1], field.getOptions()) + values = field.getValues() + + if not field.getType() in ('list-single', 'list-multi'): + options = values + + if name in fixed: + if fixed[name] in options: + negotiated[name] = fixed[name] + else: + not_acceptable.append(name) + elif name == 'rekey_freq': + preferred = int(options[0]) + negotiated['rekey_freq'] = preferred + self.rekey_freq = preferred + elif name == 'logging': + my_prefs = self.logging_preference() + + if my_prefs[0] in options: # our first choice is offered, select it + pref = my_prefs[0] + negotiated['logging'] = pref + else: # see if other acceptable choices are offered + for pref in my_prefs: + if pref in options: + ask_user['logging'] = pref + break + + if not 'logging' in ask_user: + not_acceptable.append(name) + elif name == 'init_pubkey': + for x in ('key'): + if x in options: + negotiated['recv_pubkey'] = x + break + elif name == 'resp_pubkey': + for x in ('hash', 'key'): + if x in options: + negotiated['send_pubkey'] = x + break + elif name == 'sign_algs': + if (XmlDsig + 'rsa-sha256') in options: + negotiated['sign_algs'] = XmlDsig + 'rsa-sha256' + else: + # XXX some things are handled elsewhere, some things are not-implemented + pass + + return (negotiated, not_acceptable, ask_user) + + # 4.3 esession response (bob) + def respond_e2e_bob(self, form, negotiated, not_acceptable): + response = xmpp.Message() + feature = response.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + x = xmpp.DataForm(typ='submit') + + x.addChild(node=xmpp.DataField(name='FORM_TYPE', value='urn:xmpp:ssn')) + x.addChild(node=xmpp.DataField(name='accept', value='true')) + + for name in negotiated: + # some fields are internal and should not be sent + if not name in ('send_pubkey', 'recv_pubkey'): + x.addChild(node=xmpp.DataField(name=name, value=negotiated[name])) + + self.negotiated = negotiated + + # the offset of the group we chose (need it to match up with the dhhash) + group_order = 0 + self.modp = int(form.getField('modp').getOptions()[group_order][1]) + x.addChild(node=xmpp.DataField(name='modp', value=self.modp)) + + g = dh.generators[self.modp] + p = dh.primes[self.modp] + + self.n_o = base64.b64decode(form['my_nonce']) + + dhhashes = form.getField('dhhashes').getValues() + self.negotiated['He'] = base64.b64decode(dhhashes[group_order].encode("utf8")) + + bytes = int(self.n / 8) + + self.n_s = self.generate_nonce() + + self.c_o = self.decode_mpi(self.random_bytes(bytes)) # n-bit random number + self.c_s = self.c_o ^ (2 ** (self.n - 1)) + + self.y = self.srand(2 ** (2 * self.n - 1), p - 1) + self.d = self.powmod(g, self.y, p) + + to_add = { 'my_nonce': self.n_s, + 'dhkeys': self.encode_mpi(self.d), + 'counter': self.encode_mpi(self.c_o), + 'nonce': self.n_o } + + for name in to_add: + b64ed = base64.b64encode(to_add[name]) + x.addChild(node=xmpp.DataField(name=name, value=b64ed)) + + self.form_o = ''.join(map(lambda el: xmpp.c14n.c14n(el), form.getChildren())) + self.form_s = ''.join(map(lambda el: xmpp.c14n.c14n(el), x.getChildren())) + + self.status = 'responded-e2e' + + feature.addChild(node=x) + + if not_acceptable: + response = xmpp.Error(response, xmpp.ERR_NOT_ACCEPTABLE) + + feature = xmpp.Node(xmpp.NS_FEATURE + ' feature') + + for f in not_acceptable: + n = xmpp.Node('field') + n['var'] = f + feature.addChild(node=n) + + response.T.error.addChild(node=feature) + + self.send(response) + + # 'Alice Accepts' + def verify_options_alice(self, form): + negotiated = {} + ask_user = {} + not_acceptable = [] + + if not form['logging'] in self.logging_preference(): + not_acceptable.append(form['logging']) + elif form['logging'] != self.logging_preference()[0]: + ask_user['logging'] = form['logging'] + else: + negotiated['logging'] = self.logging_preference()[0] + + for r,a in (('recv_pubkey', 'resp_pubkey'), ('send_pubkey', 'init_pubkey')): + negotiated[r] = None + + if a in form.asDict() and form[a] in ('key', 'hash'): + negotiated[r] = form[a] + + if 'sign_algs' in form.asDict(): + if form['sign_algs'] in (XmlDsig + 'rsa-sha256',): + negotiated['sign_algs'] = form['sign_algs'] + else: + not_acceptable.append(form['sign_algs']) + + return (negotiated, not_acceptable, ask_user) + + # 'Alice Accepts', continued + def accept_e2e_alice(self, form, negotiated): + self.encryptable_stanzas = ['message'] + self.sas_algs = 'sas28x5' + self.cipher = AES + self.hash_alg = SHA256 + self.compression = None + + self.negotiated = negotiated + + accept = xmpp.Message() + feature = accept.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + result = xmpp.DataForm(typ='result') + + self.c_s = self.decode_mpi(base64.b64decode(form['counter'])) + self.c_o = self.c_s ^ (2 ** (self.n - 1)) + + self.n_o = base64.b64decode(form['my_nonce']) + + mod_p = int(form['modp']) + p = dh.primes[mod_p] + x = self.xes[mod_p] + e = self.es[mod_p] + + self.d = self.decode_mpi(base64.b64decode(form['dhkeys'])) + + self.k = self.get_shared_secret(self.d, x, p) + + result.addChild(node=xmpp.DataField(name='FORM_TYPE', value='urn:xmpp:ssn')) + result.addChild(node=xmpp.DataField(name='accept', value='1')) + result.addChild(node=xmpp.DataField(name='nonce', value=base64.b64encode(self.n_o))) + + self.kc_s, self.km_s, self.ks_s = self.generate_initiator_keys(self.k) + + if self.sigmai: + self.kc_o, self.km_o, self.ks_o = self.generate_responder_keys(self.k) + self.verify_identity(form, self.d, True, 'b') + else: + secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped()) + rshashes = [self.hmac(self.n_s, rs) for rs in secrets] + + # XXX add some random fake rshashes here + rshashes.sort() + + rshashes = [base64.b64encode(rshash) for rshash in rshashes] + result.addChild(node=xmpp.DataField(name='rshashes', value=rshashes)) + result.addChild(node=xmpp.DataField(name='dhkeys', value=base64.b64encode(self.encode_mpi(e)))) + + self.form_o = ''.join(map(lambda el: xmpp.c14n.c14n(el), form.getChildren())) + + + # MUST securely destroy K unless it will be used later to generate the final shared secret + + for datafield in self.make_identity(result, e): + result.addChild(node=datafield) + + feature.addChild(node=result) + self.send(accept) + + if self.sigmai: + self.status = 'active' + self.enable_encryption = True + else: + self.status = 'identified-alice' + + # 4.5 esession accept (bob) + def accept_e2e_bob(self, form): + response = xmpp.Message() + + init = response.NT.init + init.setNamespace(xmpp.NS_ESESSION_INIT) + + x = xmpp.DataForm(typ='result') + + for field in ('nonce', 'dhkeys', 'rshashes', 'identity', 'mac'): + assert field in form.asDict(), "alice's form didn't have a %s field" % field + + # 4.5.1 generating provisory session keys + e = self.decode_mpi(base64.b64decode(form['dhkeys'])) + p = dh.primes[self.modp] + + if self.sha256(self.encode_mpi(e)) != self.negotiated['He']: + raise exceptions.NegotiationError, 'SHA256(e) != He' + + k = self.get_shared_secret(e, self.y, p) + + self.kc_o, self.km_o, self.ks_o = self.generate_initiator_keys(k) + + # 4.5.2 verifying alice's identity + + self.verify_identity(form, e, False, 'a') + + # 4.5.4 generating bob's final session keys + + srs = '' + + secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped()) + rshashes = [base64.b64decode(rshash) for rshash in form.getField('rshashes').getValues()] + + for secret in secrets: + if self.hmac(self.n_o, secret) in rshashes: + srs = secret + break + + # other shared secret, we haven't got one. + oss = '' + + k = self.sha256(k + srs + oss) + + self.kc_s, self.km_s, self.ks_s = self.generate_responder_keys(k) + self.kc_o, self.km_o, self.ks_o = self.generate_initiator_keys(k) + + # 4.5.5 + if srs: + srshash = self.hmac(srs, 'Shared Retained Secret') + else: + srshash = self.random_bytes(32) + + x.addChild(node=xmpp.DataField(name='FORM_TYPE', value='urn:xmpp:ssn')) + x.addChild(node=xmpp.DataField(name='nonce', value=base64.b64encode(self.n_o))) + x.addChild(node=xmpp.DataField(name='srshash', value=base64.b64encode(srshash))) + + for datafield in self.make_identity(x, self.d): + x.addChild(node=datafield) + + init.addChild(node=x) + + self.send(response) + + self.do_retained_secret(k, srs) + + if self.negotiated['logging'] == 'mustnot': + self.loggable = False + + self.status = 'active' + self.enable_encryption = True + + def final_steps_alice(self, form): + srs = '' + secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped()) + + srshash = base64.b64decode(form['srshash']) + + for secret in secrets: + if self.hmac(secret, 'Shared Retained Secret') == srshash: + srs = secret + break + + oss = '' + k = self.sha256(self.k + srs + oss) + del self.k + + self.do_retained_secret(k, srs) + + # don't need to calculate ks_s here + + self.kc_s, self.km_s, self.ks_s = self.generate_initiator_keys(k) + self.kc_o, self.km_o, self.ks_o = self.generate_responder_keys(k) + + # 4.6.2 Verifying Bob's Identity + + self.verify_identity(form, self.d, False, 'b') +# Note: If Alice discovers an error then she SHOULD ignore any encrypted content she received in the stanza. + + if self.negotiated['logging'] == 'mustnot': + self.loggable = False + + self.status = 'active' + self.enable_encryption = True + + # calculate and store the new retained secret + # prompt the user to check the remote party's identity (if necessary) + def do_retained_secret(self, k, srs): + new_srs = self.hmac(k, 'New Retained Secret') + account = self.conn.name + bjid = self.jid.getStripped() + + if srs: + gajim.interface.replace_secret(account, bjid, srs, new_srs) + else: + self.check_identity() + gajim.interface.save_new_secret(account, bjid, new_srs) + + # generate a random number between 'bottom' and 'top' + def srand(self, bottom, top): + # minimum number of bytes needed to represent that range + bytes = int(math.ceil(math.log(top - bottom, 256))) + + # in retrospect, this is horribly inadequate. + return (self.decode_mpi(self.random_bytes(bytes)) % (top - bottom)) + bottom + + def make_dhfield(self, modp_options, sigmai): + dhs = [] + + for modp in modp_options: + p = dh.primes[modp] + g = dh.generators[modp] + + x = self.srand(2 ** (2 * self.n - 1), p - 1) + + # XXX this may be a source of performance issues + e = self.powmod(g, x, p) + + self.xes[modp] = x + self.es[modp] = e + + if sigmai: + dhs.append(base64.b64encode(self.encode_mpi(e))) + name = 'dhkeys' + else: + He = self.sha256(self.encode_mpi(e)) + dhs.append(base64.b64encode(He)) + name = 'dhhashes' + + return xmpp.DataField(name=name, typ='hidden', value=dhs) + + # a faster version of (base ** exp) % mod + # taken from + def powmod(self, base, exp, mod): + square = base % mod + result = 1 + + while exp > 0: + if exp & 1: # exponent is odd + result = (result * square) % mod + + square = (square * square) % mod + exp /= 2 + + return result + + def terminate_e2e(self): + self.terminate() + + self.enable_encryption = False + + def acknowledge_termination(self): + StanzaSession.acknowledge_termination(self) + + self.enable_encryption = False + + def fail_bad_negotiation(self, reason): + '''they've tried to feed us a bogus value, send an error and cancel everything.''' + + err = xmpp.Error(xmpp.Message(), xmpp.ERR_FEATURE_NOT_IMPLEMENTED) + err.T.error.T.text.setData(reason) + self.send(err) + + self.status = None + self.enable_encryption = False + + # this prevents the MAC check on decryption from succeeding, + # preventing falsified messages from going through. + self.km_o = '' + + def is_loggable(self): + account = self.conn.name + no_log_for = gajim.config.get_per('accounts', account, 'no_log_for') + + if not no_log_for: + no_log_for = '' + + no_log_for = no_log_for.split() + + return self.loggable and account not in no_log_for and self.jid not in no_log_for diff --git a/src/common/xmpp/c14n.py b/src/common/xmpp/c14n.py new file mode 100644 index 000000000..b58e0a1d6 --- /dev/null +++ b/src/common/xmpp/c14n.py @@ -0,0 +1,36 @@ +from simplexml import ustr + +# XML canonicalisation methods (for XEP-0116) +def c14n(node): + s = "<" + node.name + if node.namespace: + if not node.parent or node.parent.namespace != node.namespace: + s = s + ' xmlns="%s"' % node.namespace + + sorted_attrs = node.attrs.keys() + sorted_attrs.sort() + for key in sorted_attrs: + val = ustr(node.attrs[key]) + # like XMLescape() but with whitespace and without > + s = s + ' %s="%s"' % ( key, normalise_attr(val) ) + s = s + ">" + cnt = 0 + if node.kids: + for a in node.kids: + if (len(node.data)-1) >= cnt: + s = s + normalise_text(node.data[cnt]) + s = s + c14n(a) + cnt=cnt+1 + if (len(node.data)-1) >= cnt: s = s + normalise_text(node.data[cnt]) + if not node.kids and s[-1:]=='>': + s=s[:-1]+' />' + else: + s = s + "" + return s.encode('utf-8') + +def normalise_attr(val): + return val.replace('&', '&').replace('<', '<').replace('"', '"').replace('\t', ' ').replace('\n', ' ').replace('\r', ' ') + +def normalise_text(val): + return val.replace('&', '&').replace('<', '<').replace('>', '>').replace('\r', ' ') + diff --git a/src/common/xmpp/protocol.py b/src/common/xmpp/protocol.py index 1c95b5dd7..2edb5be83 100644 --- a/src/common/xmpp/protocol.py +++ b/src/common/xmpp/protocol.py @@ -45,6 +45,7 @@ NS_DISCO ='http://jabber.org/protocol/disco' NS_DISCO_INFO =NS_DISCO+'#info' NS_DISCO_ITEMS =NS_DISCO+'#items' NS_ENCRYPTED ='jabber:x:encrypted' # XEP-0027 +NS_ESESSION_INIT='http://www.xmpp.org/extensions/xep-0116.html#ns-init' # XEP-0116 NS_EVENT ='jabber:x:event' # XEP-0022 NS_FEATURE ='http://jabber.org/protocol/feature-neg' NS_FILE ='http://jabber.org/protocol/si/profile/file-transfer' # XEP-0096 @@ -82,6 +83,7 @@ NS_SESSION ='urn:ietf:params:xml:ns:xmpp-session' NS_SI ='http://jabber.org/protocol/si' # XEP-0096 NS_SI_PUB ='http://jabber.org/protocol/sipub' # XEP-0137 NS_SIGNED ='jabber:x:signed' # XEP-0027 +NS_STANZA_CRYPTO='http://www.xmpp.org/extensions/xep-0200.html#ns' # JEP-0200 NS_STANZAS ='urn:ietf:params:xml:ns:xmpp-stanzas' NS_STREAM ='http://affinix.com/jabber/stream' NS_STREAMS ='http://etherx.jabber.org/streams' diff --git a/src/common/zeroconf/connection_handlers_zeroconf.py b/src/common/zeroconf/connection_handlers_zeroconf.py index eaa2f6a17..72e14dbaf 100644 --- a/src/common/zeroconf/connection_handlers_zeroconf.py +++ b/src/common/zeroconf/connection_handlers_zeroconf.py @@ -639,6 +639,7 @@ class ConnectionHandlersZeroconf(ConnectionVcard, ConnectionBytestream): msghtml = msg.getXHTML() mtype = msg.getType() subject = msg.getSubject() # if not there, it's None + thread = msg.getThread() tim = msg.getTimestamp() tim = helpers.datetime_tuple(tim) tim = time.localtime(timegm(tim)) @@ -715,7 +716,7 @@ class ConnectionHandlersZeroconf(ConnectionVcard, ConnectionBytestream): msg_id = gajim.logger.write('chat_msg_recv', frm, msgtxt, tim = tim, subject = subject) self.dispatch('MSG', (frm, msgtxt, tim, encrypted, mtype, subject, - chatstate, msg_id, composing_xep, user_nick, msghtml)) + chatstate, msg_id, composing_jep, user_nick, msghtml, thread)) elif mtype == 'normal': # it's single message if self.name not in no_log_for and jid not in no_log_for and msgtxt: gajim.logger.write('single_msg_recv', frm, msgtxt, tim = tim, diff --git a/src/dialogs.py b/src/dialogs.py index bc6202849..f67bf63d5 100644 --- a/src/dialogs.py +++ b/src/dialogs.py @@ -199,7 +199,7 @@ class EditGroupsDialog: for group in groups: if group not in helpers.special_groups or groups[group] > 0: group_list.append(group) - group_list.sort() + group_list.sort() for group in group_list: iter = store.append() store.set(iter, 0, group) # Group name @@ -1427,7 +1427,7 @@ class SynchroniseSelectAccountDialog: if not iter: return remote_account = model.get_value(iter, 0).decode('utf-8') - + if gajim.connections[remote_account].connected < 2: ErrorDialog(_('This account is not connected to the server'), _('You cannot synchronize with an account unless it is connected.')) @@ -1709,7 +1709,7 @@ class SingleMessageWindow: or 'receive'. ''' def __init__(self, account, to = '', action = '', from_whom = '', - subject = '', message = '', resource = ''): + subject = '', message = '', resource = '', session = None): self.account = account self.action = action @@ -1718,6 +1718,7 @@ class SingleMessageWindow: self.to = to self.from_whom = from_whom self.resource = resource + self.session = session self.xml = gtkgui_helpers.get_glade('single_message_window.glade') self.window = self.xml.get_widget('single_message_window') @@ -1908,7 +1909,7 @@ class SingleMessageWindow: # FIXME: allow GPG message some day gajim.connections[self.account].send_message(to_whom_jid, message, - keyID = None, type = 'normal', subject=subject) + keyID = None, type = 'normal', subject=subject, session = self.session) self.subject_entry.set_text('') # we sent ok, clear the subject self.message_tv_buffer.set_text('') # we sent ok, clear the textview @@ -1925,7 +1926,7 @@ class SingleMessageWindow: self.window.destroy() SingleMessageWindow(self.account, to = self.from_whom, action = 'send', from_whom = self.from_whom, subject = self.subject, - message = self.message) + message = self.message, session = self.session) def on_send_and_close_button_clicked(self, widget): self.send_single_message() @@ -2111,7 +2112,6 @@ class PrivacyListWindow: jid_entry_completion.set_model(jids_list_store) jid_entry_completion.set_popup_completion(True) self.edit_type_jabberid_entry.set_completion(jid_entry_completion) - if action == 'EDIT': self.refresh_rules() diff --git a/src/gajim.py b/src/gajim.py index 135828aa9..2b3f852f9 100755 --- a/src/gajim.py +++ b/src/gajim.py @@ -117,12 +117,17 @@ import message_control from chat_control import ChatControlBase from atom_window import AtomWindow +import negotiation +import Crypto.PublicKey.RSA + from common import exceptions from common.zeroconf import connection_zeroconf from common import dbus_support if dbus_support.supported: import dbus +import pickle + if os.name == 'posix': # dl module is Unix Only try: # rename the process name to gajim import dl @@ -217,6 +222,7 @@ gajimpaths = common.configpaths.gajimpaths pid_filename = gajimpaths['PID_FILE'] config_filename = gajimpaths['CONFIG_FILE'] +secrets_filename = gajimpaths['SECRETS_FILE'] import traceback import errno @@ -686,11 +692,11 @@ class Interface: # It's maybe a GC_NOTIFY (specialy for MSN gc) self.handle_event_gc_notify(account, (jid, array[1], status_message, array[3], None, None, None, None, None, None, None, None)) - + def handle_event_msg(self, account, array): # 'MSG' (account, (jid, msg, time, encrypted, msg_type, subject, - # chatstate, msg_id, composing_xep, user_nick, xhtml)) + # chatstate, msg_id, composing_xep, user_nick, xhtml, session)) # user_nick is JEP-0172 full_jid_with_resource = array[0] @@ -705,6 +711,7 @@ class Interface: msg_id = array[7] composing_xep = array[8] xhtml = array[10] + session = array[11] if gajim.config.get('ignore_incoming_xhtml'): xhtml = None if gajim.jid_is_transport(jid): @@ -791,20 +798,20 @@ class Interface: if pm: nickname = resource groupchat_control.on_private_message(nickname, message, array[2], - xhtml, msg_id) + xhtml, session, msg_id) else: # array: (jid, msg, time, encrypted, msg_type, subject) if encrypted: self.roster.on_message(jid, message, array[2], account, array[3], msg_type, subject, resource, msg_id, array[9], - advanced_notif_num) + advanced_notif_num, session = session) else: # xhtml in last element self.roster.on_message(jid, message, array[2], account, array[3], msg_type, subject, resource, msg_id, array[9], - advanced_notif_num, xhtml = xhtml) + advanced_notif_num, xhtml = xhtml, session = session) nickname = gajim.get_name_from_jid(account, jid) - # Check and do wanted notifications + # Check and do wanted notifications msg = message if subject: msg = _('Subject: %s') % subject + '\n' + msg @@ -1501,6 +1508,48 @@ class Interface: if os.path.isfile(path_to_original_file): os.remove(path_to_original_file) + # list the retained secrets we have for a local account and a remote jid + def list_secrets(self, account, jid): + f = open(secrets_filename) + + try: + s = pickle.load(f)[account][jid] + except KeyError: + s = [] + + f.close() + return s + + # save a new retained secret + def save_new_secret(self, account, jid, secret): + f = open(secrets_filename, 'r') + secrets = pickle.load(f) + f.close() + + if not account in secrets: + secrets[account] = {} + + if not jid in secrets[account]: + secrets[account][jid] = [] + + secrets[account][jid].append(secret) + + f = open(secrets_filename, 'w') + pickle.dump(secrets, f) + f.close() + + def replace_secret(self, account, jid, old_secret, new_secret): + f = open(secrets_filename, 'r') + secrets = pickle.load(f) + f.close() + + this_secrets = secrets[account][jid] + this_secrets[this_secrets.index(old_secret)] = new_secret + + f = open(secrets_filename, 'w') + pickle.dump(secrets, f) + f.close() + def add_event(self, account, jid, type_, event_args): '''add an event to the gajim.events var''' # We add it to the gajim.events queue @@ -1768,6 +1817,162 @@ class Interface: atom_entry, = data AtomWindow.newAtomEntry(atom_entry) + def handle_event_failed_decrypt(self, account, data): + jid, tim = data + + ctrl = self.msg_win_mgr.get_control(jid, account) + if ctrl: + ctrl.print_conversation_line('Unable to decrypt message from %s\nIt may have been tampered with.' % (jid), 'status', '', tim) + else: + print 'failed decrypt, unable to find a control to notify you in.' + + def handle_session_negotiation(self, account, data): + jid, session, form = data + + if form.getField('accept') and not form['accept'] in ('1', 'true'): + dialogs.InformationDialog(_('Session negotiation cancelled'), + _('The client at %s cancelled the session negotiation.') % (jid)) + session.cancelled_negotiation() + return + + # encrypted session states. these are described in stanza_session.py + + # bob responds + if form.getType() == 'form' and 'security' in form.asDict(): + def continue_with_negotiation(*args): + if len(args): + self.dialog.destroy() + + # we don't support 3-message negotiation as the responder + if 'dhkeys' in form.asDict(): + err = xmpp.Error(xmpp.Message(), xmpp.ERR_FEATURE_NOT_IMPLEMENTED) + + feature = xmpp.Node(xmpp.NS_FEATURE + ' feature') + field = xmpp.Node('field') + field['var'] = 'dhkeys' + + feature.addChild(node=field) + err.addChild(node=feature) + + session.send(err) + return + + negotiated, not_acceptable, ask_user = session.verify_options_bob(form) + + if ask_user: + def accept_nondefault_options(widget): + self.dialog.destroy() + negotiated.update(ask_user) + session.respond_e2e_bob(form, negotiated, not_acceptable) + + def reject_nondefault_options(widget): + self.dialog.destroy() + for key in ask_user.keys(): + not_acceptable.append(key) + session.respond_e2e_bob(form, negotiated, not_acceptable) + + self.dialog = dialogs.YesNoDialog(_('Confirm these session options'), + _('''The remote client wants to negotiate an session with these features: + + %s + + Are these options acceptable?''') % (negotiation.describe_features(ask_user)), + on_response_yes = accept_nondefault_options, + on_response_no = reject_nondefault_options) + else: + session.respond_e2e_bob(form, negotiated, not_acceptable) + + def ignore_negotiation(widget): + self.dialog.destroy() + return + + continue_with_negotiation() + + return + + # alice accepts + elif session.status == 'requested-e2e' and form.getType() == 'submit': + negotiated, not_acceptable, ask_user = session.verify_options_alice(form) + + if session.sigmai: + session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas) + + if ask_user: + def accept_nondefault_options(widget): + dialog.destroy() + + negotiated.update(ask_user) + + try: + session.accept_e2e_alice(form, negotiated) + except exceptions.NegotiationError, details: + session.fail_bad_negotiation(details) + + def reject_nondefault_options(widget): + session.reject_negotiation() + dialog.destroy() + + dialog = dialogs.YesNoDialog(_('Confirm these session options'), + _('The remote client selected these options:\n\n%s\n\nContinue with the session?') % (negotiation.describe_features(ask_user)), + on_response_yes = accept_nondefault_options, + on_response_no = reject_nondefault_options) + else: + try: + session.accept_e2e_alice(form, negotiated) + except exceptions.NegotiationError, details: + session.fail_bad_negotiation(details) + + return + elif session.status == 'responded-e2e' and form.getType() == 'result': + session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas) + + try: + session.accept_e2e_bob(form) + except exceptions.NegotiationError, details: + session.fail_bad_negotiation(details) + + return + elif session.status == 'identified-alice' and form.getType() == 'result': + session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas) + + try: + session.final_steps_alice(form) + except exceptions.NegotiationError, details: + session.fail_bad_negotiation(details) + + return + + if form.getField('terminate'): + if form.getField('terminate').getValue() in ('1', 'true'): + session.acknowledge_termination() + + gajim.connections[account].delete_session(str(jid), session.thread_id) + + ctrl = gajim.interface.msg_win_mgr.get_control(str(jid), account) + + if ctrl: + ctrl.session = gajim.connections[account].make_new_session(str(jid)) + + return + + # non-esession negotiation. this isn't very useful, but i'm keeping it around + # to test my test suite. + if form.getType() == 'form': + ctrl = gajim.interface.msg_win_mgr.get_control(str(jid), account) + if not ctrl: + resource = jid.getResource() + contact = gajim.contacts.get_contact(account, str(jid), resource) + if not contact: + connection = gajim.connections[account] + contact = gajim.contacts.create_contact(jid = jid.getStripped(), resource = resource, show = connection.get_status()) + self.roster.new_chat(contact, account, resource = resource) + + ctrl = gajim.interface.msg_win_mgr.get_control(str(jid), account) + + ctrl.set_session(session) + + negotiation.FeatureNegotiationWindow(account, jid, session, form) + def handle_event_privacy_lists_received(self, account, data): # ('PRIVACY_LISTS_RECEIVED', account, list) if not self.instances.has_key(account): @@ -2208,6 +2413,7 @@ class Interface: 'SIGNED_IN': self.handle_event_signed_in, 'METACONTACTS': self.handle_event_metacontacts, 'ATOM_ENTRY': self.handle_atom_entry, + 'FAILED_DECRYPT': self.handle_event_failed_decrypt, 'PRIVACY_LISTS_RECEIVED': self.handle_event_privacy_lists_received, 'PRIVACY_LIST_RECEIVED': self.handle_event_privacy_list_received, 'PRIVACY_LISTS_ACTIVE_DEFAULT': \ @@ -2223,6 +2429,7 @@ class Interface: 'UNIQUE_ROOM_ID_UNSUPPORTED': \ self.handle_event_unique_room_id_unsupported, 'UNIQUE_ROOM_ID_SUPPORTED': self.handle_event_unique_room_id_supported, + 'SESSION_NEG': self.handle_session_negotiation, } gajim.handlers = self.handlers @@ -2545,6 +2752,10 @@ class Interface: gobject.timeout_add(2000, self.process_connections) gobject.timeout_add(10000, self.read_sleepy) + # public key for XEP-0116 + # XXX os.urandom is not a cryptographic PRNG + self.pubkey = Crypto.PublicKey.RSA.generate(384, os.urandom) + if __name__ == '__main__': def sigint_cb(num, stack): sys.exit(5) @@ -2583,5 +2794,11 @@ if __name__ == '__main__': check_paths.check_and_possibly_create_paths() + # create secrets file (unless it exists) + if not os.path.exists(secrets_filename): + f = open(secrets_filename, 'w') + pickle.dump({}, f) + f.close() + Interface() gtk.main() diff --git a/src/groupchat_control.py b/src/groupchat_control.py index a5beb6098..1d69d3169 100644 --- a/src/groupchat_control.py +++ b/src/groupchat_control.py @@ -105,14 +105,14 @@ def tree_cell_data_func(column, renderer, model, iter, tv=None): class PrivateChatControl(ChatControl): TYPE_ID = message_control.TYPE_PM - def __init__(self, parent_win, gc_contact, contact, account): + def __init__(self, parent_win, gc_contact, contact, account, session): room_jid = contact.jid.split('/')[0] room_ctrl = gajim.interface.msg_win_mgr.get_control(room_jid, account) if gajim.interface.minimized_controls[account].has_key(room_jid): room_ctrl = gajim.interface.minimized_controls[account][room_jid] self.room_name = room_ctrl.name self.gc_contact = gc_contact - ChatControl.__init__(self, parent_win, contact, account) + ChatControl.__init__(self, parent_win, contact, account, session) self.TYPE_ID = 'pm' def send_message(self, message): @@ -544,7 +544,7 @@ class GroupchatControl(ChatControlBase): else: self.print_conversation(msg, nick, tim, xhtml) - def on_private_message(self, nick, msg, tim, xhtml, msg_id = None): + def on_private_message(self, nick, msg, tim, xhtml, session, msg_id = None): # Do we have a queue? fjid = self.room_jid + '/' + nick no_queue = len(gajim.events.get_events(self.account, fjid)) == 0 @@ -556,7 +556,7 @@ class GroupchatControl(ChatControlBase): return event = gajim.events.create_event('pm', (msg, '', 'incoming', tim, - False, '', msg_id, xhtml)) + False, '', msg_id, xhtml, session)) gajim.events.add_event(self.account, fjid, event) autopopup = gajim.config.get('autopopup') @@ -576,7 +576,7 @@ class GroupchatControl(ChatControlBase): self.parent_win.show_title() self.parent_win.redraw_tab(self) else: - self._start_private_message(nick) + self._start_private_message(nick, session) # Scroll to line self.list_treeview.expand_row(path[0:1], False) self.list_treeview.scroll_to_cell(path) @@ -1942,7 +1942,7 @@ class GroupchatControl(ChatControlBase): menu.show_all() menu.popup(None, None, None, event.button, event.time) - def _start_private_message(self, nick): + def _start_private_message(self, nick, session = None): gc_c = gajim.contacts.get_gc_contact(self.account, self.room_jid, nick) nick_jid = gc_c.get_full_jid() diff --git a/src/message_control.py b/src/message_control.py index 1bc2ac0dd..fe575a9d8 100644 --- a/src/message_control.py +++ b/src/message_control.py @@ -110,14 +110,28 @@ class MessageControl: def get_specific_unread(self): return len(gajim.events.get_events(self.account, self.contact.jid)) + def set_session(self, session): + if session == self.session: + return + + if self.session: + print "starting a new session, dropping the old one!" + gajim.connections[self.account].delete_session(self.contact.get_full_jid(), self.session.thread_id) + + self.session = session + def send_message(self, message, keyID = '', type = 'chat', chatstate = None, msg_id = None, composing_xep = None, resource = None, user_nick = None): '''Send the given message to the active tab. Doesn't return None if error ''' jid = self.contact.jid + + if not self.session: + self.session = gajim.connections[self.account].make_new_session(self.contact.get_full_jid()) + # Send and update history return gajim.connections[self.account].send_message(jid, message, keyID, type = type, chatstate = chatstate, msg_id = msg_id, composing_xep = composing_xep, resource = self.resource, - user_nick = user_nick) + user_nick = user_nick, session = self.session) diff --git a/src/negotiation.py b/src/negotiation.py new file mode 100644 index 000000000..73917400c --- /dev/null +++ b/src/negotiation.py @@ -0,0 +1,75 @@ +import gtkgui_helpers +import dataforms_widget + +import dialogs + +from common import dataforms +from common import gajim +from common import xmpp + +def describe_features(features): + '''a human-readable description of the features that have been negotiated''' + if features['logging'] == 'may': + return _('- messages will be logged') + elif features['logging'] == 'mustnot': + return _('- messages will not be logged') + +def show_sas_dialog(jid, sas): + dialogs.InformationDialog(_('''Verify the remote client's identity'''), _('''You've begun an encrypted session with %s, but it can't be guaranteed that you're talking directly to the person you think you are. + +You should speak with them directly (in person or on the phone) and confirm that their Short Authentication String is identical to this one: %s''') % (jid, sas)) + +class FeatureNegotiationWindow: + '''FeatureNegotiotionWindow class''' + def __init__(self, account, jid, session, form): + self.account = account + self.jid = jid + self.form = form + self.session = session + + self.xml = gtkgui_helpers.get_glade('data_form_window.glade', 'data_form_window') + self.window = self.xml.get_widget('data_form_window') + + config_vbox = self.xml.get_widget('config_vbox') + dataform = dataforms.ExtendForm(node = self.form) + self.data_form_widget = dataforms_widget.DataFormWidget(dataform) + self.data_form_widget.show() + config_vbox.pack_start(self.data_form_widget) + + self.xml.signal_autoconnect(self) + self.window.show_all() + + def on_ok_button_clicked(self, widget): + acceptance = xmpp.Message(self.jid) + acceptance.setThread(self.session.thread_id) + feature = acceptance.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + form = self.data_form_widget.data_form + form.setAttr('type', 'submit') + + feature.addChild(node=form) + + gajim.connections[self.account].send_stanza(acceptance) + + self.window.destroy() + + def on_cancel_button_clicked(self, widget): + # XXX determine whether to reveal presence + + rejection = xmpp.Message(self.jid) + rejection.setThread(self.session.thread_id) + feature = rejection.NT.feature + feature.setNamespace(xmpp.NS_FEATURE) + + x = xmpp.DataForm(typ='submit') + x.addChild(node=xmpp.DataField('FORM_TYPE', value='urn:xmpp:ssn')) + x.addChild(node=xmpp.DataField('accept', value='false', typ='boolean')) + + feature.addChild(node=x) + + # XXX optional + + gajim.connections[self.account].send_stanza(rejection) + + self.window.destroy() diff --git a/src/roster_window.py b/src/roster_window.py index a310f2518..b3ad379f7 100644 --- a/src/roster_window.py +++ b/src/roster_window.py @@ -1230,10 +1230,15 @@ class RosterWindow: '''reads from db the unread messages, and fire them up''' for jid in gajim.contacts.get_jid_list(account): results = gajim.logger.get_unread_msgs_for_jid(jid) + + # XXX unread messages should probably have their session saved with them + if results: + session = gajim.connections[account].make_new_session(jid) + for result in results: tim = time.localtime(float(result[2])) self.on_message(jid, result[1], tim, account, msg_type = 'chat', - msg_id = result[0]) + msg_id = result[0], session = session) def fill_contacts_and_groups_dicts(self, array, account): '''fill gajim.contacts and gajim.groups''' @@ -3758,7 +3763,7 @@ class RosterWindow: self.actions_menu_needs_rebuild = True self.update_status_combobox() - def new_private_chat(self, gc_contact, account): + def new_private_chat(self, gc_contact, account, session = None): contact = gajim.contacts.contact_from_gc_contact(gc_contact) type_ = message_control.TYPE_PM fjid = gc_contact.room_jid + '/' + gc_contact.name @@ -3766,24 +3771,25 @@ class RosterWindow: if not mw: mw = gajim.interface.msg_win_mgr.create_window(contact, account, type_) - chat_control = PrivateChatControl(mw, gc_contact, contact, account) + chat_control = PrivateChatControl(mw, gc_contact, contact, account, session) mw.new_tab(chat_control) if len(gajim.events.get_events(account, fjid)): # We call this here to avoid race conditions with widget validation chat_control.read_queue() - def new_chat(self, contact, account, resource = None): + def new_chat(self, contact, account, resource = None, session = None): # Get target window, create a control, and associate it with the window type_ = message_control.TYPE_CHAT fjid = contact.jid if resource: fjid += '/' + resource + mw = gajim.interface.msg_win_mgr.get_window(fjid, account) if not mw: mw = gajim.interface.msg_win_mgr.create_window(contact, account, type_) - chat_control = ChatControl(mw, contact, account, resource) + chat_control = ChatControl(mw, contact, account, session, resource) mw.new_tab(chat_control) @@ -3827,7 +3833,7 @@ class RosterWindow: def on_message(self, jid, msg, tim, account, encrypted = False, msg_type = '', subject = None, resource = '', msg_id = None, - user_nick = '', advanced_notif_num = None, xhtml = None): + user_nick = '', advanced_notif_num = None, xhtml = None, session = None): '''when we receive a message''' contact = None # if chat window will be for specific resource @@ -3884,7 +3890,7 @@ class RosterWindow: if msg_type == 'normal' and popup: # it's single message to be autopopuped dialogs.SingleMessageWindow(account, contact.jid, action = 'receive', from_whom = jid, subject = subject, - message = msg, resource = resource) + message = msg, resource = resource, session = session) return # We print if window is opened and it's not a single message @@ -3892,6 +3898,8 @@ class RosterWindow: typ = '' if msg_type == 'error': typ = 'status' + if session: + ctrl.set_session(session) ctrl.print_conversation(msg, typ, tim = tim, encrypted = encrypted, subject = subject, xhtml = xhtml) if msg_id: @@ -3907,7 +3915,7 @@ class RosterWindow: show_in_roster = notify.get_show_in_roster(event_type, account, contact) show_in_systray = notify.get_show_in_systray(event_type, account, contact) event = gajim.events.create_event(type_, (msg, subject, msg_type, tim, - encrypted, resource, msg_id, xhtml), show_in_roster = show_in_roster, + encrypted, resource, msg_id, xhtml, session), show_in_roster = show_in_roster, show_in_systray = show_in_systray) gajim.events.add_event(account, fjid, event) if popup: @@ -4171,7 +4179,7 @@ class RosterWindow: if event.type_ == 'normal': dialogs.SingleMessageWindow(account, jid, action = 'receive', from_whom = jid, subject = data[1], - message = data[0], resource = data[5]) + message = data[0], resource = data[5], session = data[8]) gajim.interface.remove_first_event(account, jid, event.type_) return True elif event.type_ == 'file-request': @@ -4208,14 +4216,14 @@ class RosterWindow: jid = jid + u'/' + resource adhoc_commands.CommandWindow(account, jid) - def on_open_chat_window(self, widget, contact, account, resource = None): + def on_open_chat_window(self, widget, contact, account, resource = None, session = None): # Get the window containing the chat fjid = contact.jid if resource: fjid += '/' + resource win = gajim.interface.msg_win_mgr.get_window(fjid, account) if not win: - self.new_chat(contact, account, resource = resource) + self.new_chat(contact, account, resource = resource, session = session) win = gajim.interface.msg_win_mgr.get_window(fjid, account) ctrl = win.get_control(fjid, account) # last message is long time ago @@ -4262,7 +4270,9 @@ class RosterWindow: jid = child_jid else: child_iter = model.iter_next(child_iter) + session = None if first_ev: + session = first_ev.parameters[8] fjid = jid if resource: fjid += '/' + resource @@ -4273,7 +4283,7 @@ class RosterWindow: c = gajim.contacts.get_contact_with_highest_priority(account, jid) if jid == gajim.get_jid_from_account(account): resource = c.resource - self.on_open_chat_window(widget, c, account, resource = resource) + self.on_open_chat_window(widget, c, account, resource = resource, session = session) def on_roster_treeview_row_activated(self, widget, path, col = 0): '''When an iter is double clicked: open the first event window'''