comparison src/browser/sat_browser/plugin_sec_otr.py @ 522:0de69fec24e9

browser and server sides: OTR plugin, first draft
author souliane <souliane@mailoo.org>
date Tue, 02 Sep 2014 21:28:42 +0200
parents
children 5add182e7dd5
comparison
equal deleted inserted replaced
521:69bffcf37ce3 522:0de69fec24e9
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia plugin for OTR encryption
5 # Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org)
6 # Copyright (C) 2013, 2014 Adrien Cossa (souliane@mailoo.org)
7
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU Affero General Public License for more details.
17
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21 """
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).
24 """
25
26 from sat.core.i18n import _, D_
27 from sat.core.log import getLogger
28 from sat.core import exceptions
29 log = getLogger(__name__)
30
31 from constants import Const as C
32 import jid
33 import otrjs_wrapper as otr
34 import dialog
35
36 NS_OTR = "otr_plugin"
37 PRIVATE_KEY = "PRIVATE KEY"
38 MAIN_MENU = D_('OTR')
39 DIALOG_EOL = "<br />"
40 AUTH_INFO_TXT = D_("Authenticating a buddy helps ensure that the person you are talking to is who he or she claims to be.{eol}{eol}").format(eol=DIALOG_EOL)
41 AUTH_FINGERPRINT_TXT = D_("<i>To verify the fingerprint, contact your buddy via some other authenticated channel (i.e. not in this chat), such as the telephone or GPG-signed email. Each of you should tell your fingerprint to the other.</i>{eol}{eol}").format(eol=DIALOG_EOL)
42 AUTH_QUEST_DEF = D_("<i>To authenticate using a question, pick a question whose answer is known only to you and your buddy. Enter this question and this answer, then wait for your buddy to enter the answer too. If the answers don't match, then you may be talking to an imposter.</i>{eol}{eol}").format(eol=DIALOG_EOL)
43 AUTH_QUEST_ASK = D_("<i>Your buddy is attempting to determine if he or she is really talking to you, or if it's someone pretending to be you. Your buddy has asked a question, indicated below. To authenticate to your buddy, enter the answer and click OK.</i>{eol}{eol}").format(eol=DIALOG_EOL)
44 AUTH_SECRET_TXT = D_("{eol}{eol}Enter secret answer here: (case sensitive){eol}").format(eol=DIALOG_EOL)
45
46
47 DROP_TXT = D_("You private key is used to encrypt messages for your correspondent, nobody except you must know it, if you are in doubt, you should drop it!{eol}{eol}Are you sure you want to drop your private key?").format(eol=DIALOG_EOL)
48
49 DEFAULT_POLICY_FLAGS = {
50 'ALLOW_V2': True,
51 'ALLOW_V3': True,
52 'REQUIRE_ENCRYPTION': False,
53 }
54
55
56 class Context(otr.context.Context):
57
58 def __init__(self, host, account, other_jid):
59 """
60
61 @param host (satWebFrontend)
62 @param account (Account)
63 @param other_jid (JID): JID of the person your chat buddy
64 """
65 super(Context, self).__init__(account, other_jid)
66 self.host = host
67
68 def getPolicy(self, key):
69 """Get the value of the specified policy
70
71 @param key (str): a value in:
72 - ALLOW_V1 (apriori removed from otr.js)
73 - ALLOW_V2
74 - ALLOW_V3
75 - REQUIRE_ENCRYPTION
76 - SEND_WHITESPACE_TAG
77 - WHITESPACE_START_AKE
78 - ERROR_START_AKE
79 @return: str
80 """
81 if key in DEFAULT_POLICY_FLAGS:
82 return DEFAULT_POLICY_FLAGS[key]
83 else:
84 return False
85
86 def receiveMessageCb(self, msg, encrypted):
87 assert isinstance(self.peer, jid.JID)
88 log.debug("message received (was %s): %s" % ('encrypted' if encrypted else 'plain', msg))
89 if not encrypted:
90 if self.state == otr.context.STATE_ENCRYPTED:
91 log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': self.peer.full()})
92 feedback = _(u"WARNING: received unencrypted data in a supposedly encrypted context"),
93 self.host.newMessageCb(self.peer, feedback, "headline", self.host.whoami, {})
94 self.host.newMessageCb(self.peer, msg, "chat", self.host.whoami, {})
95
96 def sendMessageCb(self, msg, meta=None):
97 assert isinstance(self.peer, jid.JID)
98 log.debug("message to send%s: %s" % ((' (attached meta data: %s)' % meta) if meta else '', msg))
99 self.host.bridge.call('sendMessage', (None, self.host.sendError), self.peer.full(), msg, '', 'chat', {'send_only': 'true'})
100
101 def messageErrorCb(self, error):
102 log.error('error occured: %s' % error)
103
104 def setStateCb(self, msg_state, status):
105 other_jid_s = self.peer.full()
106 feedback = _(u"Error: the state of the conversation with %s is unknown!")
107
108 if status == otr.context.STATUS_AKE_INIT:
109 return
110
111 elif status == otr.context.STATUS_SEND_QUERY:
112 if msg_state in (otr.context.STATE_PLAINTEXT, otr.context.STATE_FINISHED):
113 feedback = _('Attempting to start a private conversation with %s...')
114 elif msg_state == otr.context.STATE_ENCRYPTED:
115 feedback = _('Attempting to refresh the private conversation with %s...')
116
117 elif status == otr.context.STATUS_AKE_SUCCESS:
118 trusted_str = _(u"Verified") if self.getCurrentTrust() else _(u"Unverified")
119 if msg_state == otr.context.STATE_ENCRYPTED:
120 feedback = trusted_str + (u" conversation with %s started. Your client is not logging this conversation.")
121 else:
122 feedback = _("Error: successfully ake'd with %s but the conversation is not private!")
123
124 elif status == otr.context.STATUS_END_OTR:
125 if msg_state == otr.context.STATE_PLAINTEXT:
126 feedback = _("You haven't start any private conversation with %s yet.")
127 elif msg_state == otr.context.STATE_ENCRYPTED:
128 feedback = _("%s has ended his/her private conversation with you; you should do the same.")
129 elif msg_state == otr.context.STATE_FINISHED:
130 feedback = _("Private conversation with %s lost.")
131
132 self.host.newMessageCb(self.peer, feedback % other_jid_s, "headline", self.host.whoami, {})
133
134 def setCurrentTrust(self, new_trust='', act='asked', type_='trust'):
135 log.debug("setCurrentTrust: trust={trust}, act={act}, type={type}".format(type=type_, trust=new_trust, act=act))
136 title = (_("Authentication of {jid}") if act == "asked" else _("Authentication to {jid}")).format(jid=self.peer.full())
137 if type_ == 'abort':
138 msg = _("Authentication aborted.")
139 elif new_trust:
140 if act == "asked":
141 msg = _("Authentication successful.")
142 else:
143 msg = _("Your buddy has successfully authenticated you. You may want to authenticate your buddy as well by asking your own question.")
144 else:
145 msg = _("Authentication failed.")
146 dialog.InfoDialog(title, msg).show()
147 if act != "asked":
148 return
149 old_trust = self.getCurrentTrust()
150 otr.context.Context.setCurrentTrust(self, new_trust)
151 if old_trust != new_trust:
152 feedback = _("The privacy status of the current conversation is now: {state}").format(state='Private' if new_trust else 'Unverified')
153 self.host.newMessageCb(self.peer, feedback, "headline", self.host.whoami, {})
154
155 def fingerprintAuthCb(self):
156 """OTR v2 authentication using manual fingerprint comparison"""
157 priv_key = self.user.privkey
158
159 if priv_key is None: # OTR._authenticate should not let us arrive here
160 raise exceptions.InternalError
161 return
162
163 other_key = self.getCurrentKey()
164 if other_key is None:
165 # we have a private key, but not the fingerprint of our correspondent
166 msg = AUTH_INFO_TXT + ("Your fingerprint is:{eol}{fingerprint}{eol}{eol}Start an OTR conversation to have your correspondent one.").format(fingerprint=priv_key.fingerprint(), eol=DIALOG_EOL)
167 dialog.InfoDialog(_("Fingerprint"), msg).show()
168 return
169
170 def setTrust(confirm):
171 self.setCurrentTrust('fingerprint' if confirm else '')
172
173 text = AUTH_INFO_TXT + AUTH_FINGERPRINT_TXT + _("Fingerprint for you, {jid}:{eol}{fingerprint}{eol}{eol}").format(jid=self.host.whoami, fingerprint=priv_key.fingerprint(), eol=DIALOG_EOL)
174 text += _("Purported fingerprint for {jid}:{eol}{fingerprint}{eol}{eol}").format(jid=self.peer, fingerprint=other_key.fingerprint(), eol=DIALOG_EOL)
175 text += _("Did you verify that this is in fact the correct fingerprint for {jid}?").format(jid=self.peer)
176 title = _('Authentication of {jid}').format(jid=self.peer.full())
177 dialog.ConfirmDialog(setTrust, text, title).show()
178
179 def smpAuthCb(self, type_, data, act=None):
180 """OTR v3 authentication using the socialist millionaire protocol.
181
182 @param type_ (str): a value in ('question', 'trust', 'abort')
183 @param data (str, bool): this could be:
184 - a string containing the question if type_ is 'question'
185 - a boolean value telling if the authentication succeed when type_ is 'trust'
186 @param act (str): a value in ('asked', 'answered')
187 """
188 log.debug("smpAuthCb: type={type}, data={data}, act={act}".format(type=type_, data=data, act=act))
189 if act is None:
190 if type_ == 'question':
191 act = 'answered' # OTR._authenticate calls this method with act="asked"
192 elif type_ == 'abort':
193 act = 'asked' # smpAuthAbort triggers this method with act='answered' when needed
194
195 # FIXME upstream: if buddy uses Pidgin and use fingerprint to authenticate us,
196 # we will reach this code... that's wrong, this method is for SMP! There's
197 # probably a bug to fix in otr.js. Do it together with the issue that make
198 # us need the dirty self.smpAuthAbort.
199 else:
200 log.error("FIXME: unmanaged ambiguous 'act' value in Context.smpAuthCb!")
201 title = (_("Authentication of {jid}") if act == "asked" else _("Authentication to {jid}")).format(jid=self.peer.full())
202 if type_ == 'question':
203 if act == 'asked':
204 def cb(question, answer=None):
205 if question is False or not answer: # dialog cancelled or the answer is empty
206 return
207 self.smpAuthSecret(answer, question)
208 text = AUTH_INFO_TXT + AUTH_QUEST_DEF + _("Enter question here:{eol}").format(eol=DIALOG_EOL, question=data)
209 dialog.PromptDialog(cb, [text, AUTH_SECRET_TXT], title=title).show()
210 else:
211 def cb(answer):
212 if not answer: # dialog cancelled or the answer is empty
213 self.smpAuthAbort('answered')
214 return
215 self.smpAuthSecret(answer)
216 text = AUTH_INFO_TXT + AUTH_QUEST_ASK + _("This is the question asked by your buddy:{eol}{question}").format(eol=DIALOG_EOL, question=data)
217 dialog.PromptDialog(cb, text + AUTH_SECRET_TXT, title=title).show()
218 elif type_ == 'trust':
219 self.setCurrentTrust('smp' if data else '', act)
220 elif type_ == 'abort':
221 self.setCurrentTrust('', act, 'abort')
222
223
224 class Account(otr.context.Account):
225
226 def __init__(self, host):
227 log.debug(u"new account: %s" % host.whoami.full())
228 if not host.whoami.resource:
229 log.warning("Account created without resource")
230 super(Account, self).__init__(host.whoami)
231 self.host = host
232
233 def loadPrivkey(self):
234 return self.privkey
235
236 def savePrivkey(self):
237 # TODO: serialize and encrypt the private key and save it to a HTML5 persistent storage
238 # We need to ask the user before saving the key (e.g. if he's not on his private machine)
239 # self.privkey.serializePrivateKey() --> encrypt --> store
240 if self.privkey is None:
241 raise exceptions.InternalError(_("Save is called but privkey is None !"))
242 pass
243
244 def saveTrusts(self):
245 # TODO save the trusts as it would be done for the private key
246 pass
247
248
249 class ContextManager(object):
250
251 def __init__(self, host):
252 self.host = host
253 self.account = Account(host)
254 self.contexts = {}
255
256 def startContext(self, other_jid):
257 assert isinstance(other_jid, jid.JID)
258 # FIXME upstream: apparently pyjamas doesn't implement setdefault well, it ignores JID.__hash__ redefinition
259 #context = self.contexts.setdefault(other_jid, Context(self.host, self.account, other_jid))
260 if other_jid not in self.contexts:
261 self.contexts[other_jid] = Context(self.host, self.account, other_jid)
262 return self.contexts[other_jid]
263
264 def getContextForUser(self, other):
265 log.debug(u"getContextForUser [%s]" % other)
266 if not other.resource:
267 log.error("getContextForUser called with a bare jid")
268 return self.startContext(other)
269
270
271 class OTR(object):
272
273 def __init__(self, host):
274 log.info(_(u"OTR plugin initialization"))
275 self.host = host
276 self.context_manager = None
277 self.last_resources = {}
278 self.host.bridge._registerMethods(["skipOTR"])
279
280 def inhibitMenus(self):
281 """Tell the caller which dynamic menus should be inhibited"""
282 return ["OTR"] # menu categories name to inhibit
283
284 def extraMenus(self):
285 # FIXME: handle help strings too
286 return [(self._startRefresh, C.MENU_SINGLE, (MAIN_MENU, "Start/Refresh"), (MAIN_MENU, D_("Start/Refresh"))),
287 (self._endSession, C.MENU_SINGLE, (MAIN_MENU, "End session"), (MAIN_MENU, D_("End session"))),
288 (self._authenticate, C.MENU_SINGLE, (MAIN_MENU, "Authenticate buddy"), (MAIN_MENU, D_("Authenticate buddy"))),
289 (self._dropPrivkey, C.MENU_SINGLE, (MAIN_MENU, "Drop private key"), (MAIN_MENU, D_("Drop private key")))]
290
291 def profileConnected(self):
292 self.host.bridge.call('skipOTR', None)
293 self.context_manager = ContextManager(self.host)
294 # TODO: retrieve the encrypted private key from a HTML5 persistent storage,
295 # decrypt it, parse it with otr.crypt.PK.parsePrivateKey(privkey) and
296 # assign it to self.context_manager.account.privkey
297
298 def fixResource(self, jid, cb):
299 # FIXME: it's dirty, but libervia doesn't manage resources correctly now, refactoring is planed
300 if jid.resource:
301 cb(jid)
302 elif jid.bare in self.last_resources:
303 jid.resource = self.last_resources[jid.bare]
304 cb(jid)
305 else:
306 def gotResource(resource):
307 jid.setResource(resource)
308 cb(jid)
309 self.host.bridge.call('getLastResource', gotResource, jid.full())
310
311 def messageReceivedTrigger(self, from_jid, msg, msg_type, to_jid, extra):
312 other_jid = to_jid if from_jid.bare == self.host.whoami.bare else from_jid
313
314 def cb(jid):
315 otrctx = self.context_manager.getContextForUser(jid)
316 otrctx.receiveMessage(msg)
317 return False # interrupt the main process
318
319 self.fixResource(other_jid, cb)
320
321 def sendMessageTrigger(self, to_jid, msg, msg_type, extra):
322 def cb(jid):
323 otrctx = self.context_manager.getContextForUser(jid)
324 if msg_type != 'groupchat' and otrctx.state == otr.context.STATE_ENCRYPTED:
325 log.debug(u"encrypting message")
326 otrctx.sendMessage(msg)
327 self.host.newMessageCb(self.host.whoami, msg, msg_type, jid, extra)
328 else:
329 log.debug(u"sending message unencrypted")
330 self.host.bridge.call('sendMessage', (None, self.host.sendError), to_jid.full(), msg, '', msg_type, extra)
331
332 if msg_type != 'groupchat':
333 self.fixResource(to_jid, cb)
334 else:
335 cb(to_jid)
336 return False # interrupt the main process
337
338 # Menu callbacks
339
340 def _startRefresh(self, menu_data):
341 """Start or refresh an OTR session
342
343 @param menu_data: %(menu_data)s
344 """
345 def cb(other_jid):
346 otrctx = self.context_manager.getContextForUser(other_jid)
347 otrctx.sendQueryMessage()
348
349 try:
350 other_jid = menu_data['jid']
351 self.fixResource(other_jid, cb)
352 except KeyError:
353 log.error(_("jid key is not present !"))
354 return None
355
356 def _endSession(self, menu_data):
357 """End an OTR session
358
359 @param menu_data: %(menu_data)s
360 """
361 def cb(other_jid):
362 otrctx = self.context_manager.getContextForUser(other_jid)
363 otrctx.disconnect()
364 try:
365 other_jid = menu_data['jid']
366 self.fixResource(other_jid, cb)
367 except KeyError:
368 log.error(_("jid key is not present !"))
369 return None
370
371 def _authenticate(self, menu_data, profile):
372 """Authenticate other user and see our own fingerprint
373
374 @param menu_data: %(menu_data)s
375 @param profile: %(doc_profile)s
376 """
377 def cb(to_jid):
378 otrctx = self.context_manager.getContextForUser(to_jid)
379 otr_version = otrctx.getUsedVersion()
380 if otr_version == otr.context.OTR_VERSION_2:
381 otrctx.fingerprintAuthCb()
382 elif otr_version == otr.context.OTR_VERSION_3:
383 otrctx.smpAuthCb('question', None, 'asked')
384 else:
385 dialog.InfoDialog(_("No running session"), _("You must start a private conversation before authenticating your buddy.")).show()
386
387 try:
388 to_jid = menu_data['jid']
389 self.fixResource(to_jid, cb)
390 except KeyError:
391 log.error(_("jid key is not present !"))
392 return None
393
394 def _dropPrivkey(self, menu_data, profile):
395 """Drop our private Key
396
397 @param menu_data: %(menu_data)s
398 @param profile: %(doc_profile)s
399 """
400 def cb(to_jid):
401 priv_key = self.context_manager.account.privkey
402
403 if priv_key is None:
404 # we have no private key yet
405 dialog.InfoDialog(_("No private key"), _("You don't have any private key yet!")).show()
406 return
407
408 def dropKey(confirm):
409 if confirm:
410 # we end all sessions
411 for context in self.context_manager.contexts.values():
412 if context.state not in (otr.context.STATE_FINISHED, otr.context.STATE_PLAINTEXT):
413 context.disconnect()
414 self.context_manager.account.privkey = None
415 self.context_manager.account.getPrivkey() # as account.privkey are None, getPrivkey will generate a new key, and save it
416 dialog.InfoDialog(_("Your private key has been dropped"), _('Drop')).show()
417
418 text = _(DROP_TXT)
419 title = _('Confirm private key drop')
420 dialog.ConfirmDialog(dropKey, text, title).show()
421
422 try:
423 to_jid = menu_data['jid']
424 self.fixResource(to_jid, cb)
425 except KeyError:
426 log.error(_("jid key is not present !"))
427 return None
428