comparison src/browser/sat_browser/plugin_sec_otr.py @ 679:a90cc8fc9605

merged branch frontends_multi_profiles
author Goffi <goffi@goffi.org>
date Wed, 18 Mar 2015 16:15:18 +0100
parents 2e087e093e7f
children 3b185ccb70b4
comparison
equal deleted inserted replaced
590:1bffc4c244c3 679:a90cc8fc9605
21 """ 21 """
22 This file is adapted from sat.plugins.plugin.sec_otr. It offers browser-side OTR encryption using otr.js. 22 This file is adapted from sat.plugins.plugin.sec_otr. It offers browser-side OTR encryption using otr.js.
23 The text messages to display are mostly taken from the Pidgin OTR plugin (GPL 2.0, see http://otr.cypherpunks.ca). 23 The text messages to display are mostly taken from the Pidgin OTR plugin (GPL 2.0, see http://otr.cypherpunks.ca).
24 """ 24 """
25 25
26 from sat.core.log import getLogger
27 log = getLogger(__name__)
28
26 from sat.core.i18n import _, D_ 29 from sat.core.i18n import _, D_
27 from sat.core.log import getLogger
28 from sat.core import exceptions 30 from sat.core import exceptions
29 log = getLogger(__name__) 31 from sat.tools.misc import TriggerManager
30 32
31 from constants import Const as C 33 from constants import Const as C
32 import jid 34 from sat_frontends.tools import jid
33 import otrjs_wrapper as otr 35 import otrjs_wrapper as otr
34 import dialog 36 import dialog
35 import panels 37 import chat
36 38
37 39
38 NS_OTR = "otr_plugin" 40 NS_OTR = "otr_plugin"
39 PRIVATE_KEY = "PRIVATE KEY" 41 PRIVATE_KEY = "PRIVATE KEY"
40 MAIN_MENU = D_('OTR encryption') 42 MAIN_MENU = D_('OTR') # TODO: get this constant directly from backend's plugin
41 DIALOG_EOL = "<br />" 43 DIALOG_EOL = "<br />"
42 DIALOG_USERS_ML = D_("<a href='mailto:users@salut-a-toi.org?subject={subject}&body=Please give us some hints about how to reproduce the bug (your browser name and version, what you did and what happened)'>users@salut-a-toi.org</a>")
43 44
44 AUTH_TRUSTED = D_("Verified") 45 AUTH_TRUSTED = D_("Verified")
45 AUTH_UNTRUSTED = D_("Unverified") 46 AUTH_UNTRUSTED = D_("Unverified")
46 AUTH_OTHER_TITLE = D_("Authentication of {jid}") 47 AUTH_OTHER_TITLE = D_("Authentication of {jid}")
47 AUTH_US_TITLE = D_("Authentication to {jid}") 48 AUTH_US_TITLE = D_("Authentication to {jid}")
70 QUERY_ENCRYPTED = D_('Attempting to refresh the OTR conversation with {jid}...') 71 QUERY_ENCRYPTED = D_('Attempting to refresh the OTR conversation with {jid}...')
71 QUERY_NOT_ENCRYPTED = D_('Attempting to start an OTR conversation with {jid}...') 72 QUERY_NOT_ENCRYPTED = D_('Attempting to start an OTR conversation with {jid}...')
72 AKE_ENCRYPTED = D_(" conversation with {jid} started. Your client is not logging this conversation.") 73 AKE_ENCRYPTED = D_(" conversation with {jid} started. Your client is not logging this conversation.")
73 AKE_NOT_ENCRYPTED = D_("ERROR: successfully ake'd with {jid} but the conversation is not encrypted!") 74 AKE_NOT_ENCRYPTED = D_("ERROR: successfully ake'd with {jid} but the conversation is not encrypted!")
74 END_ENCRYPTED = D_("ERROR: the OTR session ended but the context is still supposedly encrypted!") 75 END_ENCRYPTED = D_("ERROR: the OTR session ended but the context is still supposedly encrypted!")
75 END_PLAIN = D_("Your conversation with {jid} is no more or hasn't been encrypted.") 76 END_PLAIN_NO_MORE = D_("Your conversation with {jid} is no more encrypted.")
77 END_PLAIN_HAS_NOT = D_("Your conversation with {jid} hasn't been encrypted.")
76 END_FINISHED = D_("{jid} has ended his or her private conversation with you; you should do the same.") 78 END_FINISHED = D_("{jid} has ended his or her private conversation with you; you should do the same.")
77 79
78 KEY_TITLE = D_('Private key') 80 KEY_TITLE = D_('Private key')
79 KEY_NA_TITLE = D_("No private key") 81 KEY_NA_TITLE = D_("No private key")
80 KEY_NA_TXT = D_("You don't have any private key yet.") 82 KEY_NA_TXT = D_("You don't have any private key yet.")
90 QUERY_KEY = D_("You already have a private key, but to start the conversation will still require a couple of seconds.{eol}{eol}") 92 QUERY_KEY = D_("You already have a private key, but to start the conversation will still require a couple of seconds.{eol}{eol}")
91 QUERY_CONFIRM = D_("Press OK to start now the encryption.") 93 QUERY_CONFIRM = D_("Press OK to start now the encryption.")
92 94
93 ACTION_NA_TITLE = D_("Impossible action") 95 ACTION_NA_TITLE = D_("Impossible action")
94 ACTION_NA = D_("Your correspondent must be connected to start an OTR conversation with him.") 96 ACTION_NA = D_("Your correspondent must be connected to start an OTR conversation with him.")
95 RESOURCE_ISSUE_TITLE = D_("Security issue")
96 RESOURCE_ISSUE = D_("Your correspondent's resource is unknown!{eol}{eol}You should stop any OTR conversation with {jid} to avoid sending him unencrypted messages in an encrypted context.{eol}{eol}Please report the bug to the users mailing list: {users_ml}.")
97 97
98 DEFAULT_POLICY_FLAGS = { 98 DEFAULT_POLICY_FLAGS = {
99 'ALLOW_V2': True, 99 'ALLOW_V2': True,
100 'ALLOW_V3': True, 100 'ALLOW_V3': True,
101 'REQUIRE_ENCRYPTION': False, 101 'REQUIRE_ENCRYPTION': False,
102 'SEND_WHITESPACE_TAG': False, # FIXME: we need to complete sendMessageTrigger before turning this to True 102 'SEND_WHITESPACE_TAG': False, # FIXME: we need to complete sendMessageTg before turning this to True
103 'WHITESPACE_START_AKE': False, # FIXME: we need to complete messageReceivedTrigger before turning this to True 103 'WHITESPACE_START_AKE': False, # FIXME: we need to complete newMessageTg before turning this to True
104 } 104 }
105 105
106 # list a couple of texts or htmls (untrusted, trusted) for each state 106 # list a couple of texts or htmls (untrusted, trusted) for each state
107 OTR_MSG_STATES = { 107 OTR_MSG_STATES = {
108 otr.context.STATE_PLAINTEXT: [ 108 otr.context.STATE_PLAINTEXT: [
118 '<img src="media/icons/silk/lock_break.png" /><img src="media/icons/silk/key.png" />' 118 '<img src="media/icons/silk/lock_break.png" /><img src="media/icons/silk/key.png" />'
119 ] 119 ]
120 } 120 }
121 121
122 122
123 unicode = str # FIXME: pyjamas workaround
124
125
126 class NotConnectedEntity(Exception):
127 pass
128
129
123 class Context(otr.context.Context): 130 class Context(otr.context.Context):
124 131
125 def __init__(self, host, account, other_jid): 132 def __init__(self, host, account, other_jid):
126 """ 133 """
127 134
128 @param host (satWebFrontend) 135 @param host (satWebFrontend)
129 @param account (Account) 136 @param account (Account)
130 @param other_jid (JID): JID of the person your chat correspondent 137 @param other_jid (jid.JID): JID of the person your chat correspondent
131 """ 138 """
132 super(Context, self).__init__(account, other_jid) 139 super(Context, self).__init__(account, other_jid)
133 self.host = host 140 self.host = host
134 141
135 def getPolicy(self, key): 142 def getPolicy(self, key):
136 """Get the value of the specified policy 143 """Get the value of the specified policy
137 144
138 @param key (str): a value in: 145 @param key (unicode): a value in:
139 - ALLOW_V1 (apriori removed from otr.js) 146 - ALLOW_V1 (apriori removed from otr.js)
140 - ALLOW_V2 147 - ALLOW_V2
141 - ALLOW_V3 148 - ALLOW_V3
142 - REQUIRE_ENCRYPTION 149 - REQUIRE_ENCRYPTION
143 - SEND_WHITESPACE_TAG 150 - SEND_WHITESPACE_TAG
144 - WHITESPACE_START_AKE 151 - WHITESPACE_START_AKE
145 - ERROR_START_AKE 152 - ERROR_START_AKE
146 @return: str 153 @return: unicode
147 """ 154 """
148 if key in DEFAULT_POLICY_FLAGS: 155 if key in DEFAULT_POLICY_FLAGS:
149 return DEFAULT_POLICY_FLAGS[key] 156 return DEFAULT_POLICY_FLAGS[key]
150 else: 157 else:
151 return False 158 return False
155 if not encrypted: 162 if not encrypted:
156 log.warning("A plain-text message has been handled by otr.js") 163 log.warning("A plain-text message has been handled by otr.js")
157 log.debug("message received (was %s): %s" % ('encrypted' if encrypted else 'plain', msg)) 164 log.debug("message received (was %s): %s" % ('encrypted' if encrypted else 'plain', msg))
158 if not encrypted: 165 if not encrypted:
159 if self.state == otr.context.STATE_ENCRYPTED: 166 if self.state == otr.context.STATE_ENCRYPTED:
160 log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': self.peer.full()}) 167 log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': self.peer})
161 self.host.newMessageCb(self.peer, RECEIVE_PLAIN_IN_ENCRYPTED_CONTEXT, C.MESS_TYPE_INFO, self.host.whoami, {}) 168 self.host.newMessageHandler(unicode(self.peer), RECEIVE_PLAIN_IN_ENCRYPTED_CONTEXT, C.MESS_TYPE_INFO, unicode(self.host.whoami), {})
162 self.host.newMessageCb(self.peer, msg, "chat", self.host.whoami, {}) 169 self.host.newMessageHandler(unicode(self.peer), msg, C.MESS_TYPE_CHAT, unicode(self.host.whoami), {})
163 170
164 def sendMessageCb(self, msg, meta=None): 171 def sendMessageCb(self, msg, meta=None):
165 assert isinstance(self.peer, jid.JID) 172 assert isinstance(self.peer, jid.JID)
166 log.debug("message to send%s: %s" % ((' (attached meta data: %s)' % meta) if meta else '', msg)) 173 log.debug("message to send%s: %s" % ((' (attached meta data: %s)' % meta) if meta else '', msg))
167 self.host.bridge.call('sendMessage', (None, self.host.sendError), self.peer.full(), msg, '', 'chat', {'send_only': 'true'}) 174 self.host.bridge.call('sendMessage', (None, self.host.sendError), unicode(self.peer), msg, '', C.MESS_TYPE_CHAT, {'send_only': 'true'})
168 175
169 def messageErrorCb(self, error): 176 def messageErrorCb(self, error):
170 log.error('error occured: %s' % error) 177 log.error('error occured: %s' % error)
171 178
172 def setStateCb(self, msg_state, status): 179 def setStateCb(self, msg_state, status):
173 if status == otr.context.STATUS_AKE_INIT: 180 if status == otr.context.STATUS_AKE_INIT:
174 return 181 return
175 182
176 other_jid_s = self.peer.full() 183 other_jid_s = self.peer
177 feedback = _(u"Error: the state of the conversation with %s is unknown!") 184 feedback = _(u"Error: the state of the conversation with %s is unknown!")
178 trust = self.getCurrentTrust() 185 trust = self.getCurrentTrust()
179 186
180 if status == otr.context.STATUS_SEND_QUERY: 187 if status == otr.context.STATUS_SEND_QUERY:
181 feedback = QUERY_ENCRYPTED if msg_state == otr.context.STATE_ENCRYPTED else QUERY_NOT_ENCRYPTED 188 feedback = QUERY_ENCRYPTED if msg_state == otr.context.STATE_ENCRYPTED else QUERY_NOT_ENCRYPTED
184 trusted_str = AUTH_TRUSTED if trust else AUTH_UNTRUSTED 191 trusted_str = AUTH_TRUSTED if trust else AUTH_UNTRUSTED
185 feedback = (trusted_str + AKE_ENCRYPTED) if msg_state == otr.context.STATE_ENCRYPTED else AKE_NOT_ENCRYPTED 192 feedback = (trusted_str + AKE_ENCRYPTED) if msg_state == otr.context.STATE_ENCRYPTED else AKE_NOT_ENCRYPTED
186 193
187 elif status == otr.context.STATUS_END_OTR: 194 elif status == otr.context.STATUS_END_OTR:
188 if msg_state == otr.context.STATE_PLAINTEXT: 195 if msg_state == otr.context.STATE_PLAINTEXT:
189 feedback = END_PLAIN 196 feedback = END_PLAIN_NO_MORE
190 elif msg_state == otr.context.STATE_ENCRYPTED: 197 elif msg_state == otr.context.STATE_ENCRYPTED:
191 log.error(END_ENCRYPTED) 198 log.error(END_ENCRYPTED)
192 elif msg_state == otr.context.STATE_FINISHED: 199 elif msg_state == otr.context.STATE_FINISHED:
193 feedback = END_FINISHED 200 feedback = END_FINISHED
194 201
195 self.host.newMessageCb(self.peer, feedback.format(jid=other_jid_s), C.MESS_TYPE_INFO, self.host.whoami, {'header_info': OTR.getInfoText(msg_state, trust)}) 202 self.host.newMessageHandler(unicode(self.peer), feedback.format(jid=other_jid_s), C.MESS_TYPE_INFO, unicode(self.host.whoami), {'header_info': OTR.getInfoText(msg_state, trust)})
196 203
197 def setCurrentTrust(self, new_trust='', act='asked', type_='trust'): 204 def setCurrentTrust(self, new_trust='', act='asked', type_='trust'):
198 log.debug("setCurrentTrust: trust={trust}, act={act}, type={type}".format(type=type_, trust=new_trust, act=act)) 205 log.debug("setCurrentTrust: trust={trust}, act={act}, type={type}".format(type=type_, trust=new_trust, act=act))
199 title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer.full()) 206 title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer)
200 old_trust = self.getCurrentTrust() 207 old_trust = self.getCurrentTrust()
201 if type_ == 'abort': 208 if type_ == 'abort':
202 msg = AUTH_ABORTED_TXT 209 msg = AUTH_ABORTED_TXT
203 elif new_trust: 210 elif new_trust:
204 if act == "asked": 211 if act == "asked":
213 if act != "asked": 220 if act != "asked":
214 return 221 return
215 otr.context.Context.setCurrentTrust(self, new_trust) 222 otr.context.Context.setCurrentTrust(self, new_trust)
216 if old_trust != new_trust: 223 if old_trust != new_trust:
217 feedback = AUTH_STATUS.format(state=(AUTH_TRUSTED if new_trust else AUTH_UNTRUSTED).lower()) 224 feedback = AUTH_STATUS.format(state=(AUTH_TRUSTED if new_trust else AUTH_UNTRUSTED).lower())
218 self.host.newMessageCb(self.peer, feedback, C.MESS_TYPE_INFO, self.host.whoami, {'header_info': OTR.getInfoText(self.state, new_trust)}) 225 self.host.newMessageHandler(unicode(self.peer), feedback, C.MESS_TYPE_INFO, unicode(self.host.whoami), {'header_info': OTR.getInfoText(self.state, new_trust)})
219 226
220 def fingerprintAuthCb(self): 227 def fingerprintAuthCb(self):
221 """OTR v2 authentication using manual fingerprint comparison""" 228 """OTR v2 authentication using manual fingerprint comparison"""
222 priv_key = self.user.privkey 229 priv_key = self.user.privkey
223 230
234 241
235 def setTrust(confirm): 242 def setTrust(confirm):
236 self.setCurrentTrust('fingerprint' if confirm else '') 243 self.setCurrentTrust('fingerprint' if confirm else '')
237 244
238 text = (AUTH_INFO_TXT + "<i>" + AUTH_FINGERPRINT_TXT + "</i>" + AUTH_FINGERPRINT_VERIFY).format(you=self.host.whoami, your_fp=priv_key.fingerprint(), other=self.peer, other_fp=other_key.fingerprint(), eol=DIALOG_EOL) 245 text = (AUTH_INFO_TXT + "<i>" + AUTH_FINGERPRINT_TXT + "</i>" + AUTH_FINGERPRINT_VERIFY).format(you=self.host.whoami, your_fp=priv_key.fingerprint(), other=self.peer, other_fp=other_key.fingerprint(), eol=DIALOG_EOL)
239 title = AUTH_OTHER_TITLE.format(jid=self.peer.full()) 246 title = AUTH_OTHER_TITLE.format(jid=self.peer)
240 dialog.ConfirmDialog(setTrust, text, title, AddStyleName="maxWidthLimit").show() 247 dialog.ConfirmDialog(setTrust, text, title, AddStyleName="maxWidthLimit").show()
241 248
242 def smpAuthCb(self, type_, data, act=None): 249 def smpAuthCb(self, type_, data, act=None):
243 """OTR v3 authentication using the socialist millionaire protocol. 250 """OTR v3 authentication using the socialist millionaire protocol.
244 251
245 @param type_ (str): a value in ('question', 'trust', 'abort') 252 @param type_ (unicode): a value in ('question', 'trust', 'abort')
246 @param data (str, bool): this could be: 253 @param data (unicode, bool): this could be:
247 - a string containing the question if type_ is 'question' 254 - a string containing the question if type_ is 'question'
248 - a boolean value telling if the authentication succeed when type_ is 'trust' 255 - a boolean value telling if the authentication succeed when type_ is 'trust'
249 @param act (str): a value in ('asked', 'answered') 256 @param act (unicode): a value in ('asked', 'answered')
250 """ 257 """
251 log.debug("smpAuthCb: type={type}, data={data}, act={act}".format(type=type_, data=data, act=act)) 258 log.debug("smpAuthCb: type={type}, data={data}, act={act}".format(type=type_, data=data, act=act))
252 if act is None: 259 if act is None:
253 if type_ == 'question': 260 if type_ == 'question':
254 act = 'answered' # OTR._authenticate calls this method with act="asked" 261 act = 'answered' # OTR._authenticate calls this method with act="asked"
259 # fingerprints, we will reach this code... that's wrong, this method is for SMP! 266 # fingerprints, we will reach this code... that's wrong, this method is for SMP!
260 # There's probably a bug to fix in otr.js. Do it together with the issue that 267 # There's probably a bug to fix in otr.js. Do it together with the issue that
261 # make us need the dirty self.smpAuthAbort. 268 # make us need the dirty self.smpAuthAbort.
262 else: 269 else:
263 log.error("FIXME: unmanaged ambiguous 'act' value in Context.smpAuthCb!") 270 log.error("FIXME: unmanaged ambiguous 'act' value in Context.smpAuthCb!")
264 title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer.full()) 271 title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer)
265 if type_ == 'question': 272 if type_ == 'question':
266 if act == 'asked': 273 if act == 'asked':
267 def cb(question, answer=None): 274 def cb(result, question, answer=None):
268 if question is False or not answer: # dialog cancelled or the answer is empty 275 if not result or not answer: # dialog cancelled or the answer is empty
269 return 276 return
270 self.smpAuthSecret(answer, question) 277 self.smpAuthSecret(answer, question)
271 text = (AUTH_INFO_TXT + "<i>" + AUTH_QUEST_DEFINE_TXT + "</i>" + AUTH_QUEST_DEFINE).format(eol=DIALOG_EOL) 278 text = (AUTH_INFO_TXT + "<i>" + AUTH_QUEST_DEFINE_TXT + "</i>" + AUTH_QUEST_DEFINE).format(eol=DIALOG_EOL)
272 dialog.PromptDialog(cb, [text, AUTH_SECRET_INPUT.format(eol=DIALOG_EOL)], title=title, AddStyleName="maxWidthLimit").show() 279 dialog.PromptDialog(cb, [text, AUTH_SECRET_INPUT.format(eol=DIALOG_EOL)], title=title, AddStyleName="maxWidthLimit").show()
273 else: 280 else:
274 def cb(answer): 281 def cb(result, answer):
275 if not answer: # dialog cancelled or the answer is empty 282 if not result or not answer: # dialog cancelled or the answer is empty
276 self.smpAuthAbort('answered') 283 self.smpAuthAbort('answered')
277 return 284 return
278 self.smpAuthSecret(answer) 285 self.smpAuthSecret(answer)
279 text = (AUTH_INFO_TXT + "<i>" + AUTH_QUEST_ANSWER_TXT + "</i>" + AUTH_QUEST_ANSWER).format(eol=DIALOG_EOL, question=data) 286 text = (AUTH_INFO_TXT + "<i>" + AUTH_QUEST_ANSWER_TXT + "</i>" + AUTH_QUEST_ANSWER).format(eol=DIALOG_EOL, question=data)
280 dialog.PromptDialog(cb, text + AUTH_SECRET_INPUT.format(eol=DIALOG_EOL), title=title, AddStyleName="maxWidthLimit").show() 287 dialog.PromptDialog(cb, [text + AUTH_SECRET_INPUT.format(eol=DIALOG_EOL)], title=title, AddStyleName="maxWidthLimit").show()
281 elif type_ == 'trust': 288 elif type_ == 'trust':
282 self.setCurrentTrust('smp' if data else '', act) 289 self.setCurrentTrust('smp' if data else '', act)
283 elif type_ == 'abort': 290 elif type_ == 'abort':
284 self.setCurrentTrust('', act, 'abort') 291 self.setCurrentTrust('', act, 'abort')
285 292
295 302
296 303
297 class Account(otr.context.Account): 304 class Account(otr.context.Account):
298 305
299 def __init__(self, host): 306 def __init__(self, host):
300 log.debug(u"new account: %s" % host.whoami.full()) 307 log.debug(u"new account: %s" % host.whoami)
301 if not host.whoami.resource: 308 if not host.whoami.resource:
302 log.warning("Account created without resource") 309 log.warning("Account created without resource")
303 super(Account, self).__init__(host.whoami) 310 super(Account, self).__init__(host.whoami)
304 self.host = host 311 self.host = host
305 312
325 self.host = host 332 self.host = host
326 self.account = Account(host) 333 self.account = Account(host)
327 self.contexts = {} 334 self.contexts = {}
328 335
329 def startContext(self, other_jid): 336 def startContext(self, other_jid):
330 assert isinstance(other_jid, jid.JID) 337 assert isinstance(other_jid, jid.JID) # never start an OTR session with a bare JID
331 # FIXME upstream: apparently pyjamas doesn't implement setdefault well, it ignores JID.__hash__ redefinition 338 # FIXME upstream: apparently pyjamas doesn't implement setdefault well, it ignores JID.__hash__ redefinition
332 #context = self.contexts.setdefault(other_jid, Context(self.host, self.account, other_jid)) 339 #context = self.contexts.setdefault(other_jid, Context(self.host, self.account, other_jid))
333 if other_jid not in self.contexts: 340 if other_jid not in self.contexts:
334 self.contexts[other_jid] = Context(self.host, self.account, other_jid) 341 self.contexts[other_jid] = Context(self.host, self.account, other_jid)
335 return self.contexts[other_jid] 342 return self.contexts[other_jid]
336 343
337 def getContextForUser(self, other_jid, start=True): 344 def getContextForUser(self, other_jid, start=True):
338 """Get the context for the given JID 345 """Get the context for the given JID
339 346
340 @param other_jid (JID): your correspondent 347 @param other_jid (jid.JID): your correspondent
341 @param start (bool): start non-existing context if True 348 @param start (bool): start non-existing context if True
342 @return: Context 349 @return: Context
343 """ 350 """
351 try:
352 other_jid = self.fixResource(other_jid)
353 except NotConnectedEntity:
354 log.debug(u"getContextForUser [%s]: not connected!" % other_jid)
355 return None
344 log.debug(u"getContextForUser [%s]" % other_jid) 356 log.debug(u"getContextForUser [%s]" % other_jid)
345 if not other_jid.resource:
346 log.error("getContextForUser called with a bare jid")
347 running_sessions = [jid.bareJID() for jid in self.contexts.keys() if self.contexts[jid].state == otr.context.STATE_ENCRYPTED]
348 if start or (other_jid in running_sessions):
349 users_ml = DIALOG_USERS_ML.format(subject=D_("OTR issue in Libervia: getContextForUser called with a bare jid in an encrypted context"))
350 text = RESOURCE_ISSUE.format(eol=DIALOG_EOL, jid=other_jid.full(), users_ml=users_ml)
351 dialog.InfoDialog(RESOURCE_ISSUE_TITLE, text, AddStyleName="maxWidthLimit").show()
352 return None # never start an OTR session with a bare JID
353 if start: 357 if start:
354 return self.startContext(other_jid) 358 return self.startContext(other_jid)
355 else: 359 else:
356 return self.contexts.get(other_jid, None) 360 return self.contexts.get(other_jid, None)
357 361
362 def getContextsForBareUser(self, bare_jid):
363 """Get all the contexts for the users sharing the given bare JID.
364
365 @param bare_jid (jid.JID): bare JID
366 @return: list[Context]
367 """
368 return [context for other_jid, context in self.contexts.iteritems() if other_jid.bare == bare_jid]
369
370 def fixResource(self, other_jid):
371 """Return the full JID in case the resource of the given JID is missing.
372
373 @param other_jid (jid.JID): JID to check
374 @return jid.JID
375 """
376 if other_jid.resource:
377 return other_jid
378 clist = self.host.contact_list
379 if clist.getCache(other_jid.bare, C.PRESENCE_SHOW) is None:
380 raise NotConnectedEntity
381 return clist.getFullJid(other_jid)
382
358 383
359 class OTR(object): 384 class OTR(object):
360 385
361 def __init__(self, host): 386 def __init__(self, host):
362 log.info(_(u"OTR plugin initialization")) 387 log.info(_(u"OTR plugin initialization"))
363 self.host = host 388 self.host = host
364 self.context_manager = None 389 self.context_manager = None
365 self.last_resources = {}
366 self.host.bridge._registerMethods(["skipOTR"]) 390 self.host.bridge._registerMethods(["skipOTR"])
391 self.host.trigger.add("newMessageTrigger", self.newMessageTg, priority=TriggerManager.MAX_PRIORITY)
392 self.host.trigger.add("sendMessageTrigger", self.sendMessageTg, priority=TriggerManager.MAX_PRIORITY)
393
394 # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword)
395 self._profilePluggedListener = self.profilePluggedListener
396 self._gotMenusListener = self.gotMenusListener
397 # FIXME: these listeners are never removed, can't be removed by themselves (it modifies the list while looping), maybe need a 'one_shot' argument
398 self.host.addListener('profilePlugged', self._profilePluggedListener)
399 self.host.addListener('gotMenus', self._gotMenusListener)
367 400
368 @classmethod 401 @classmethod
369 def getInfoText(self, state=otr.context.STATE_PLAINTEXT, trust=''): 402 def getInfoText(self, state=otr.context.STATE_PLAINTEXT, trust=''):
370 """Get the widget info text for a certain message state and trust. 403 """Get the widget info text for a certain message state and trust.
371 404
372 @param state (str): message state 405 @param state (unicode): message state
373 @param trust (str): trust 406 @param trust (unicode): trust
374 @return: str 407 @return: unicode
375 """ 408 """
376 if not state: 409 if not state:
377 state = OTR_MSG_STATES.keys()[0] 410 state = OTR_MSG_STATES.keys()[0]
378 return OTR_MSG_STATES[state][1 if trust else 0] 411 return OTR_MSG_STATES[state][1 if trust else 0]
379 412
380 def infoTextCallback(self, other_jid, cb): 413 def getInfoTextForUser(self, other_jid):
381 """Get the current info text for a conversation and run a callback. 414 """Get the current info text for a conversation.
382 415
383 @param other_jid (JID): JID of the correspondant 416 @param other_jid (jid.JID): JID of the correspondant
384 @paam cb (callable): method to be called with the computed info text 417 """
385 """ 418 otrctx = self.context_manager.getContextForUser(other_jid, start=False)
386 def gotResource(other_jid): 419 if otrctx is None:
387 otrctx = self.context_manager.getContextForUser(other_jid, start=False) 420 return OTR.getInfoText()
388 if otrctx is None: 421 else:
389 cb(OTR.getInfoText()) 422 return OTR.getInfoText(otrctx.state, otrctx.getCurrentTrust())
390 else: 423
391 cb(OTR.getInfoText(otrctx.state, otrctx.getCurrentTrust())) 424 def gotMenusListener(self,):
392 425 # TODO: get menus paths to hook directly from backend's OTR plugin
393 self.fixResource(other_jid, gotResource) 426 self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"Start/Refresh")), callback=self._startRefresh)
394 427 self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"End session")), callback=self._endSession)
395 def inhibitMenus(self): 428 self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"Authenticate")), callback=self._authenticate)
396 """Tell the caller which dynamic menus should be inhibited""" 429 self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"Drop private key")), callback=self._dropPrivkey)
397 return ["OTR"] # menu categories name to inhibit 430
398 431 def profilePluggedListener(self, profile):
399 def extraMenus(self): 432 # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword)
400 # FIXME: handle help strings too 433 self._presenceListener = self.presenceListener
401 return [(self._startRefresh, C.MENU_SINGLE, (MAIN_MENU, "Start / refresh"), (MAIN_MENU, D_("Start / refresh"))), 434 self._disconnectListener = self.disconnectListener
402 (self._endSession, C.MENU_SINGLE, (MAIN_MENU, "Stop encryption"), (MAIN_MENU, D_("Stop encryption"))), 435 self.host.addListener('presence', self._presenceListener, [C.PROF_KEY_NONE])
403 (self._authenticate, C.MENU_SINGLE, (MAIN_MENU, "Authenticate correspondent"), (MAIN_MENU, D_("Authenticate correspondent"))), 436 # FIXME: this listener is never removed, can't be removed by itself (it modifies the list while looping), maybe need a 'one_shot' argument
404 (self._dropPrivkey, C.MENU_SINGLE, (MAIN_MENU, "Drop your private key"), (MAIN_MENU, D_("Drop your private key")))] 437 self.host.addListener('disconnect', self._disconnectListener, [C.PROF_KEY_NONE])
405 438
406 def profileConnected(self):
407 self.host.bridge.call('skipOTR', None) 439 self.host.bridge.call('skipOTR', None)
408 self.context_manager = ContextManager(self.host) 440 self.context_manager = ContextManager(self.host)
409 # TODO: retrieve the encrypted private key from a HTML5 persistent storage, 441 # TODO: retrieve the encrypted private key from a HTML5 persistent storage,
410 # decrypt it, parse it with otr.crypt.PK.parsePrivateKey(privkey) and 442 # decrypt it, parse it with otr.crypt.PK.parsePrivateKey(privkey) and
411 # assign it to self.context_manager.account.privkey 443 # assign it to self.context_manager.account.privkey
412 444
413 def profileDisconnected(self): 445 def disconnectListener(self, profile):
446 """Things to do just before the profile disconnection"""
447 self.host.removeListener('presence', self._presenceListener)
448
414 for context in self.context_manager.contexts.values(): 449 for context in self.context_manager.contexts.values():
415 context.disconnect() 450 context.disconnect() # FIXME: no time to send the message before the profile has been disconnected
416 451
417 def fixResource(self, jid, cb): 452 def presenceListener(self, entity, show, priority, statuses, profile):
418 # FIXME: it's dirty, but libervia doesn't manage resources correctly now, refactoring is planed 453 if show == C.PRESENCE_UNAVAILABLE:
419 if jid.resource: 454 self.endSession(entity, disconnect=False)
420 self.last_resources[jid.bare] = jid.resource 455
421 cb(jid) 456 def newMessageTg(self, from_jid, msg, msg_type, to_jid, extra, profile):
422 elif jid.bare in self.last_resources: 457 if msg_type != C.MESS_TYPE_CHAT:
423 jid.setResource(self.last_resources[jid.bare])
424 cb(jid)
425 else:
426 def gotResource(resource):
427 if resource:
428 jid.setResource(resource)
429 self.last_resources[jid.bare] = jid.resource
430 cb(jid)
431 self.host.bridge.call('getLastResource', gotResource, jid.full())
432
433 def messageReceivedTrigger(self, from_jid, msg, msg_type, to_jid, extra):
434 if msg_type == C.MESS_TYPE_INFO:
435 return True 458 return True
436 459
437 tag = otr.proto.checkForOTR(msg) 460 tag = otr.proto.checkForOTR(msg)
438 if tag is None or (tag == otr.context.WHITESPACE_TAG and not DEFAULT_POLICY_FLAGS['WHITESPACE_START_AKE']): 461 if tag is None or (tag == otr.context.WHITESPACE_TAG and not DEFAULT_POLICY_FLAGS['WHITESPACE_START_AKE']):
439 return True 462 return True
440 463
441 def decrypt(context):
442 context.receiveMessage(msg)
443
444 def cb(jid):
445 otrctx = self.context_manager.getContextForUser(jid, start=False)
446 if otrctx is None:
447 def confirm(confirm):
448 if confirm:
449 self.host.getOrCreateLiberviaWidget(panels.ChatPanel, {'item': jid})
450 decrypt(self.context_manager.startContext(jid))
451 else:
452 # FIXME: plain text messages with whitespaces would be lost here when WHITESPACE_START_AKE is True
453 pass
454 key = self.context_manager.account.privkey
455 msg = QUERY_RECEIVED + QUERY_SLOWDOWN + (QUERY_KEY if key else QUERY_NO_KEY) + QUERY_CONFIRM
456 dialog.ConfirmDialog(confirm, msg.format(jid=jid.full(), eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show()
457 else: # do not ask if the context exist
458 decrypt(otrctx)
459
460 other_jid = to_jid if from_jid.bare == self.host.whoami.bare else from_jid 464 other_jid = to_jid if from_jid.bare == self.host.whoami.bare else from_jid
461 self.fixResource(other_jid, cb) 465 otrctx = self.context_manager.getContextForUser(other_jid, start=False)
466 if otrctx is None:
467 def confirm(confirm):
468 if confirm:
469 self.host.displayWidget(chat.Chat, other_jid)
470 self.context_manager.startContext(other_jid).receiveMessage(msg)
471 else:
472 # FIXME: plain text messages with whitespaces would be lost here when WHITESPACE_START_AKE is True
473 pass
474 key = self.context_manager.account.privkey
475 question = QUERY_RECEIVED + QUERY_SLOWDOWN + (QUERY_KEY if key else QUERY_NO_KEY) + QUERY_CONFIRM
476 dialog.ConfirmDialog(confirm, question.format(jid=other_jid, eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show()
477 else: # do not ask for user confirmation if the context exist
478 otrctx.receiveMessage(msg)
479
462 return False # interrupt the main process 480 return False # interrupt the main process
463 481
464 def sendMessageTrigger(self, to_jid, msg, msg_type, extra): 482 def sendMessageTg(self, to_jid, message, subject, mess_type, extra, callback, errback, profile_key):
465 def cb(jid): 483 if mess_type != C.MESS_TYPE_CHAT:
466 otrctx = self.context_manager.getContextForUser(jid, start=False) 484 return True
467 if otrctx is not None and otrctx.state != otr.context.STATE_PLAINTEXT: 485
468 if otrctx.state == otr.context.STATE_ENCRYPTED: 486 otrctx = self.context_manager.getContextForUser(to_jid, start=False)
469 log.debug(u"encrypting message") 487 if otrctx is not None and otrctx.state != otr.context.STATE_PLAINTEXT:
470 otrctx.sendMessage(msg) 488 if otrctx.state == otr.context.STATE_ENCRYPTED:
471 self.host.newMessageCb(self.host.whoami, msg, msg_type, jid, extra) 489 log.debug(u"encrypting message")
472 else: 490 otrctx.sendMessage(message)
473 feedback = SEND_PLAIN_IN_FINISHED_CONTEXT 491 self.host.newMessageHandler(unicode(self.host.whoami), message, mess_type, unicode(to_jid), extra)
474 dialog.InfoDialog(FINISHED_CONTEXT_TITLE.format(jid=to_jid.full()), feedback, AddStyleName="maxWidthLimit").show()
475 else: 492 else:
476 log.debug(u"sending message unencrypted") 493 feedback = SEND_PLAIN_IN_FINISHED_CONTEXT
477 self.host.bridge.call('sendMessage', (None, self.host.sendError), to_jid.full(), msg, '', msg_type, extra) 494 dialog.InfoDialog(FINISHED_CONTEXT_TITLE.format(jid=to_jid), feedback, AddStyleName="maxWidthLimit").show()
478 495 return False # interrupt the main process
479 if msg_type == 'groupchat': 496
480 return True 497 log.debug(u"sending message unencrypted")
481 self.fixResource(to_jid, cb)
482 return False # interrupt the main process
483
484 def presenceReceivedTrigger(self, entity, show, priority, statuses):
485 if show == "unavailable":
486 self.endSession(entity, finish=True)
487 return True 498 return True
488 499
489 def endSession(self, other_jid, profile, finish=False): 500 def endSession(self, other_jid, disconnect=True):
490 """Finish or disconnect an OTR session 501 """Finish or disconnect an OTR session
491 502
492 @param other_jid (JID): str 503 @param other_jid (jid.JID): other JID
493 @param finish: if True, finish the session but do not disconnect it 504 @param disconnect (bool): if False, finish the session but do not disconnect it
494 @return: True if the session has been finished or disconnected, False if there was nothing to do 505 """
495 """ 506 # checking for private key existence is not needed, context checking is enough
496 def cb(other_jid): 507 if other_jid.resource:
497 def not_available(): 508 contexts = [self.context_manager.getContextForUser(other_jid, start=False)]
498 if not finish: 509 else: # contact disconnected itself so we need to terminate the OTR session but the Chat panel lost its resource
499 self.host.newMessageCb(other_jid, END_PLAIN.format(jid=other_jid.full()), C.MESS_TYPE_INFO, self.host.whoami, {}) 510 contexts = self.context_manager.getContextsForBareUser(other_jid)
500 511 for otrctx in contexts:
501 priv_key = self.context_manager.account.privkey 512 if otrctx is None or otrctx.state == otr.context.STATE_PLAINTEXT:
502 if priv_key is None: 513 if disconnect:
503 not_available() 514 self.host.newMessageHandler(unicode(other_jid), END_PLAIN_HAS_NOT.format(jid=other_jid), C.MESS_TYPE_INFO, unicode(self.host.whoami), {})
504 return 515 return
505 516 if disconnect:
506 otrctx = self.context_manager.getContextForUser(other_jid, start=False) 517 otrctx.disconnect()
507 if otrctx is None: 518 else:
508 not_available()
509 return
510 if finish:
511 otrctx.finish() 519 otrctx.finish()
512 else:
513 otrctx.disconnect()
514
515 self.fixResource(other_jid, cb)
516 520
517 # Menu callbacks 521 # Menu callbacks
518 522
519 def _startRefresh(self, menu_data): 523 def _startRefresh(self, caller, menu_data, profile):
520 """Start or refresh an OTR session 524 """Start or refresh an OTR session
521 525
522 @param menu_data: %(menu_data)s 526 @param menu_data: %(menu_data)s
523 """ 527 """
524 def query(other_jid): 528 def query(other_jid):
525 otrctx = self.context_manager.getContextForUser(other_jid) 529 otrctx = self.context_manager.getContextForUser(other_jid)
526 if otrctx: 530 if otrctx:
527 otrctx.sendQueryMessage() 531 otrctx.sendQueryMessage()
528 532
529 def cb(jid): 533 other_jid = jid.JID(menu_data['jid'])
530 key = self.context_manager.account.privkey 534 clist = self.host.contact_list
531 if key is None: 535 if clist.getCache(other_jid.bare, C.PRESENCE_SHOW) is None:
532 def confirm(confirm): 536 dialog.InfoDialog(ACTION_NA_TITLE, ACTION_NA, AddStyleName="maxWidthLimit").show()
533 if confirm: 537 return
534 query(jid) 538
535 msg = QUERY_SEND + QUERY_SLOWDOWN + QUERY_NO_KEY + QUERY_CONFIRM 539 key = self.context_manager.account.privkey
536 dialog.ConfirmDialog(confirm, msg.format(jid=jid.full(), eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show() 540 if key is None:
537 else: # on query reception we ask always, if we initiate we just ask the first time 541 def confirm(confirm):
538 query(jid) 542 if confirm:
539 543 query(other_jid)
540 try: 544 msg = QUERY_SEND + QUERY_SLOWDOWN + QUERY_NO_KEY + QUERY_CONFIRM
541 other_jid = menu_data['jid'] 545 dialog.ConfirmDialog(confirm, msg.format(jid=other_jid, eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show()
542 if other_jid.bare not in self.host.contact_panel.connected: 546 else: # on query reception we ask always, if we initiate we just ask the first time
543 dialog.InfoDialog(ACTION_NA_TITLE, ACTION_NA, AddStyleName="maxWidthLimit").show() 547 query(other_jid)
544 return 548
545 self.fixResource(other_jid, cb) 549 def _endSession(self, caller, menu_data, profile):
546 except KeyError:
547 log.error(_("jid key is not present !"))
548
549 def _endSession(self, menu_data):
550 """End an OTR session 550 """End an OTR session
551 551
552 @param menu_data: %(menu_data)s 552 @param menu_data: %(menu_data)s
553 """ 553 """
554 try: 554 self.endSession(jid.JID(menu_data['jid']))
555 other_jid = menu_data['jid'] 555
556 except KeyError: 556 def _authenticate(self, caller, menu_data, profile):
557 log.error(_("jid key is not present !"))
558 return None
559 self.endSession(other_jid)
560
561 def _authenticate(self, menu_data, profile):
562 """Authenticate other user and see our own fingerprint 557 """Authenticate other user and see our own fingerprint
563 558
564 @param menu_data: %(menu_data)s 559 @param menu_data: %(menu_data)s
565 @param profile: %(doc_profile)s 560 @param profile: %(doc_profile)s
566 """ 561 """
567 def not_available(): 562 def not_available():
568 dialog.InfoDialog(AUTH_TRUST_NA_TITLE, AUTH_TRUST_NA_TXT, AddStyleName="maxWidthLimit").show() 563 dialog.InfoDialog(AUTH_TRUST_NA_TITLE, AUTH_TRUST_NA_TXT, AddStyleName="maxWidthLimit").show()
569 564
570 priv_key = self.context_manager.account.privkey 565 to_jid = jid.JID(menu_data['jid'])
571 if priv_key is None: 566
567 # checking for private key existence is not needed, context checking is enough
568 otrctx = self.context_manager.getContextForUser(to_jid, start=False)
569 if otrctx is None or otrctx.state != otr.context.STATE_ENCRYPTED:
572 not_available() 570 not_available()
573 return 571 return
574 572 otr_version = otrctx.getUsedVersion()
575 def cb(to_jid): 573 if otr_version == otr.context.OTR_VERSION_2:
576 otrctx = self.context_manager.getContextForUser(to_jid, start=False) 574 otrctx.fingerprintAuthCb()
577 if otrctx is None: 575 elif otr_version == otr.context.OTR_VERSION_3:
578 not_available() 576 otrctx.smpAuthCb('question', None, 'asked')
579 return 577 else:
580 otr_version = otrctx.getUsedVersion() 578 not_available()
581 if otr_version == otr.context.OTR_VERSION_2: 579
582 otrctx.fingerprintAuthCb() 580 def _dropPrivkey(self, caller, menu_data, profile):
583 elif otr_version == otr.context.OTR_VERSION_3:
584 otrctx.smpAuthCb('question', None, 'asked')
585 else:
586 not_available()
587
588 try:
589 to_jid = menu_data['jid']
590 self.fixResource(to_jid, cb)
591 except KeyError:
592 log.error(_("jid key is not present !"))
593 return None
594
595 def _dropPrivkey(self, menu_data, profile):
596 """Drop our private Key 581 """Drop our private Key
597 582
598 @param menu_data: %(menu_data)s 583 @param menu_data: %(menu_data)s
599 @param profile: %(doc_profile)s 584 @param profile: %(doc_profile)s
600 """ 585 """
602 if priv_key is None: 587 if priv_key is None:
603 # we have no private key yet 588 # we have no private key yet
604 dialog.InfoDialog(KEY_NA_TITLE, KEY_NA_TXT, AddStyleName="maxWidthLimit").show() 589 dialog.InfoDialog(KEY_NA_TITLE, KEY_NA_TXT, AddStyleName="maxWidthLimit").show()
605 return 590 return
606 591
607 def cb(to_jid): 592 def dropKey(confirm):
608 def dropKey(confirm): 593 if confirm:
609 if confirm: 594 # we end all sessions
610 # we end all sessions 595 for context in self.context_manager.contexts.values():
611 for context in self.context_manager.contexts.values(): 596 context.disconnect()
612 context.disconnect() 597 self.context_manager.contexts.clear()
613 self.context_manager.contexts.clear() 598 self.context_manager.account.privkey = None
614 self.context_manager.account.privkey = None 599 dialog.InfoDialog(KEY_TITLE, KEY_DROPPED_TXT, AddStyleName="maxWidthLimit").show()
615 dialog.InfoDialog(KEY_TITLE, KEY_DROPPED_TXT, AddStyleName="maxWidthLimit").show() 600
616 601 dialog.ConfirmDialog(dropKey, KEY_DROP_TXT.format(eol=DIALOG_EOL), KEY_DROP_TITLE, AddStyleName="maxWidthLimit").show()
617 dialog.ConfirmDialog(dropKey, KEY_DROP_TXT.format(eol=DIALOG_EOL), KEY_DROP_TITLE, AddStyleName="maxWidthLimit").show()
618
619 try:
620 to_jid = menu_data['jid']
621 self.fixResource(to_jid, cb)
622 except KeyError:
623 log.error(_("jid key is not present !"))
624 return None