diff options
Diffstat (limited to 'src/leap/common/keymanager')
-rw-r--r-- | src/leap/common/keymanager/__init__.py | 286 | ||||
-rw-r--r-- | src/leap/common/keymanager/errors.py | 46 | ||||
-rw-r--r-- | src/leap/common/keymanager/gpg.py | 397 | ||||
-rw-r--r-- | src/leap/common/keymanager/keys.py | 230 | ||||
-rw-r--r-- | src/leap/common/keymanager/openpgp.py | 459 |
5 files changed, 1418 insertions, 0 deletions
diff --git a/src/leap/common/keymanager/__init__.py b/src/leap/common/keymanager/__init__.py new file mode 100644 index 0000000..d6dbb8a --- /dev/null +++ b/src/leap/common/keymanager/__init__.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +# __init__.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Key Manager is a Nicknym agent for LEAP client. +""" + +import requests + +try: + import simplejson as json +except ImportError: + import json # noqa + +from leap.common.check import leap_assert +from leap.common.keymanager.errors import ( + KeyNotFound, + NoPasswordGiven, +) +from leap.common.keymanager.keys import ( + build_key_from_dict, +) +from leap.common.keymanager.openpgp import ( + OpenPGPKey, + OpenPGPScheme, + encrypt_sym, +) + + +TAGS_INDEX = 'by-tags' +TAGS_AND_PRIVATE_INDEX = 'by-tags-and-private' +INDEXES = { + TAGS_INDEX: ['tags'], + TAGS_AND_PRIVATE_INDEX: ['tags', 'bool(private)'], +} + + +class KeyManager(object): + + def __init__(self, address, nickserver_url, soledad, token=None): + """ + Initialize a Key Manager for user's C{address} with provider's + nickserver reachable in C{url}. + + @param address: The address of the user of this Key Manager. + @type address: str + @param url: The URL of the nickserver. + @type url: str + @param soledad: A Soledad instance for local storage of keys. + @type soledad: leap.soledad.Soledad + """ + self._address = address + self._nickserver_url = nickserver_url + self._soledad = soledad + self.token = token + self._wrapper_map = { + OpenPGPKey: OpenPGPScheme(soledad), + # other types of key will be added to this mapper. + } + self._init_indexes() + self._fetcher = requests + + # + # utilities + # + + def _key_class_from_type(self, ktype): + """ + Return key class from string representation of key type. + """ + return filter( + lambda klass: str(klass) == ktype, + self._wrapper_map).pop() + + def _init_indexes(self): + """ + Initialize the database indexes. + """ + # Ask the database for currently existing indexes. + db_indexes = dict(self._soledad.list_indexes()) + # Loop through the indexes we expect to find. + for name, expression in INDEXES.items(): + if name not in db_indexes: + # The index does not yet exist. + self._soledad.create_index(name, *expression) + continue + if expression == db_indexes[name]: + # The index exists and is up to date. + continue + # The index exists but the definition is not what expected, so we + # delete it and add the proper index expression. + self._soledad.delete_index(name) + self._soledad.create_index(name, *expression) + + def _get_dict_from_http_json(self, path): + """ + Make a GET HTTP request and return a dictionary containing the + response. + """ + response = self._fetcher.get(self._nickserver_url+path) + leap_assert(response.status_code == 200, 'Invalid response.') + leap_assert( + response.headers['content-type'].startswith('application/json') + is True, + 'Content-type is not JSON.') + return response.json() + + # + # key management + # + + def send_key(self, ktype, send_private=False, password=None): + """ + Send user's key of type C{ktype} to provider. + + Public key bound to user's is sent to provider, which will sign it and + replace any prior keys for the same address in its database. + + If C{send_private} is True, then the private key is encrypted with + C{password} and sent to server in the same request, together with a + hash string of user's address and password. The encrypted private key + will be saved in the server in a way it is publicly retrievable + through the hash string. + + @param ktype: The type of the key. + @type ktype: KeyType + + @raise httplib.HTTPException: + @raise KeyNotFound: If the key was not found both locally and in + keyserver. + """ + # prepare the public key bound to address + pubkey = self.get_key( + self._address, ktype, private=False, fetch_remote=False) + data = { + 'address': self._address, + 'keys': [ + json.loads(pubkey.get_json()), + ] + } + # prepare the private key bound to address + if send_private: + if password is None or password == '': + raise NoPasswordGiven('Can\'t send unencrypted private keys!') + privkey = self.get_key( + self._address, ktype, private=True, fetch_remote=False) + privkey = json.loads(privkey.get_json()) + privkey.key_data = encrypt_sym(privkey.key_data, password) + data['keys'].append(privkey) + self._fetcher.put( + self._nickserver_url + '/key/' + self._address, + data=data, + auth=(self._address, self._token)) + + def get_key(self, address, ktype, private=False, fetch_remote=True): + """ + Return a key of type C{ktype} bound to C{address}. + + First, search for the key in local storage. If it is not available, + then try to fetch from nickserver. + + @param address: The address bound to the key. + @type address: str + @param ktype: The type of the key. + @type ktype: KeyType + @param private: Look for a private key instead of a public one? + @type private: bool + + @return: A key of type C{ktype} bound to C{address}. + @rtype: EncryptionKey + @raise KeyNotFound: If the key was not found both locally and in + keyserver. + """ + leap_assert( + ktype in self._wrapper_map, + 'Unkown key type: %s.' % str(ktype)) + try: + return self._wrapper_map[ktype].get_key(address, private=private) + except KeyNotFound: + if fetch_remote is False: + raise + # fetch keys from server and discard unwanted types. + keys = filter(lambda k: isinstance(k, ktype), + self.fetch_keys_from_server(address)) + if len(keys) is 0: + raise KeyNotFound() + leap_assert( + len(keys) == 1, + 'Got more than one key of type %s for %s.' % + (str(ktype), address)) + self._wrapper_map[ktype].put_key(keys[0]) + return self._wrapper_map[ktype].get_key(address, private=private) + + def fetch_keys_from_server(self, address): + """ + Fetch keys bound to C{address} from nickserver. + + @param address: The address bound to the keys. + @type address: str + + @return: A list of keys bound to C{address}. + @rtype: list of EncryptionKey + @raise KeyNotFound: If the key was not found on nickserver. + @raise httplib.HTTPException: + """ + keydata = self._get_dict_from_http_json('/key/%s' % address) + leap_assert( + keydata['address'] == address, + "Fetched key for wrong address.") + keys = [] + for key in keydata['keys']: + keys.append( + build_key_from_dict( + self._key_class_from_type(key['type']), + address, + key)) + return keys + + def get_all_keys_in_local_db(self, private=False): + """ + Return all keys stored in local database. + + @return: A list with all keys in local db. + @rtype: list + """ + return map( + lambda doc: build_key_from_dict( + self._key_class_from_type(doc.content['type']), + doc.content['address'], + doc.content), + self._soledad.get_from_index( + TAGS_AND_PRIVATE_INDEX, + 'keymanager-key', + '1' if private else '0')) + + def refresh_keys(self): + """ + Fetch keys from nickserver and update them locally. + """ + addresses = set(map( + lambda doc: doc.address, + self.get_all_keys_in_local_db(private=False))) + # TODO: maybe we should not attempt to refresh our own public key? + for address in addresses: + for key in self.fetch_keys_from_server(address): + self._wrapper_map[key.__class__].put_key(key) + + def gen_key(self, ktype): + """ + Generate a key of type C{ktype} bound to the user's address. + + @param ktype: The type of the key. + @type ktype: KeyType + + @return: The generated key. + @rtype: EncryptionKey + """ + return self._wrapper_map[ktype].gen_key(self._address) + + # + # Token setter/getter + # + + def _get_token(self): + return self._token + + def _set_token(self, token): + self._token = token + + token = property( + _get_token, _set_token, doc='The auth token.') diff --git a/src/leap/common/keymanager/errors.py b/src/leap/common/keymanager/errors.py new file mode 100644 index 0000000..1cf506e --- /dev/null +++ b/src/leap/common/keymanager/errors.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# errors.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Errors and exceptions used by the Key Manager. +""" + + +class KeyNotFound(Exception): + """ + Raised when key was no found on keyserver. + """ + + +class KeyAlreadyExists(Exception): + """ + Raised when attempted to create a key that already exists. + """ + + +class KeyAttributesDiffer(Exception): + """ + Raised when trying to delete a key but the stored key differs from the key + passed to the delete_key() method. + """ + +class NoPasswordGiven(Exception): + """ + Raised when trying to perform some action that needs a password without + providing one. + """ diff --git a/src/leap/common/keymanager/gpg.py b/src/leap/common/keymanager/gpg.py new file mode 100644 index 0000000..f3e6453 --- /dev/null +++ b/src/leap/common/keymanager/gpg.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- +# gpgwrapper.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +A GPG wrapper used to handle OpenPGP keys. + +This is a temporary class that will be superseded by the a revised version of +python-gnupg. +""" + + +import os +import gnupg +import re +from gnupg import ( + logger, + _is_sequence, + _make_binary_stream, +) + + +class ListPackets(): + """ + Handle status messages for --list-packets. + """ + + def __init__(self, gpg): + """ + Initialize the packet listing handling class. + + @param gpg: GPG object instance. + @type gpg: gnupg.GPG + """ + self.gpg = gpg + self.nodata = None + self.key = None + self.need_passphrase = None + self.need_passphrase_sym = None + self.userid_hint = None + + def handle_status(self, key, value): + """ + Handle one line of the --list-packets status message. + + @param key: The status message key. + @type key: str + @param value: The status message value. + @type value: str + """ + # TODO: write tests for handle_status + if key == 'NODATA': + self.nodata = True + if key == 'ENC_TO': + # This will only capture keys in our keyring. In the future we + # may want to include multiple unknown keys in this list. + self.key, _, _ = value.split() + if key == 'NEED_PASSPHRASE': + self.need_passphrase = True + if key == 'NEED_PASSPHRASE_SYM': + self.need_passphrase_sym = True + if key == 'USERID_HINT': + self.userid_hint = value.strip().split() + + +class GPGWrapper(gnupg.GPG): + """ + This is a temporary class for handling GPG requests, and should be + replaced by a more general class used throughout the project. + """ + + GNUPG_HOME = os.environ['HOME'] + "/.config/leap/gnupg" + GNUPG_BINARY = "/usr/bin/gpg" # this has to be changed based on OS + + def __init__(self, gpgbinary=GNUPG_BINARY, gnupghome=GNUPG_HOME, + verbose=False, use_agent=False, keyring=None, options=None): + """ + Initialize a GnuPG process wrapper. + + @param gpgbinary: Name for GnuPG binary executable. + @type gpgbinary: C{str} + @param gpghome: Full pathname to directory containing the public and + private keyrings. + @type gpghome: C{str} + @param keyring: Name of alternative keyring file to use. If specified, + the default keyring is not used. + @param verbose: Should some verbose info be output? + @type verbose: bool + @param use_agent: Should pass `--use-agent` to GPG binary? + @type use_agent: bool + @param keyring: Path for the keyring to use. + @type keyring: str + @options: A list of additional options to pass to the GPG binary. + @type options: list + + @raise: RuntimeError with explanation message if there is a problem + invoking gpg. + """ + gnupg.GPG.__init__(self, gnupghome=gnupghome, gpgbinary=gpgbinary, + verbose=verbose, use_agent=use_agent, + keyring=keyring, options=options) + self.result_map['list-packets'] = ListPackets + + def find_key_by_email(self, email, secret=False): + """ + Find user's key based on their email. + + @param email: Email address of key being searched for. + @type email: str + @param secret: Should we search for a secret key? + @type secret: bool + + @return: The fingerprint of the found key. + @rtype: str + """ + for key in self.list_keys(secret=secret): + for uid in key['uids']: + if re.search(email, uid): + return key + raise LookupError("GnuPG public key for email %s not found!" % email) + + def find_key_by_subkey(self, subkey, secret=False): + """ + Find user's key based on a subkey fingerprint. + + @param email: Subkey fingerprint of the key being searched for. + @type email: str + @param secret: Should we search for a secret key? + @type secret: bool + + @return: The fingerprint of the found key. + @rtype: str + """ + for key in self.list_keys(secret=secret): + for sub in key['subkeys']: + if sub[0] == subkey: + return key + raise LookupError( + "GnuPG public key for subkey %s not found!" % subkey) + + def find_key_by_keyid(self, keyid, secret=False): + """ + Find user's key based on the key ID. + + @param email: The key ID of the key being searched for. + @type email: str + @param secret: Should we search for a secret key? + @type secret: bool + + @return: The fingerprint of the found key. + @rtype: str + """ + for key in self.list_keys(secret=secret): + if keyid == key['keyid']: + return key + raise LookupError( + "GnuPG public key for keyid %s not found!" % keyid) + + def find_key_by_fingerprint(self, fingerprint, secret=False): + """ + Find user's key based on the key fingerprint. + + @param email: The fingerprint of the key being searched for. + @type email: str + @param secret: Should we search for a secret key? + @type secret: bool + + @return: The fingerprint of the found key. + @rtype: str + """ + for key in self.list_keys(secret=secret): + if fingerprint == key['fingerprint']: + return key + raise LookupError( + "GnuPG public key for fingerprint %s not found!" % fingerprint) + + def encrypt(self, data, recipient, sign=None, always_trust=True, + passphrase=None, symmetric=False): + """ + Encrypt data using GPG. + + @param data: The data to be encrypted. + @type data: str + @param recipient: The address of the public key to be used. + @type recipient: str + @param sign: Should the encrypted content be signed? + @type sign: bool + @param always_trust: Skip key validation and assume that used keys + are always fully trusted? + @type always_trust: bool + @param passphrase: The passphrase to be used if symmetric encryption + is desired. + @type passphrase: str + @param symmetric: Should we encrypt to a password? + @type symmetric: bool + + @return: An object with encrypted result in the `data` field. + @rtype: gnupg.Crypt + """ + # TODO: devise a way so we don't need to "always trust". + return gnupg.GPG.encrypt(self, data, recipient, sign=sign, + always_trust=always_trust, + passphrase=passphrase, + symmetric=symmetric, + cipher_algo='AES256') + + def decrypt(self, data, always_trust=True, passphrase=None): + """ + Decrypt data using GPG. + + @param data: The data to be decrypted. + @type data: str + @param always_trust: Skip key validation and assume that used keys + are always fully trusted? + @type always_trust: bool + @param passphrase: The passphrase to be used if symmetric encryption + is desired. + @type passphrase: str + + @return: An object with decrypted result in the `data` field. + @rtype: gnupg.Crypt + """ + # TODO: devise a way so we don't need to "always trust". + return gnupg.GPG.decrypt(self, data, always_trust=always_trust, + passphrase=passphrase) + + def send_keys(self, keyserver, *keyids): + """ + Send keys to a keyserver + + @param keyserver: The keyserver to send the keys to. + @type keyserver: str + @param keyids: The key ids to send. + @type keyids: list + + @return: A list of keys sent to server. + @rtype: gnupg.ListKeys + """ + # TODO: write tests for this. + # TODO: write a SendKeys class to handle status for this. + result = self.result_map['list'](self) + gnupg.logger.debug('send_keys: %r', keyids) + data = gnupg._make_binary_stream("", self.encoding) + args = ['--keyserver', keyserver, '--send-keys'] + args.extend(keyids) + self._handle_io(args, data, result, binary=True) + gnupg.logger.debug('send_keys result: %r', result.__dict__) + data.close() + return result + + def encrypt_file(self, file, recipients, sign=None, + always_trust=False, passphrase=None, + armor=True, output=None, symmetric=False, + cipher_algo=None): + """ + Encrypt the message read from the file-like object 'file'. + + @param file: The file to be encrypted. + @type data: file + @param recipient: The address of the public key to be used. + @type recipient: str + @param sign: Should the encrypted content be signed? + @type sign: bool + @param always_trust: Skip key validation and assume that used keys + are always fully trusted? + @type always_trust: bool + @param passphrase: The passphrase to be used if symmetric encryption + is desired. + @type passphrase: str + @param armor: Create ASCII armored output? + @type armor: bool + @param output: Path of file to write results in. + @type output: str + @param symmetric: Should we encrypt to a password? + @type symmetric: bool + @param cipher_algo: Algorithm to use. + @type cipher_algo: str + + @return: An object with encrypted result in the `data` field. + @rtype: gnupg.Crypt + """ + args = ['--encrypt'] + if symmetric: + args = ['--symmetric'] + if cipher_algo: + args.append('--cipher-algo %s' % cipher_algo) + else: + args = ['--encrypt'] + if not _is_sequence(recipients): + recipients = (recipients,) + for recipient in recipients: + args.append('--recipient "%s"' % recipient) + if armor: # create ascii-armored output - set to False for binary + args.append('--armor') + if output: # write the output to a file with the specified name + if os.path.exists(output): + os.remove(output) # to avoid overwrite confirmation message + args.append('--output "%s"' % output) + if sign: + args.append('--sign --default-key "%s"' % sign) + if always_trust: + args.append("--always-trust") + result = self.result_map['crypt'](self) + self._handle_io(args, file, result, passphrase=passphrase, binary=True) + logger.debug('encrypt result: %r', result.data) + return result + + def list_packets(self, data): + """ + List the sequence of packets. + + @param data: The data to extract packets from. + @type data: str + + @return: An object with packet info. + @rtype ListPackets + """ + args = ["--list-packets"] + result = self.result_map['list-packets'](self) + self._handle_io( + args, + _make_binary_stream(data, self.encoding), + result, + ) + return result + + def encrypted_to(self, data): + """ + Return the key to which data is encrypted to. + + @param data: The data to be examined. + @type data: str + + @return: The fingerprint of the key to which data is encrypted to. + @rtype: str + """ + # TODO: make this support multiple keys. + result = self.list_packets(data) + if not result.key: + raise LookupError( + "Content is not encrypted to a GnuPG key!") + try: + return self.find_key_by_keyid(result.key) + except: + return self.find_key_by_subkey(result.key) + + def is_encrypted_sym(self, data): + """ + Say whether some chunk of data is encrypted to a symmetric key. + + @param data: The data to be examined. + @type data: str + + @return: Whether data is encrypted to a symmetric key. + @rtype: bool + """ + result = self.list_packets(data) + return bool(result.need_passphrase_sym) + + def is_encrypted_asym(self, data): + """ + Say whether some chunk of data is encrypted to a private key. + + @param data: The data to be examined. + @type data: str + + @return: Whether data is encrypted to a private key. + @rtype: bool + """ + result = self.list_packets(data) + return bool(result.key) + + def is_encrypted(self, data): + """ + Say whether some chunk of data is encrypted to a key. + + @param data: The data to be examined. + @type data: str + + @return: Whether data is encrypted to a key. + @rtype: bool + """ + return self.is_encrypted_asym(data) or self.is_encrypted_sym(data) diff --git a/src/leap/common/keymanager/keys.py b/src/leap/common/keymanager/keys.py new file mode 100644 index 0000000..2e6bfe9 --- /dev/null +++ b/src/leap/common/keymanager/keys.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# keys.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Abstact key type and encryption scheme representations. +""" + + +try: + import simplejson as json +except ImportError: + import json # noqa +import re + + +from hashlib import sha256 +from abc import ABCMeta, abstractmethod +from leap.common.check import leap_assert + + +# +# Key handling utilities +# + +def is_address(address): + """ + Return whether the given C{address} is in the form user@provider. + + @param address: The address to be tested. + @type address: str + @return: Whether C{address} is in the form user@provider. + @rtype: bool + """ + return bool(re.match('[\w.-]+@[\w.-]+', address)) + + +def build_key_from_dict(kClass, address, kdict): + """ + Build an C{kClass} key bound to C{address} based on info in C{kdict}. + + @param address: The address bound to the key. + @type address: str + @param kdict: Dictionary with key data. + @type kdict: dict + @return: An instance of the key. + @rtype: C{kClass} + """ + leap_assert(address == kdict['address'], 'Wrong address in key data.') + return kClass( + address, + key_id=kdict['key_id'], + fingerprint=kdict['fingerprint'], + key_data=kdict['key_data'], + private=kdict['private'], + length=kdict['length'], + expiry_date=kdict['expiry_date'], + first_seen_at=kdict['first_seen_at'], + last_audited_at=kdict['last_audited_at'], + validation=kdict['validation'], # TODO: verify for validation. + ) + + +def keymanager_doc_id(ktype, address, private=False): + """ + Return the document id for the document containing a key for + C{address}. + + @param address: The type of the key. + @type address: KeyType + @param address: The address bound to the key. + @type address: str + @param private: Whether the key is private or not. + @type private: bool + @return: The document id for the document that stores a key bound to + C{address}. + @rtype: str + """ + leap_assert(is_address(address), "Wrong address format: %s" % address) + ktype = str(ktype) + visibility = 'private' if private else 'public' + return sha256('keymanager-'+address+'-'+ktype+'-'+visibility).hexdigest() + + +# +# Abstraction for encryption keys +# + +class EncryptionKey(object): + """ + Abstract class for encryption keys. + + A key is "validated" if the nicknym agent has bound the user address to a + public key. Nicknym supports three different levels of key validation: + + * Level 3 - path trusted: A path of cryptographic signatures can be traced + from a trusted key to the key under evaluation. By default, only the + provider key from the user's provider is a "trusted key". + * level 2 - provider signed: The key has been signed by a provider key for + the same domain, but the provider key is not validated using a trust + path (i.e. it is only registered) + * level 1 - registered: The key has been encountered and saved, it has no + signatures (that are meaningful to the nicknym agent). + """ + + __metaclass__ = ABCMeta + + def __init__(self, address, key_id=None, fingerprint=None, + key_data=None, private=None, length=None, expiry_date=None, + validation=None, first_seen_at=None, last_audited_at=None): + self.address = address + self.key_id = key_id + self.fingerprint = fingerprint + self.key_data = key_data + self.private = private + self.length = length + self.expiry_date = expiry_date + self.validation = validation + self.first_seen_at = first_seen_at + self.last_audited_at = last_audited_at + + def get_json(self): + """ + Return a JSON string describing this key. + + @return: The JSON string describing this key. + @rtype: str + """ + return json.dumps({ + 'address': self.address, + 'type': str(self.__class__), + 'key_id': self.key_id, + 'fingerprint': self.fingerprint, + 'key_data': self.key_data, + 'private': self.private, + 'length': self.length, + 'expiry_date': self.expiry_date, + 'validation': self.validation, + 'first_seen_at': self.first_seen_at, + 'last_audited_at': self.last_audited_at, + 'tags': ['keymanager-key'], + }) + + +# +# Encryption schemes +# + +class EncryptionScheme(object): + """ + Abstract class for Encryption Schemes. + + A wrapper for a certain encryption schemes should know how to get and put + keys in local storage using Soledad, how to generate new keys and how to + find out about possibly encrypted content. + """ + + __metaclass__ = ABCMeta + + def __init__(self, soledad): + """ + Initialize this Encryption Scheme. + + @param soledad: A Soledad instance for local storage of keys. + @type soledad: leap.soledad.Soledad + """ + self._soledad = soledad + + @abstractmethod + def get_key(self, address, private=False): + """ + Get key from local storage. + + @param address: The address bound to the key. + @type address: str + @param private: Look for a private key instead of a public one? + @type private: bool + + @return: The key bound to C{address}. + @rtype: EncryptionKey + @raise KeyNotFound: If the key was not found on local storage. + """ + pass + + @abstractmethod + def put_key(self, key): + """ + Put a key in local storage. + + @param key: The key to be stored. + @type key: EncryptionKey + """ + pass + + @abstractmethod + def gen_key(self, address): + """ + Generate a new key. + + @param address: The address bound to the key. + @type address: str + + @return: The key bound to C{address}. + @rtype: EncryptionKey + """ + pass + + @abstractmethod + def delete_key(self, key): + """ + Remove C{key} from storage. + + @param key: The key to be removed. + @type key: EncryptionKey + """ + pass diff --git a/src/leap/common/keymanager/openpgp.py b/src/leap/common/keymanager/openpgp.py new file mode 100644 index 0000000..e2ffe76 --- /dev/null +++ b/src/leap/common/keymanager/openpgp.py @@ -0,0 +1,459 @@ +# -*- coding: utf-8 -*- +# openpgp.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +""" +Infrastructure for using OpenPGP keys in Key Manager. +""" + + +import re +import tempfile +import shutil + +from leap.common.check import leap_assert +from leap.common.keymanager.errors import ( + KeyNotFound, + KeyAlreadyExists, + KeyAttributesDiffer +) +from leap.common.keymanager.keys import ( + EncryptionKey, + EncryptionScheme, + is_address, + keymanager_doc_id, + build_key_from_dict, +) +from leap.common.keymanager.gpg import GPGWrapper + + +# +# Utility functions +# + +def encrypt_sym(data, passphrase): + """ + Encrypt C{data} with C{passphrase}. + + @param data: The data to be encrypted. + @type data: str + @param passphrase: The passphrase used to encrypt C{data}. + @type passphrase: str + + @return: The encrypted data. + @rtype: str + """ + + def _encrypt_cb(gpg): + return gpg.encrypt( + data, None, passphrase=passphrase, symmetric=True).data + + return _safe_call(_encrypt_cb) + + +def decrypt_sym(data, passphrase): + """ + Decrypt C{data} with C{passphrase}. + + @param data: The data to be decrypted. + @type data: str + @param passphrase: The passphrase used to decrypt C{data}. + @type passphrase: str + + @return: The decrypted data. + @rtype: str + """ + + def _decrypt_cb(gpg): + return gpg.decrypt(data, passphrase=passphrase).data + + return _safe_call(_decrypt_cb) + + +def encrypt_asym(data, key): + """ + Encrypt C{data} using public @{key}. + + @param data: The data to be encrypted. + @type data: str + @param key: The key used to encrypt. + @type key: OpenPGPKey + + @return: The encrypted data. + @rtype: str + """ + leap_assert(key.private is False, 'Key is not public.') + + def _encrypt_cb(gpg): + return gpg.encrypt( + data, key.fingerprint, symmetric=False).data + + return _safe_call(_encrypt_cb, key.key_data) + + +def decrypt_asym(data, key): + """ + Decrypt C{data} using private @{key}. + + @param data: The data to be decrypted. + @type data: str + @param key: The key used to decrypt. + @type key: OpenPGPKey + + @return: The decrypted data. + @rtype: str + """ + leap_assert(key.private is True, 'Key is not private.') + + def _decrypt_cb(gpg): + return gpg.decrypt(data).data + + return _safe_call(_decrypt_cb, key.key_data) + + +def is_encrypted(data): + """ + Return whether C{data} was encrypted using OpenPGP. + + @param data: The data we want to know about. + @type data: str + + @return: Whether C{data} was encrypted using this wrapper. + @rtype: bool + """ + + def _is_encrypted_cb(gpg): + return gpg.is_encrypted(data) + + return _safe_call(_is_encrypted_cb) + + +def is_encrypted_sym(data): + """ + Return whether C{data} was encrypted using a public OpenPGP key. + + @param data: The data we want to know about. + @type data: str + + @return: Whether C{data} was encrypted using this wrapper. + @rtype: bool + """ + + def _is_encrypted_cb(gpg): + return gpg.is_encrypted_sym(data) + + return _safe_call(_is_encrypted_cb) + + +def is_encrypted_asym(data): + """ + Return whether C{data} was asymmetrically encrypted using OpenPGP. + + @param data: The data we want to know about. + @type data: str + + @return: Whether C{data} was encrypted using this wrapper. + @rtype: bool + """ + + def _is_encrypted_cb(gpg): + return gpg.is_encrypted_asym(data) + + return _safe_call(_is_encrypted_cb) + + +def _build_key_from_gpg(address, key, key_data): + """ + Build an OpenPGPKey for C{address} based on C{key} from + local gpg storage. + + ASCII armored GPG key data has to be queried independently in this + wrapper, so we receive it in C{key_data}. + + @param address: The address bound to the key. + @type address: str + @param key: Key obtained from GPG storage. + @type key: dict + @param key_data: Key data obtained from GPG storage. + @type key_data: str + @return: An instance of the key. + @rtype: OpenPGPKey + """ + return OpenPGPKey( + address, + key_id=key['keyid'], + fingerprint=key['fingerprint'], + key_data=key_data, + private=True if key['type'] == 'sec' else False, + length=key['length'], + expiry_date=key['expires'], + validation=None, # TODO: verify for validation. + ) + + +def _build_unitary_gpgwrapper(key_data=None): + """ + Return a temporary GPG wrapper keyring containing exactly zero or one + keys. + + Temporary unitary keyrings allow the to use GPG's facilities for exactly + one key. This function creates an empty temporary keyring and imports + C{key_data} if it is not None. + + @param key_data: ASCII armored key data. + @type key_data: str + @return: A GPG wrapper with a unitary keyring. + @rtype: gnupg.GPG + """ + tmpdir = tempfile.mkdtemp() + gpg = GPGWrapper(gnupghome=tmpdir) + leap_assert(len(gpg.list_keys()) is 0, 'Keyring not empty.') + if key_data: + gpg.import_keys(key_data) + leap_assert( + len(gpg.list_keys()) is 1, + 'Unitary keyring has wrong number of keys: %d.' + % len(gpg.list_keys())) + return gpg + + +def _destroy_unitary_gpgwrapper(gpg): + """ + Securely erase a unitary keyring. + + @param gpg: A GPG wrapper instance. + @type gpg: gnupg.GPG + """ + for secret in [True, False]: + for key in gpg.list_keys(secret=secret): + gpg.delete_keys( + key['fingerprint'], + secret=secret) + leap_assert(len(gpg.list_keys()) is 0, 'Keyring not empty!') + # TODO: implement some kind of wiping of data or a more secure way that + # does not write to disk. + shutil.rmtree(gpg.gnupghome) + + +def _safe_call(callback, key_data=None, **kwargs): + """ + Run C{callback} in an unitary keyring containing C{key_data}. + + @param callback: Function whose first argument is the gpg keyring. + @type callback: function(gnupg.GPG) + @param key_data: ASCII armored key data. + @type key_data: str + @param **kwargs: Other eventual parameters for the callback. + @type **kwargs: **dict + + @return: The results of the callback. + @rtype: str or bool + """ + gpg = _build_unitary_gpgwrapper(key_data) + val = callback(gpg, **kwargs) + _destroy_unitary_gpgwrapper(gpg) + return val + + +# +# The OpenPGP wrapper +# + +class OpenPGPKey(EncryptionKey): + """ + Base class for OpenPGP keys. + """ + + +class OpenPGPScheme(EncryptionScheme): + """ + A wrapper for OpenPGP keys. + """ + + def __init__(self, soledad): + """ + Initialize the OpenPGP wrapper. + + @param soledad: A Soledad instance for key storage. + @type soledad: leap.soledad.Soledad + """ + EncryptionScheme.__init__(self, soledad) + + def gen_key(self, address): + """ + Generate an OpenPGP keypair bound to C{address}. + + @param address: The address bound to the key. + @type address: str + @return: The key bound to C{address}. + @rtype: OpenPGPKey + @raise KeyAlreadyExists: If key already exists in local database. + """ + # make sure the key does not already exist + leap_assert(is_address(address), 'Not an user address: %s' % address) + try: + self.get_key(address) + raise KeyAlreadyExists(address) + except KeyNotFound: + pass + + def _gen_key_cb(gpg): + params = gpg.gen_key_input( + key_type='RSA', + key_length=4096, + name_real=address, + name_email=address, + name_comment='Generated by LEAP Key Manager.') + gpg.gen_key(params) + pubkeys = gpg.list_keys() + # assert for new key characteristics + leap_assert( + len(pubkeys) is 1, # a unitary keyring! + 'Keyring has wrong number of keys: %d.' % len(pubkeys)) + key = gpg.list_keys(secret=True).pop() + leap_assert( + len(key['uids']) is 1, # with just one uid! + 'Wrong number of uids for key: %d.' % len(key['uids'])) + leap_assert( + re.match('.*<%s>$' % address, key['uids'][0]) is not None, + 'Key not correctly bound to address.') + # insert both public and private keys in storage + for secret in [True, False]: + key = gpg.list_keys(secret=secret).pop() + openpgp_key = _build_key_from_gpg( + address, key, + gpg.export_keys(key['fingerprint'], secret=secret)) + self.put_key(openpgp_key) + + _safe_call(_gen_key_cb) + return self.get_key(address, private=True) + + def get_key(self, address, private=False): + """ + Get key bound to C{address} from local storage. + + @param address: The address bound to the key. + @type address: str + @param private: Look for a private key instead of a public one? + @type private: bool + + @return: The key bound to C{address}. + @rtype: OpenPGPKey + @raise KeyNotFound: If the key was not found on local storage. + """ + leap_assert(is_address(address), 'Not an user address: %s' % address) + doc = self._get_key_doc(address, private) + if doc is None: + raise KeyNotFound(address) + return build_key_from_dict(OpenPGPKey, address, doc.content) + + def put_key_raw(self, data): + """ + Put key contained in raw C{data} in local storage. + + @param data: The key data to be stored. + @type data: str + """ + # TODO: add more checks for correct key data. + leap_assert(data is not None, 'Data does not represent a key.') + + def _put_key_raw_cb(gpg): + + privkey = None + pubkey = None + try: + privkey = gpg.list_keys(secret=True).pop() + except IndexError: + pass + pubkey = gpg.list_keys(secret=False).pop() # unitary keyring + # extract adress from first uid on key + match = re.match('.*<([\w.-]+@[\w.-]+)>.*', pubkey['uids'].pop()) + leap_assert(match is not None, 'No user address in key data.') + address = match.group(1) + if privkey is not None: + match = re.match( + '.*<([\w.-]+@[\w.-]+)>.*', privkey['uids'].pop()) + leap_assert(match is not None, 'No user address in key data.') + privaddress = match.group(1) + leap_assert( + address == privaddress, + 'Addresses in pub and priv key differ.') + leap_assert( + pubkey['fingerprint'] == privkey['fingerprint'], + 'Fingerprints for pub and priv key differ.') + # insert private key in storage + openpgp_privkey = _build_key_from_gpg( + address, privkey, + gpg.export_keys(privkey['fingerprint'], secret=True)) + self.put_key(openpgp_privkey) + # insert public key in storage + openpgp_pubkey = _build_key_from_gpg( + address, pubkey, + gpg.export_keys(pubkey['fingerprint'], secret=False)) + self.put_key(openpgp_pubkey) + + _safe_call(_put_key_raw_cb, data) + + def put_key(self, key): + """ + Put C{key} in local storage. + + @param key: The key to be stored. + @type key: OpenPGPKey + """ + doc = self._get_key_doc(key.address, private=key.private) + if doc is None: + self._soledad.create_doc_from_json( + key.get_json(), + doc_id=keymanager_doc_id( + OpenPGPKey, key.address, key.private)) + else: + doc.set_json(key.get_json()) + self._soledad.put_doc(doc) + + def _get_key_doc(self, address, private=False): + """ + Get the document with a key (public, by default) bound to C{address}. + + If C{private} is True, looks for a private key instead of a public. + + @param address: The address bound to the key. + @type address: str + @param private: Whether to look for a private key. + @type private: bool + @return: The document with the key or None if it does not exist. + @rtype: leap.soledad.backends.leap_backend.LeapDocument + """ + return self._soledad.get_doc( + keymanager_doc_id(OpenPGPKey, address, private)) + + def delete_key(self, key): + """ + Remove C{key} from storage. + + @param key: The key to be removed. + @type key: EncryptionKey + """ + leap_assert(key.__class__ is OpenPGPKey, 'Wrong key type.') + stored_key = self.get_key(key.address, private=key.private) + if stored_key is None: + raise KeyNotFound(key) + if stored_key.__dict__ != key.__dict__: + raise KeyAttributesDiffer(key) + doc = self._soledad.get_doc( + keymanager_doc_id(OpenPGPKey, key.address, key.private)) + self._soledad.delete_doc(doc) |