diff options
-rw-r--r-- | data/default_config.cfg | 10 | ||||
-rw-r--r-- | doc/source/commands.rst | 43 | ||||
-rw-r--r-- | doc/source/configuration.rst | 16 | ||||
-rw-r--r-- | doc/source/misc/client_certs.rst | 43 | ||||
-rw-r--r-- | doc/source/misc/index.rst | 1 | ||||
-rw-r--r-- | src/config.py | 2 | ||||
-rw-r--r-- | src/connection.py | 16 | ||||
-rw-r--r-- | src/core/handlers.py | 1 | ||||
-rw-r--r-- | src/tabs/rostertab.py | 166 |
9 files changed, 293 insertions, 5 deletions
diff --git a/data/default_config.cfg b/data/default_config.cfg index 39899d31..36ca7858 100644 --- a/data/default_config.cfg +++ b/data/default_config.cfg @@ -15,6 +15,16 @@ jid = # If you leave this empty, the password will be asked at each startup password = +# Path to a PEM certificate file to use for certificate authentication +# through SASL External. If set, keyfile MUST be provided as well in +# order to login. +certfile = + +# Path to a PEM private key file to use for certificate authentication +# through SASL External. If set, certfile MUST be provided as well in +# order to login. +keyfile = + # the nick you will use when joining a room with no associated nick # If this is empty, the $USER environnement variable will be used default_nick = diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 395b396b..f8f2b5e1 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -312,10 +312,10 @@ MultiUserChat tab commands .. glossary:: :sorted: - /clear [RosterTab version] + /clear [MUCTab version] **Usage:** ``/clear`` - Clear the information buffer. (was /clear_infos) + Clear the messages buffer. /ignore **Usage:** ``/ignore <nickname>`` @@ -502,8 +502,8 @@ Roster tab commands Disconnect from the remote server (if connected) and then connect to it again. -.. note:: The following commands only exist if your server supports them. If it - does not, you will be notified when you start poezio. +.. note:: The following commands only exist if your server announces it + supports them. .. glossary:: :sorted: @@ -523,6 +523,41 @@ Roster tab commands /list_blocks List the blocked JIDs. + /certs + + List the remotely stored X.509 certificated allowed to connect + to your accounts. + + /cert_add + **Usage:** ``/cert_add <name> <certificate file> [management]`` + + Add a client X.509 certificate to the list of the certificates + which grand access to your account. It must have an unique name + the file must be in PEM format. ``[management]`` is true by + default and specifies if the clients connecting with this + particular certificate will be able to manage the list of + authorized certificates. + + /cert_disable + **Usage:** ``/cert_disable <name>`` + + Remove a certificate from the authorized list. Clients currently + connected with the certificate identified by ``<name>`` will + however **not** be disconnected. + + /cert_revoke + **Usage:** ``/cert_revoke <name>`` + + Remove a certificate from the authorized list. Clients currently + connected with the certificate identified by ``<name>`` **will** + be disconnected. + + /cert_fetch + **Usage:** ``/cert_fetch <name> <path>`` + + Download the public key of the authorized certificate identified by + ``name`` from the XMPP server, and store it in ``<path>``. + .. note:: The following commands do not comply with any XEP or whatever, but they can still prove useful when you are migrating to an other JID. diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index b32cbec3..b7099020 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -156,6 +156,22 @@ Options related to account configuration, nicknameā¦ your alternative nickname will be "john\_". + keyfile + + **Default value:** ``[empty]`` + + Path to a PEM private key file to use for certificate authentication + through SASL External. If set, :term:`certfile` **MUST** be set as well + in order to login. + + certfile + + **Default value:** ``[empty]`` + + Path to a PEM certificate file to use for certificate authentication + through SASL External. If set, :term:`keyfile` **MUST** be set as well + in order to login. + resource **Default value:** ``[empty]`` diff --git a/doc/source/misc/client_certs.rst b/doc/source/misc/client_certs.rst new file mode 100644 index 00000000..df09ea3c --- /dev/null +++ b/doc/source/misc/client_certs.rst @@ -0,0 +1,43 @@ +Using client certificates to login +================================== + +Passwordless authentication is possible in XMPP through the use of mecanisms +such as `SASL External`_. This mechanism has to be supported by both the client +and the server. This page does not cover the server setup, but prosody has a +`mod_client_certs`_ module which can perform this kind of authentication, and +also helps you create a self-signed certificate. + +Poezio configuration +-------------------- + +If you created a certificate using the above link, you should have at least +two files, a ``.crt`` (public key in PEM format) and a ``.key`` (private key +in PEM format). + +You only have to store the files wherever you want and set :term:`keyfile` +with the path to the private key (``.key``), and :term:`certfile` with the +path to the public key (``.crt``). + +Authorizing your keys +--------------------- + +Now your poezio is setup to try to use client certificates at each connection. +However, you still need to inform your XMPP server that you want to allow +those keys to access your account. + +This is done through :term:`/cert_add`. Once you have added your certificate, +you can try to connect without a password by commenting the option. + +.. note:: The :term:`/cert_add` command and the others are only available if + your server supports them. + +Next +---- +Now that this is setup, you might want to use :term:`/certs` to list the +keys currently known by your XMPP server, :term:`/cert_revoke` or +:term:`/cert_disable` to remove them, and :term:`/cert_fetch` to retrieve +a public key. + + +.. _SASL External: http://xmpp.org/extensions/xep-0178.html +.. _mod_client_certs: https://code.google.com/p/prosody-modules/wiki/mod_client_certs diff --git a/doc/source/misc/index.rst b/doc/source/misc/index.rst index fe8f1100..2603298e 100644 --- a/doc/source/misc/index.rst +++ b/doc/source/misc/index.rst @@ -7,6 +7,7 @@ Contents: :maxdepth: 2 carbons + client_certs correct personal_events pyenv diff --git a/src/config.py b/src/config.py index 390fe7f1..86aae8ef 100644 --- a/src/config.py +++ b/src/config.py @@ -34,6 +34,7 @@ DEFAULT_CONFIG = { 'beep_on': 'highlight private invite', 'ca_cert_path': '', 'certificate': '', + 'certfile': '', 'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL', 'connection_check_interval': 60, 'connection_timeout_delay': 10, @@ -68,6 +69,7 @@ DEFAULT_CONFIG = { 'ignore_private': False, 'information_buffer_popup_on': 'error roster warning help info', 'jid': '', + 'keyfile': '', 'lang': 'en', 'lazy_resize': True, 'load_log': 10, diff --git a/src/connection.py b/src/connection.py index 1bbe632d..cd2ccedd 100644 --- a/src/connection.py +++ b/src/connection.py @@ -30,6 +30,10 @@ class Connection(slixmpp.ClientXMPP): __init = False def __init__(self): resource = config.get('resource') + + keyfile = config.get('keyfile') + certfile = config.get('certfile') + if config.get('jid'): # Field used to know if we are anonymous or not. # many features will be handled differently @@ -38,7 +42,9 @@ class Connection(slixmpp.ClientXMPP): jid = '%s' % config.get('jid') if resource: jid = '%s/%s'% (jid, resource) - password = config.get('password') or getpass.getpass() + password = config.get('password') + if not password and not (keyfile and certfile): + password = getpass.getpass() else: # anonymous auth self.anon = True jid = config.get('server') @@ -57,6 +63,13 @@ class Connection(slixmpp.ClientXMPP): self['feature_mechanisms'].unencrypted_cram = False self['feature_mechanisms'].unencrypted_scram = False + self.keyfile = config.get('keyfile') + self.certfile = config.get('certfile') + if keyfile and not certfile: + log.error('keyfile is present in configuration file without certfile') + elif certfile and not keyfile: + log.error('certfile is present in configuration file without keyfile') + self.core = None self.auto_reconnect = config.get('auto_reconnect') self.reconnect_max_attempts = 0 @@ -127,6 +140,7 @@ class Connection(slixmpp.ClientXMPP): self.register_plugin('xep_0202') self.register_plugin('xep_0224') self.register_plugin('xep_0249') + self.register_plugin('xep_0257') self.register_plugin('xep_0280') self.register_plugin('xep_0297') self.register_plugin('xep_0308') diff --git a/src/core/handlers.py b/src/core/handlers.py index 6b613a93..a1e8596c 100644 --- a/src/core/handlers.py +++ b/src/core/handlers.py @@ -58,6 +58,7 @@ def on_session_start_features(self, _): features = iq['disco_info']['features'] rostertab = self.get_tab_by_name('Roster', tabs.RosterInfoTab) rostertab.check_blocking(features) + rostertab.check_saslexternal(features) if (config.get('enable_carbons') and 'urn:xmpp:carbons:2' in features): self.xmpp.plugin['xep_0280'].enable() diff --git a/src/tabs/rostertab.py b/src/tabs/rostertab.py index 52a3a88c..846ba327 100644 --- a/src/tabs/rostertab.py +++ b/src/tabs/rostertab.py @@ -10,9 +10,11 @@ from gettext import gettext as _ import logging log = logging.getLogger(__name__) +import base64 import curses import difflib import os +import ssl from os import getenv, path from . import Tab @@ -146,6 +148,170 @@ class RosterInfoTab(Tab): self.core.xmpp.del_event_handler('session_start', self.check_blocking) self.core.xmpp.add_event_handler('blocked_message', self.on_blocked_message) + def check_saslexternal(self, features): + if 'urn:xmpp:saslcert:1' in features: + self.register_command('certs', self.command_certs, + desc=_('List the fingerprints of certificates' + ' which can connect to your account.'), + shortdesc=_('List allowed client certs.')) + self.register_command('cert_add', self.command_cert_add, + desc=_('Add a client certificate to the authorized ones. ' + 'It must have an unique name and be contained in ' + 'a PEM file. [management] is a boolean indicating' + ' if a client connected using this certificate can' + ' manage the certificates itself.'), + shortdesc=_('Add a client certificate.'), + usage='<name> <certificate path> [management]') + self.register_command('cert_disable', self.command_cert_disable, + desc=_('Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will not be ' + 'forcefully disconnected.'), + shortdesc=_('Disable a certificate'), + usage='<name>') + self.register_command('cert_revoke', self.command_cert_revoke, + desc=_('Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will be ' + 'forcefully disconnected.'), + shortdesc=_('Revoke a certificate'), + usage='<name>') + self.register_command('cert_fetch', self.command_cert_fetch, + desc=_('Retrieve a certificate with its ' + 'name. It will be stored in <path>.'), + shortdesc=_('Fetch a certificate'), + usage='<name> <path>') + + @command_args_parser.ignored + def command_certs(self): + """ + /certs + """ + def cb(iq): + if iq['type'] == 'error': + self.core.information(_('Unable to retrieve the certificate list.'), + _('Error')) + return + certs = [] + for item in iq['sasl_certs']['items']: + users = '\n'.join(item['users']) + certs.append((item['name'], users)) + + if not certs: + return self.core.information(_('No certificates found'), _('Info')) + msg = _('Certificates:\n') + msg += '\n'.join(((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1])) for item in certs)) + self.core.information(msg, 'Info') + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3) + + @command_args_parser.quoted(2, 1) + def command_cert_add(self, args): + """ + /cert_add <name> <certfile> [cert-management] + """ + if not args or len(args) < 2: + return self.core.command_help('cert_add') + def cb(iq): + if iq['type'] == 'error': + self.core.information(_('Unable to add the certificate.'), _('Error')) + else: + self.core.information(_('Certificate added.'), _('Info')) + + name = args[0] + + try: + with open(args[1]) as fd: + crt = fd.read() + crt = crt.replace(ssl.PEM_FOOTER, '').replace(ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '') + except Exception as e: + self.core.information('Unable to read the certificate: %s' % e, 'Error') + return + + if len(args) > 2: + management = args[2] + if management: + management = management.lower() + if management not in ('false', '0'): + management = True + else: + management = False + else: + management = False + else: + management = True + + self.core.xmpp.plugin['xep_0257'].add_cert(name, crt, callback=cb, + allow_management=management) + + @command_args_parser.quoted(1) + def command_cert_disable(self, args): + """ + /cert_disable <name> + """ + if not args: + return self.core.command_help('cert_disable') + def cb(iq): + if iq['type'] == 'error': + self.core.information(_('Unable to disable the certificate.'), _('Error')) + else: + self.core.information(_('Certificate disabled.'), _('Info')) + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb) + + @command_args_parser.quoted(1) + def command_cert_revoke(self, args): + """ + /cert_revoke <name> + """ + if not args: + return self.core.command_help('cert_revoke') + def cb(iq): + if iq['type'] == 'error': + self.core.information(_('Unable to revoke the certificate.'), _('Error')) + else: + self.core.information(_('Certificate revoked.'), _('Info')) + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb) + + + @command_args_parser.quoted(2) + def command_cert_fetch(self, args): + """ + /cert_fetch <name> <path> + """ + if not args or len(args) < 2: + return self.core.command_help('cert_fetch') + def cb(iq): + if iq['type'] == 'error': + self.core.information(_('Unable to fetch the certificate.'), + _('Error')) + return + + cert = None + for item in iq['sasl_certs']['items']: + if item['name'] == name: + cert = base64.b64decode(item['x509cert']) + break + + if not cert: + return self.core.information(_('Certificate not found.'), _('Info')) + + cert = ssl.DER_cert_to_PEM_cert(cert) + with open(path, 'w') as fd: + fd.write(cert) + + self.core.information(_('File stored at %s') % path, 'Info') + + name = args[0] + path = args[1] + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb) + def on_blocked_message(self, message): """ When we try to send a message to a blocked contact |