Mercurial > libervia-web
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 |