encrypted secret storage and an improved SAS verification dialog

This commit is contained in:
Brendan Taylor 2007-09-29 20:51:01 +00:00
parent 5c80d100b7
commit bd8ececb46
10 changed files with 368 additions and 163 deletions

View File

@ -177,6 +177,7 @@ class Config:
'tabs_border': [opt_bool, False, _('Show tabbed notebook border in chat windows?')], 'tabs_border': [opt_bool, False, _('Show tabbed notebook border in chat windows?')],
'tabs_close_button': [opt_bool, True, _('Show close button in tab?')], '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?')], 'log_encrypted_sessions': [opt_bool, False, _('When negotiating an encrypted session, should Gajim assume you want your messages to be logged?')],
'e2e_public_key': [opt_bool, False, _('When negotiating an encrypted session, should Gajim prefer to use public keys for identification?')],
'chat_avatar_width': [opt_int, 52], 'chat_avatar_width': [opt_int, 52],
'chat_avatar_height': [opt_int, 52], 'chat_avatar_height': [opt_int, 52],
'roster_avatar_width': [opt_int, 32], 'roster_avatar_width': [opt_int, 32],

View File

@ -110,6 +110,7 @@ class ConfigPaths:
if len(profile) > 0: if len(profile) > 0:
conffile += u'.' + profile conffile += u'.' + profile
pidfile += u'.' + profile pidfile += u'.' + profile
secretsfile += u'.' + profile
pidfile += u'.pid' pidfile += u'.pid'
self.add_from_root('CONFIG_FILE', conffile) self.add_from_root('CONFIG_FILE', conffile)
self.add_from_root('PID_FILE', pidfile) self.add_from_root('PID_FILE', pidfile)

View File

@ -1241,6 +1241,8 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco,
con.send(reply) con.send(reply)
raise common.xmpp.NodeProcessed
def _InitE2ECB(self, con, stanza, session): def _InitE2ECB(self, con, stanza, session):
gajim.log.debug('InitE2ECB') gajim.log.debug('InitE2ECB')
init = stanza.getTag(name='init', namespace=common.xmpp.NS_ESESSION_INIT) init = stanza.getTag(name='init', namespace=common.xmpp.NS_ESESSION_INIT)
@ -1248,6 +1250,8 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco,
self.dispatch('SESSION_NEG', (stanza.getFrom(), session, form)) self.dispatch('SESSION_NEG', (stanza.getFrom(), session, form))
raise common.xmpp.NodeProcessed
def _ErrorCB(self, con, iq_obj): def _ErrorCB(self, con, iq_obj):
gajim.log.debug('ErrorCB') gajim.log.debug('ErrorCB')
if iq_obj.getQueryNS() == common.xmpp.NS_VERSION: if iq_obj.getQueryNS() == common.xmpp.NS_VERSION:

View File

@ -62,6 +62,10 @@ class DecryptionError(Exception):
'''A message couldn't be decrypted into usable XML''' '''A message couldn't be decrypted into usable XML'''
pass pass
class Cancelled(Exception):
'''The user cancelled an operation'''
pass
class GajimGeneralException(Exception): class GajimGeneralException(Exception):
'''This exception is our general exception''' '''This exception is our general exception'''
def __init__(self, text=''): def __init__(self, text=''):

View File

@ -138,14 +138,9 @@ for status in ('online', 'chat', 'away', 'xa', 'dnd', 'invisible'):
HAVE_PYCRYPTO = True HAVE_PYCRYPTO = True
try: try:
from Crypto.PublicKey.RSA import generate import Crypto
import os
except ImportError: except ImportError:
HAVE_PYCRYPTO = False HAVE_PYCRYPTO = False
else:
# public key for XEP-0116
#FIXME os.urandom is not a cryptographic PRNG
pubkey = generate(384, os.urandom)
def get_nick_from_jid(jid): def get_nick_from_jid(jid):
pos = jid.find('@') pos = jid.find('@')

View File

@ -7,11 +7,8 @@ from common import exceptions
import random import random
import string import string
import math
import os
import time import time
from common import dh
import xmpp.c14n import xmpp.c14n
import base64 import base64
@ -73,6 +70,9 @@ class StanzaSession(object):
def cancelled_negotiation(self): def cancelled_negotiation(self):
'''A negotiation has been cancelled, so reset this session to its default state.''' '''A negotiation has been cancelled, so reset this session to its default state.'''
# XXX notify the user
self.status = None self.status = None
self.negotiated = {} self.negotiated = {}
@ -101,6 +101,9 @@ if gajim.HAVE_PYCRYPTO:
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from common import crypto from common import crypto
from common import dh
import secrets
# an encrypted stanza negotiation has several states. i've represented them # an encrypted stanza negotiation has several states. i've represented them
# as the following values in the 'status' # as the following values in the 'status'
# attribute of the session object: # attribute of the session object:
@ -231,11 +234,9 @@ class EncryptedStanzaSession(StanzaSession):
return compressed return compressed
def encrypt(self, encryptable): def encrypt(self, encryptable):
len_padding = 16 - (len(encryptable) % 16) padded = crypto.pad_to_multiple(encryptable, 16, ' ', False)
if len_padding != 16:
encryptable += len_padding * ' '
return self.encrypter.encrypt(encryptable) return self.encrypter.encrypt(padded)
def decrypt_stanza(self, stanza): def decrypt_stanza(self, stanza):
c = stanza.getTag(name='c', c = stanza.getTag(name='c',
@ -355,7 +356,7 @@ class EncryptedStanzaSession(StanzaSession):
def make_identity(self, form, dh_i): def make_identity(self, form, dh_i):
if self.negotiated['send_pubkey']: if self.negotiated['send_pubkey']:
if self.negotiated['sign_algs'] == (XmlDsig + 'rsa-sha256'): if self.negotiated['sign_algs'] == (XmlDsig + 'rsa-sha256'):
pubkey = gajim.interface.get_pubkey(self.conn.name) pubkey = secrets.secrets().my_pubkey(self.conn.name)
fields = (pubkey.n, pubkey.e) fields = (pubkey.n, pubkey.e)
cb_fields = map(lambda f: base64.b64encode(crypto.encode_mpi(f)), fields) cb_fields = map(lambda f: base64.b64encode(crypto.encode_mpi(f)), fields)
@ -392,7 +393,8 @@ class EncryptedStanzaSession(StanzaSession):
self.sas = crypto.sas_28x5(m_s, self.form_o) self.sas = crypto.sas_28x5(m_s, self.form_o)
if self.sigmai: if self.sigmai:
self.check_identity() # XXX save retained secret?
self.check_identity(lambda : ())
return (xmpp.DataField(name='identity', value=base64.b64encode(id_s)), \ return (xmpp.DataField(name='identity', value=base64.b64encode(id_s)), \
xmpp.DataField(name='mac', value=base64.b64encode(m_s))) xmpp.DataField(name='mac', value=base64.b64encode(m_s)))
@ -664,11 +666,13 @@ class EncryptedStanzaSession(StanzaSession):
self.kc_o, self.km_o, self.ks_o = self.generate_responder_keys(self.k) self.kc_o, self.km_o, self.ks_o = self.generate_responder_keys(self.k)
self.verify_identity(form, self.d, True, 'b') self.verify_identity(form, self.d, True, 'b')
else: else:
secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped()) srses = secrets.secrets().retained_secrets(self.conn.name, self.jid.getStripped())
rshashes = [self.hmac(self.n_s, rs) for rs in secrets] rshashes = [self.hmac(self.n_s, rs) for (rs,v) in srses]
# XXX add some random fake rshashes here if not rshashes:
rshashes.sort() # we've never spoken before, but we'll pretend we have
rshash_size = self.hash_alg.digest_size
rshashes.append(crypto.random_bytes(rshash_size))
rshashes = [base64.b64encode(rshash) for rshash in rshashes] rshashes = [base64.b64encode(rshash) for rshash in rshashes]
result.addChild(node=xmpp.DataField(name='rshashes', value=rshashes)) result.addChild(node=xmpp.DataField(name='rshashes', value=rshashes))
@ -676,7 +680,6 @@ class EncryptedStanzaSession(StanzaSession):
self.form_o = ''.join(map(lambda el: xmpp.c14n.c14n(el), form.getChildren())) 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 # MUST securely destroy K unless it will be used later to generate the final shared secret
for datafield in self.make_identity(result, e): for datafield in self.make_identity(result, e):
@ -722,10 +725,10 @@ class EncryptedStanzaSession(StanzaSession):
srs = '' srs = ''
secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped()) srses = secrets.secrets().retained_secrets(self.conn.name, self.jid.getStripped())
rshashes = [base64.b64decode(rshash) for rshash in form.getField('rshashes').getValues()] rshashes = [base64.b64decode(rshash) for rshash in form.getField('rshashes').getValues()]
for secret in secrets: for (secret, verified) in srses:
if self.hmac(self.n_o, secret) in rshashes: if self.hmac(self.n_o, secret) in rshashes:
srs = secret srs = secret
break break
@ -766,11 +769,11 @@ class EncryptedStanzaSession(StanzaSession):
def final_steps_alice(self, form): def final_steps_alice(self, form):
srs = '' srs = ''
secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped()) srses = secrets.secrets().retained_secrets(self.conn.name, self.jid.getStripped())
srshash = base64.b64decode(form['srshash']) srshash = base64.b64decode(form['srshash'])
for secret in secrets: for (secret, verified) in srses:
if self.hmac(secret, 'Shared Retained Secret') == srshash: if self.hmac(secret, 'Shared Retained Secret') == srshash:
srs = secret srs = secret
break break
@ -805,10 +808,18 @@ class EncryptedStanzaSession(StanzaSession):
bjid = self.jid.getStripped() bjid = self.jid.getStripped()
if srs: if srs:
gajim.interface.replace_secret(account, bjid, srs, new_srs) if secrets.secrets().srs_verified(account, bjid, srs):
secrets.secrets().replace_srs(account, bjid, srs, new_srs, True)
else: else:
self.check_identity() def _cb(verified):
gajim.interface.save_new_secret(account, bjid, new_srs) secrets.secrets().replace_srs(account, bjid, srs, new_srs, verified)
self.check_identity(_cb)
else:
def _cb(verified):
secrets.secrets().save_new_srs(account, bjid, new_srs, verified)
self.check_identity(_cb)
def make_dhfield(self, modp_options, sigmai): def make_dhfield(self, modp_options, sigmai):
dhs = [] dhs = []

View File

@ -294,6 +294,9 @@ class PassphraseDialog:
self.window.destroy() self.window.destroy()
if isinstance(self.ok_handler, tuple):
self.ok_handler[0](passph, checked, *self.ok_handler[1:])
else:
self.ok_handler(passph, checked) self.ok_handler(passph, checked)
def on_cancelbutton_clicked(self, widget): def on_cancelbutton_clicked(self, widget):
@ -1065,10 +1068,13 @@ class YesNoDialog(HigDialog):
class ConfirmationDialogCheck(ConfirmationDialog): class ConfirmationDialogCheck(ConfirmationDialog):
'''HIG compliant confirmation dialog with checkbutton.''' '''HIG compliant confirmation dialog with checkbutton.'''
def __init__(self, pritext, sectext='', checktext = '', def __init__(self, pritext, sectext='', checktext = '',
on_response_ok = None, on_response_cancel = None): on_response_ok = None, on_response_cancel = None, is_modal = True):
self.user_response_ok = on_response_ok
self.user_response_cancel = on_response_cancel
HigDialog.__init__(self, None, gtk.MESSAGE_QUESTION, HigDialog.__init__(self, None, gtk.MESSAGE_QUESTION,
gtk.BUTTONS_OK_CANCEL, pritext, sectext, on_response_ok, gtk.BUTTONS_OK_CANCEL, pritext, sectext, self.on_response_ok,
on_response_cancel) self.on_response_cancel)
self.set_default_response(gtk.RESPONSE_OK) self.set_default_response(gtk.RESPONSE_OK)
@ -1077,8 +1083,19 @@ class ConfirmationDialogCheck(ConfirmationDialog):
self.checkbutton = gtk.CheckButton(checktext) self.checkbutton = gtk.CheckButton(checktext)
self.vbox.pack_start(self.checkbutton, expand = False, fill = True) self.vbox.pack_start(self.checkbutton, expand = False, fill = True)
self.set_modal(is_modal)
self.popup() self.popup()
# XXX should cancel if somebody closes the dialog
def on_response_ok(self, widget):
self.user_response_ok(self.is_checked())
self.destroy()
def on_response_cancel(self, widget):
self.user_response_cancel()
self.destroy()
def is_checked(self): def is_checked(self):
''' Get active state of the checkbutton ''' ''' Get active state of the checkbutton '''
return self.checkbutton.get_active() return self.checkbutton.get_active()

View File

@ -126,8 +126,6 @@ from common import dbus_support
if dbus_support.supported: if dbus_support.supported:
import dbus import dbus
import pickle
if os.name == 'posix': # dl module is Unix Only if os.name == 'posix': # dl module is Unix Only
try: # rename the process name to gajim try: # rename the process name to gajim
import dl import dl
@ -222,7 +220,6 @@ gajimpaths = common.configpaths.gajimpaths
pid_filename = gajimpaths['PID_FILE'] pid_filename = gajimpaths['PID_FILE']
config_filename = gajimpaths['CONFIG_FILE'] config_filename = gajimpaths['CONFIG_FILE']
secrets_filename = gajimpaths['SECRETS_FILE']
import traceback import traceback
import errno import errno
@ -1529,48 +1526,6 @@ class Interface:
if os.path.isfile(path_to_file + '_notif_size_bw' + ext): if os.path.isfile(path_to_file + '_notif_size_bw' + ext):
os.remove(path_to_file + '_notif_size_bw' + ext) os.remove(path_to_file + '_notif_size_bw' + ext)
# 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): def add_event(self, account, jid, type_, event_args):
'''add an event to the gajim.events var''' '''add an event to the gajim.events var'''
# We add it to the gajim.events queue # We add it to the gajim.events queue
@ -1867,6 +1822,7 @@ class Interface:
# encrypted session states. these are described in stanza_session.py # encrypted session states. these are described in stanza_session.py
try:
# bob responds # bob responds
if form.getType() == 'form' and 'security' in form.asDict(): if form.getType() == 'form' and 'security' in form.asDict():
def continue_with_negotiation(*args): def continue_with_negotiation(*args):
@ -1925,7 +1881,10 @@ class Interface:
negotiated, not_acceptable, ask_user = session.verify_options_alice(form) negotiated, not_acceptable, ask_user = session.verify_options_alice(form)
if session.sigmai: if session.sigmai:
session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas) def _cb(on_success):
negotiation.show_sas_dialog(session, jid, session.sas, on_success)
session.check_identity = _cb
if ask_user: if ask_user:
def accept_nondefault_options(widget): def accept_nondefault_options(widget):
@ -1954,7 +1913,11 @@ class Interface:
return return
elif session.status == 'responded-e2e' and form.getType() == 'result': elif session.status == 'responded-e2e' and form.getType() == 'result':
session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas)
def _cb(on_success):
negotiation.show_sas_dialog(session, jid, session.sas, on_success)
session.check_identity = _cb
try: try:
session.accept_e2e_bob(form) session.accept_e2e_bob(form)
@ -1963,7 +1926,10 @@ class Interface:
return return
elif session.status == 'identified-alice' and form.getType() == 'result': elif session.status == 'identified-alice' and form.getType() == 'result':
session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas) def _cb(on_success):
negotiation.show_sas_dialog(session, jid, session.sas, on_success)
session.check_identity = _cb
try: try:
session.final_steps_alice(form) session.final_steps_alice(form)
@ -1971,6 +1937,12 @@ class Interface:
session.fail_bad_negotiation(details) session.fail_bad_negotiation(details)
return return
except exceptions.Cancelled:
# user cancelled the negotiation
session.cancelled_negotiation()
return
if form.getField('terminate'): if form.getField('terminate'):
if form.getField('terminate').getValue() in ('1', 'true'): if form.getField('terminate').getValue() in ('1', 'true'):
@ -2824,11 +2796,5 @@ if __name__ == '__main__':
check_paths.check_and_possibly_create_paths() 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() Interface()
gtk.main() gtk.main()

View File

@ -14,10 +14,22 @@ def describe_features(features):
elif features['logging'] == 'mustnot': elif features['logging'] == 'mustnot':
return _('- messages will not be logged') return _('- messages will not be logged')
def show_sas_dialog(jid, sas): def show_sas_dialog(session, jid, sas, on_success):
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. def success_cb(checked):
on_success(checked)
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)) def failure_cb():
session.cancelled_negotiation()
dialogs.ConfirmationDialogCheck(_('''OK to continue with negotiation?'''),
_('''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
Would you like to continue with the encrypted session?''') % (jid, sas),
_('Yes, I verified the Short Authentication String'),
on_response_ok=success_cb, on_response_cancel=failure_cb, is_modal=False)
class FeatureNegotiationWindow: class FeatureNegotiationWindow:
'''FeatureNegotiotionWindow class''' '''FeatureNegotiotionWindow class'''

194
src/secrets.py Normal file
View File

@ -0,0 +1,194 @@
from common.configpaths import gajimpaths
from common import crypto
from common import exceptions
import dialogs
import os
import pickle
import gtk
import Crypto.Cipher.AES
import Crypto.Hash.SHA256
import Crypto.PublicKey.RSA
secrets_filename = gajimpaths['SECRETS_FILE']
secrets_cache = None
secrets_cipher = None
secrets_counter = None
# strength of the encryption used on SECRETS_FILE
n = 256
class Counter:
def __init__(self, n, iv):
self.n = n
self.c = crypto.decode_mpi(iv)
def __call__(self):
self.c = (self.c + 1) % (2 ** self.n)
return crypto.encode_mpi_with_padding(self.c)
# return en/decrypter if it's cached, otherwise create it from the user's
# passphrase
def get_key(counter, passph=None):
global secrets_cipher, secrets_counter
if secrets_cipher:
return secrets_cipher
if not passph:
passph, checked = dialogs.PassphraseDialog(_('Passphrase Required'),
_('To continue, Gajim needs to access your stored secrets. Enter your passphrase')
).run()
if passph == -1:
raise exceptions.Cancelled
sh = Crypto.Hash.SHA256.new()
sh.update(passph)
key = sh.digest()
secrets_counter = counter
secrets_cipher = Crypto.Cipher.AES.new(key, Crypto.Cipher.AES.MODE_CTR,
counter=secrets_counter)
return secrets_cipher
class Secrets:
def __init__(self, filename):
self.filename = filename
self.srs = {}
self.pubkeys = {}
self.privkeys = {}
def _save(self):
global secrets_cipher, secrets_counter
old_counter = secrets_counter.c
# pickle doesn't appear to have problems with trailing whitespace
padded = crypto.pad_to_multiple(pickle.dumps(self), n / 8, ' ', False)
encrypted = secrets_cipher.encrypt(padded)
f = open(secrets_filename, 'w')
f.write(crypto.encode_mpi_with_padding(old_counter) + encrypted)
f.close()
def cancel(self):
raise exceptions.Cancelled
def save(self):
passph1 = None
def _cont1(passph, checked):
dialogs.PassphraseDialog(_('Confirm Passphrase'),
_('Enter your new passphrase again for confirmation'),
is_modal=False, ok_handler=(_cont2, passph), cancel_handler=self.cancel)
def _cont2(passph, checked, passph1):
if passph != passph1:
dialogs.PassphraseDialog(_('Create Passphrase'),
_('Passphrases did not match.\n') +
_('Gajim needs you to create a passphrase to encrypt stored secrets'),
is_modal=False, ok_handler=_cont1, cancel_handler=self.cancel)
return
counter = Counter(16, crypto.random_bytes(16))
get_key(counter, passph1)
self._save()
if not os.path.exists(self.filename):
dialogs.PassphraseDialog(_('Create Passphrase'),
_('Gajim needs you to create a passphrase to encrypt stored secrets'),
is_modal=False, ok_handler=_cont1, cancel_handler=self.cancel)
else:
self._save()
def retained_secrets(self, account, bare_jid):
try:
return self.srs[account][bare_jid]
except KeyError:
return []
# retained secrets are stored as a tuple of the secret and whether the user
# has verified it
def save_new_srs(self, account, jid, secret, verified):
if not account in self.srs:
self.srs[account] = {}
if not jid in self.srs[account]:
self.srs[account][jid] = []
self.srs[account][jid].append((secret, verified))
self.save()
def find_srs(self, account, jid, srs):
our_secrets = self.srs[account][jid]
return filter(lambda (x,y): x == srs, our_secrets)[0]
# has the user verified this retained secret?
def srs_verified(self, account, jid, srs):
return self.find_srs(account, jid, srs)[1]
def replace_srs(self, account, jid, old_secret, new_secret, verified):
our_secrets = self.srs[account][jid]
idx = our_secrets.index(self.find_srs(account, jid, old_secret))
our_secrets[idx] = (new_secret, verified)
self.save()
# the public key associated with 'account'
def my_pubkey(self, account):
try:
pk = self.privkeys[account]
except KeyError:
pk = Crypto.PublicKey.RSA.generate(384, crypto.random_bytes)
self.privkeys[account] = pk
self.save()
return pk
def load_secrets(filename):
f = open(filename, 'r')
counter = Counter(16, f.read(16))
decrypted = get_key(counter).decrypt(f.read())
try:
secrets = pickle.loads(decrypted)
except:
f.close()
global secrets_cipher
secrets_cipher = None
return load_secrets(filename)
else:
f.close()
return secrets
def secrets():
global secrets_cache
if secrets_cache:
return secrets_cache
if os.path.exists(secrets_filename):
secrets_cache = load_secrets(secrets_filename)
else:
secrets_cache = Secrets(secrets_filename)
return secrets_cache