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_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?')],
'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_height': [opt_int, 52],
'roster_avatar_width': [opt_int, 32],

View File

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

View File

@ -1241,6 +1241,8 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco,
con.send(reply)
raise common.xmpp.NodeProcessed
def _InitE2ECB(self, con, stanza, session):
gajim.log.debug('InitE2ECB')
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))
raise common.xmpp.NodeProcessed
def _ErrorCB(self, con, iq_obj):
gajim.log.debug('ErrorCB')
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'''
pass
class Cancelled(Exception):
'''The user cancelled an operation'''
pass
class GajimGeneralException(Exception):
'''This exception is our general exception'''
def __init__(self, text=''):

View File

@ -138,14 +138,9 @@ for status in ('online', 'chat', 'away', 'xa', 'dnd', 'invisible'):
HAVE_PYCRYPTO = True
try:
from Crypto.PublicKey.RSA import generate
import os
import Crypto
except ImportError:
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):
pos = jid.find('@')

View File

@ -7,11 +7,8 @@ from common import exceptions
import random
import string
import math
import os
import time
from common import dh
import xmpp.c14n
import base64
@ -73,6 +70,9 @@ class StanzaSession(object):
def cancelled_negotiation(self):
'''A negotiation has been cancelled, so reset this session to its default state.'''
# XXX notify the user
self.status = None
self.negotiated = {}
@ -101,6 +101,9 @@ if gajim.HAVE_PYCRYPTO:
from Crypto.PublicKey import RSA
from common import crypto
from common import dh
import secrets
# an encrypted stanza negotiation has several states. i've represented them
# as the following values in the 'status'
# attribute of the session object:
@ -231,11 +234,9 @@ class EncryptedStanzaSession(StanzaSession):
return compressed
def encrypt(self, encryptable):
len_padding = 16 - (len(encryptable) % 16)
if len_padding != 16:
encryptable += len_padding * ' '
padded = crypto.pad_to_multiple(encryptable, 16, ' ', False)
return self.encrypter.encrypt(encryptable)
return self.encrypter.encrypt(padded)
def decrypt_stanza(self, stanza):
c = stanza.getTag(name='c',
@ -355,7 +356,7 @@ class EncryptedStanzaSession(StanzaSession):
def make_identity(self, form, dh_i):
if self.negotiated['send_pubkey']:
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)
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)
if self.sigmai:
self.check_identity()
# XXX save retained secret?
self.check_identity(lambda : ())
return (xmpp.DataField(name='identity', value=base64.b64encode(id_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.verify_identity(form, self.d, True, 'b')
else:
secrets = gajim.interface.list_secrets(self.conn.name, self.jid.getStripped())
rshashes = [self.hmac(self.n_s, rs) for rs in secrets]
srses = secrets.secrets().retained_secrets(self.conn.name, self.jid.getStripped())
rshashes = [self.hmac(self.n_s, rs) for (rs,v) in srses]
# XXX add some random fake rshashes here
rshashes.sort()
if not rshashes:
# 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]
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()))
# MUST securely destroy K unless it will be used later to generate the final shared secret
for datafield in self.make_identity(result, e):
@ -722,10 +725,10 @@ class EncryptedStanzaSession(StanzaSession):
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()]
for secret in secrets:
for (secret, verified) in srses:
if self.hmac(self.n_o, secret) in rshashes:
srs = secret
break
@ -766,11 +769,11 @@ class EncryptedStanzaSession(StanzaSession):
def final_steps_alice(self, form):
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'])
for secret in secrets:
for (secret, verified) in srses:
if self.hmac(secret, 'Shared Retained Secret') == srshash:
srs = secret
break
@ -805,10 +808,18 @@ class EncryptedStanzaSession(StanzaSession):
bjid = self.jid.getStripped()
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:
def _cb(verified):
secrets.secrets().replace_srs(account, bjid, srs, new_srs, verified)
self.check_identity(_cb)
else:
self.check_identity()
gajim.interface.save_new_secret(account, bjid, new_srs)
def _cb(verified):
secrets.secrets().save_new_srs(account, bjid, new_srs, verified)
self.check_identity(_cb)
def make_dhfield(self, modp_options, sigmai):
dhs = []

View File

@ -294,7 +294,10 @@ class PassphraseDialog:
self.window.destroy()
self.ok_handler(passph, checked)
if isinstance(self.ok_handler, tuple):
self.ok_handler[0](passph, checked, *self.ok_handler[1:])
else:
self.ok_handler(passph, checked)
def on_cancelbutton_clicked(self, widget):
self.window.destroy()
@ -1065,10 +1068,13 @@ class YesNoDialog(HigDialog):
class ConfirmationDialogCheck(ConfirmationDialog):
'''HIG compliant confirmation dialog with checkbutton.'''
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,
gtk.BUTTONS_OK_CANCEL, pritext, sectext, on_response_ok,
on_response_cancel)
gtk.BUTTONS_OK_CANCEL, pritext, sectext, self.on_response_ok,
self.on_response_cancel)
self.set_default_response(gtk.RESPONSE_OK)
@ -1077,8 +1083,19 @@ class ConfirmationDialogCheck(ConfirmationDialog):
self.checkbutton = gtk.CheckButton(checktext)
self.vbox.pack_start(self.checkbutton, expand = False, fill = True)
self.set_modal(is_modal)
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):
''' Get active state of the checkbutton '''
return self.checkbutton.get_active()

View File

@ -126,8 +126,6 @@ from common import dbus_support
if dbus_support.supported:
import dbus
import pickle
if os.name == 'posix': # dl module is Unix Only
try: # rename the process name to gajim
import dl
@ -222,7 +220,6 @@ gajimpaths = common.configpaths.gajimpaths
pid_filename = gajimpaths['PID_FILE']
config_filename = gajimpaths['CONFIG_FILE']
secrets_filename = gajimpaths['SECRETS_FILE']
import traceback
import errno
@ -1529,48 +1526,6 @@ class Interface:
if os.path.isfile(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):
'''add an event to the gajim.events var'''
# We add it to the gajim.events queue
@ -1867,108 +1822,125 @@ class Interface:
# encrypted session states. these are described in stanza_session.py
# bob responds
if form.getType() == 'form' and 'security' in form.asDict():
def continue_with_negotiation(*args):
if len(args):
try:
# bob responds
if form.getType() == 'form' and 'security' in form.asDict():
def continue_with_negotiation(*args):
if len(args):
self.dialog.destroy()
# we don't support 3-message negotiation as the responder
if 'dhkeys' in form.asDict():
err = xmpp.Error(xmpp.Message(), xmpp.ERR_FEATURE_NOT_IMPLEMENTED)
feature = xmpp.Node(xmpp.NS_FEATURE + ' feature')
field = xmpp.Node('field')
field['var'] = 'dhkeys'
feature.addChild(node=field)
err.addChild(node=feature)
session.send(err)
return
negotiated, not_acceptable, ask_user = session.verify_options_bob(form)
if ask_user:
def accept_nondefault_options(widget):
self.dialog.destroy()
negotiated.update(ask_user)
session.respond_e2e_bob(form, negotiated, not_acceptable)
def reject_nondefault_options(widget):
self.dialog.destroy()
for key in ask_user.keys():
not_acceptable.append(key)
session.respond_e2e_bob(form, negotiated, not_acceptable)
self.dialog = dialogs.YesNoDialog(_('Confirm these session options'),
_('''The remote client wants to negotiate an session with these features:
%s
Are these options acceptable?''') % (negotiation.describe_features(ask_user)),
on_response_yes = accept_nondefault_options,
on_response_no = reject_nondefault_options)
else:
session.respond_e2e_bob(form, negotiated, not_acceptable)
def ignore_negotiation(widget):
self.dialog.destroy()
# we don't support 3-message negotiation as the responder
if 'dhkeys' in form.asDict():
err = xmpp.Error(xmpp.Message(), xmpp.ERR_FEATURE_NOT_IMPLEMENTED)
feature = xmpp.Node(xmpp.NS_FEATURE + ' feature')
field = xmpp.Node('field')
field['var'] = 'dhkeys'
feature.addChild(node=field)
err.addChild(node=feature)
session.send(err)
return
negotiated, not_acceptable, ask_user = session.verify_options_bob(form)
continue_with_negotiation()
return
# alice accepts
elif session.status == 'requested-e2e' and form.getType() == 'submit':
negotiated, not_acceptable, ask_user = session.verify_options_alice(form)
if session.sigmai:
def _cb(on_success):
negotiation.show_sas_dialog(session, jid, session.sas, on_success)
session.check_identity = _cb
if ask_user:
def accept_nondefault_options(widget):
self.dialog.destroy()
dialog.destroy()
negotiated.update(ask_user)
session.respond_e2e_bob(form, negotiated, not_acceptable)
try:
session.accept_e2e_alice(form, negotiated)
except exceptions.NegotiationError, details:
session.fail_bad_negotiation(details)
def reject_nondefault_options(widget):
self.dialog.destroy()
for key in ask_user.keys():
not_acceptable.append(key)
session.respond_e2e_bob(form, negotiated, not_acceptable)
session.reject_negotiation()
dialog.destroy()
self.dialog = dialogs.YesNoDialog(_('Confirm these session options'),
_('''The remote client wants to negotiate an session with these features:
%s
Are these options acceptable?''') % (negotiation.describe_features(ask_user)),
dialog = dialogs.YesNoDialog(_('Confirm these session options'),
_('The remote client selected these options:\n\n%s\n\nContinue with the session?') % (negotiation.describe_features(ask_user)),
on_response_yes = accept_nondefault_options,
on_response_no = reject_nondefault_options)
else:
session.respond_e2e_bob(form, negotiated, not_acceptable)
def ignore_negotiation(widget):
self.dialog.destroy()
return
continue_with_negotiation()
return
# alice accepts
elif session.status == 'requested-e2e' and form.getType() == 'submit':
negotiated, not_acceptable, ask_user = session.verify_options_alice(form)
if session.sigmai:
session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas)
if ask_user:
def accept_nondefault_options(widget):
dialog.destroy()
negotiated.update(ask_user)
try:
session.accept_e2e_alice(form, negotiated)
except exceptions.NegotiationError, details:
session.fail_bad_negotiation(details)
def reject_nondefault_options(widget):
session.reject_negotiation()
dialog.destroy()
return
elif session.status == 'responded-e2e' and form.getType() == 'result':
def _cb(on_success):
negotiation.show_sas_dialog(session, jid, session.sas, on_success)
session.check_identity = _cb
dialog = dialogs.YesNoDialog(_('Confirm these session options'),
_('The remote client selected these options:\n\n%s\n\nContinue with the session?') % (negotiation.describe_features(ask_user)),
on_response_yes = accept_nondefault_options,
on_response_no = reject_nondefault_options)
else:
try:
session.accept_e2e_alice(form, negotiated)
session.accept_e2e_bob(form)
except exceptions.NegotiationError, details:
session.fail_bad_negotiation(details)
return
elif session.status == 'responded-e2e' and form.getType() == 'result':
session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas)
return
elif session.status == 'identified-alice' and form.getType() == 'result':
def _cb(on_success):
negotiation.show_sas_dialog(session, jid, session.sas, on_success)
try:
session.accept_e2e_bob(form)
except exceptions.NegotiationError, details:
session.fail_bad_negotiation(details)
session.check_identity = _cb
return
elif session.status == 'identified-alice' and form.getType() == 'result':
session.check_identity = lambda: negotiation.show_sas_dialog(jid, session.sas)
try:
session.final_steps_alice(form)
except exceptions.NegotiationError, details:
session.fail_bad_negotiation(details)
try:
session.final_steps_alice(form)
except exceptions.NegotiationError, details:
session.fail_bad_negotiation(details)
return
except exceptions.Cancelled:
# user cancelled the negotiation
session.cancelled_negotiation()
return
@ -2824,11 +2796,5 @@ if __name__ == '__main__':
check_paths.check_and_possibly_create_paths()
# create secrets file (unless it exists)
if not os.path.exists(secrets_filename):
f = open(secrets_filename, 'w')
pickle.dump({}, f)
f.close()
Interface()
gtk.main()

View File

@ -14,10 +14,22 @@ def describe_features(features):
elif features['logging'] == 'mustnot':
return _('- messages will not be logged')
def show_sas_dialog(jid, sas):
dialogs.InformationDialog(_('''Verify the remote client's identity'''), _('''You've begun an encrypted session with %s, but it can't be guaranteed that you're talking directly to the person you think you are.
def show_sas_dialog(session, jid, sas, on_success):
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:
'''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