def __init__(self, address, **ssl_args): """Create a new client for the syncapi API at `address`.""" self.address = address ssl_args.setdefault('dhparams', dhparams['skip2048']) ssl_args.setdefault('ciphers', 'ADH+AES') self.ssl_args = ssl_args self.connection = None logger = logging.getLogger(__name__) self.logger = ContextLogger(logger) self.crypto = CryptoProvider()
def setup_class(cls): super(TestLocator, cls).setup_class() sources = platform.get_location_sources() if not sources: raise SkipTest('No location sources avaialble') cls.locator = Locator() cls.locator.add_source(sources[0]()) cls.crypto = CryptoProvider()
def __init__(self, connection): """Create a new keyring. Requires a python-tdbus dispatcher as an argument.""" self.connection = connection self.logger = logging.getLogger( 'bluepass.platform.freedesktop.keyring') self.crypto = CryptoProvider()
class SecretsKeyring(Keyring): """A Keyring interface into the freedesktop "secrets" service. This interface is available on Freedesktop platforms (GNOME, KDE). """ def __init__(self, connection): """Create a new keyring. Requires a python-tdbus dispatcher as an argument.""" self.connection = connection self.logger = logging.getLogger( 'bluepass.platform.freedesktop.keyring') self.crypto = CryptoProvider() def _call_svc(self, path, method, interface, format=None, args=None): """INTERNAL: call into the secrets service.""" try: result = self.connection.call_method(path, method, interface=interface, format=format, args=args, destination=CONN_SERVICE) except tdbus.Error as e: raise KeyringError('D-BUS error for method "%s": %s' % (method, str(e))) return result def isavailable(self): """Return whether or not we can store a key. This requires the secrets service to be available and the login keyring to be unlocked.""" try: reply = self._call_svc(PATH_LOGIN_COLLECTION, 'Get', IFACE_PROPS, 'ss', (IFACE_COLLECTION, 'Locked')) except KeyringError: self.logger.debug('could not access secrets service') return False value = reply.get_args()[0] if value[0] != 'b': raise KeyringError('expecting type "b" for "Locked" property') self.logger.debug('login keyring is locked: %s', value[1]) return not value[1] def _open_session(self): """INTERNAL: open a session.""" algo = 'dh-ietf1024-sha256-aes128-cbc-pkcs7' params = dhparams['ietf1024'] keypair = self.crypto.dh_genkey(params) reply = self._call_svc(PATH_SERVICE, 'OpenSession', IFACE_SERVICE, 'sv', (algo, ('ay', keypair[1]))) if reply.get_signature() != 'vo': raise KeyringError( 'expecting "vo" reply signature for "OpenSession"') output, path = reply.get_args() if output[0] != 'ay': raise KeyringError( 'expecting "ay" type for output argument of "OpenSession"') pubkey = output[1] if not self.crypto.dh_checkkey(params, pubkey): raise KeyringError('insecure public key returned by "OpenSession"') secret = self.crypto.dh_compute(params, keypair[0], pubkey) symkey = self.crypto.hkdf(secret, None, '', 16, 'sha256') return path, symkey def store(self, key, value): """Store a secret in the keyring.""" session, symkey = self._open_session() try: attrib = {'application': 'bluepass', 'bluepass-key-id': key} props = { 'org.freedesktop.Secret.Item.Label': ('s', 'Bluepass Key: %s' % key), 'org.freedesktop.Secret.Item.Attributes': ('a{ss}', attrib) } iv = self.crypto.random(16) encrypted = self.crypto.aes_encrypt(value, symkey, iv, 'cbc-pkcs7') secret = (session, iv, encrypted, 'text/plain') reply = self._call_svc(PATH_LOGIN_COLLECTION, 'CreateItem', IFACE_COLLECTION, 'a{sv}(oayays)b', (props, secret, True)) item, prompt = reply.get_args() if item == '/': raise KeyringError('not expecting a prompt for "CreateItem"') return item finally: self._call_svc(session, 'Close', IFACE_SESSION) def retrieve(self, key): """Retrieve a secret from the keyring.""" session, symkey = self._open_session() try: attrib = {'application': 'bluepass', 'bluepass-key-id': key} reply = self._call_svc(PATH_LOGIN_COLLECTION, 'SearchItems', IFACE_COLLECTION, 'a{ss}', (attrib, )) paths = reply.get_args()[0] if len(paths) > 1: self.logger.error( 'SearchItems returned %d entries for key "%s"' % (len(paths), key)) return elif len(paths) == 0: return item = paths[0] reply = self._call_svc(item, 'GetSecret', IFACE_ITEM, 'o', (session, )) secret = reply.get_args()[0] decrypted = self.crypto.aes_decrypt(secret[2], symkey, secret[1], 'cbc-pkcs7') return decrypted finally: self._call_svc(session, 'Close', IFACE_SESSION)
def __init__(self, connection): """Create a new keyring. Requires a python-tdbus dispatcher as an argument.""" self.connection = connection self.logger = logging.getLogger('bluepass.platform.freedesktop.keyring') self.crypto = CryptoProvider()
class SecretsKeyring(Keyring): """A Keyring interface into the freedesktop "secrets" service. This interface is available on Freedesktop platforms (GNOME, KDE). """ def __init__(self, connection): """Create a new keyring. Requires a python-tdbus dispatcher as an argument.""" self.connection = connection self.logger = logging.getLogger('bluepass.platform.freedesktop.keyring') self.crypto = CryptoProvider() def _call_svc(self, path, method, interface, format=None, args=None): """INTERNAL: call into the secrets service.""" try: result = self.connection.call_method(path, method, interface=interface, format=format, args=args, destination=CONN_SERVICE) except tdbus.Error as e: raise KeyringError('D-BUS error for method "%s": %s' % (method, str(e))) return result def isavailable(self): """Return whether or not we can store a key. This requires the secrets service to be available and the login keyring to be unlocked.""" try: reply = self._call_svc(PATH_LOGIN_COLLECTION, 'Get', IFACE_PROPS, 'ss', (IFACE_COLLECTION, 'Locked')) except KeyringError: self.logger.debug('could not access secrets service') return False value = reply.get_args()[0] if value[0] != 'b': raise KeyringError('expecting type "b" for "Locked" property') self.logger.debug('login keyring is locked: %s', value[1]) return not value[1] def _open_session(self): """INTERNAL: open a session.""" algo = 'dh-ietf1024-sha256-aes128-cbc-pkcs7' params = dhparams['ietf1024'] keypair = self.crypto.dh_genkey(params) reply = self._call_svc(PATH_SERVICE, 'OpenSession', IFACE_SERVICE, 'sv', (algo, ('ay', keypair[1]))) if reply.get_signature() != 'vo': raise KeyringError('expecting "vo" reply signature for "OpenSession"') output, path = reply.get_args() if output[0] != 'ay': raise KeyringError('expecting "ay" type for output argument of "OpenSession"') pubkey = output[1] if not self.crypto.dh_checkkey(params, pubkey): raise KeyringError('insecure public key returned by "OpenSession"') secret = self.crypto.dh_compute(params, keypair[0], pubkey) symkey = self.crypto.hkdf(secret, None, '', 16, 'sha256') return path, symkey def store(self, key, value): """Store a secret in the keyring.""" session, symkey = self._open_session() try: attrib = { 'application': 'bluepass', 'bluepass-key-id': key } props = { 'org.freedesktop.Secret.Item.Label': ('s', 'Bluepass Key: %s' % key), 'org.freedesktop.Secret.Item.Attributes': ('a{ss}', attrib) } iv = self.crypto.random(16) encrypted = self.crypto.aes_encrypt(value, symkey, iv, 'cbc-pkcs7') secret = (session, iv, encrypted, 'text/plain') reply = self._call_svc(PATH_LOGIN_COLLECTION, 'CreateItem', IFACE_COLLECTION, 'a{sv}(oayays)b', (props, secret, True)) item, prompt = reply.get_args() if item == '/': raise KeyringError('not expecting a prompt for "CreateItem"') return item finally: self._call_svc(session, 'Close', IFACE_SESSION) def retrieve(self, key): """Retrieve a secret from the keyring.""" session, symkey = self._open_session() try: attrib = { 'application': 'bluepass', 'bluepass-key-id': key } reply = self._call_svc(PATH_LOGIN_COLLECTION, 'SearchItems', IFACE_COLLECTION, 'a{ss}', (attrib,)) paths = reply.get_args()[0] if len(paths) > 1: self.logger.error('SearchItems returned %d entries for key "%s"' % (len(paths), key)) return elif len(paths) == 0: return item = paths[0] reply = self._call_svc(item, 'GetSecret', IFACE_ITEM, 'o', (session,)) secret = reply.get_args()[0] decrypted = self.crypto.aes_decrypt(secret[2], symkey, secret[1], 'cbc-pkcs7') return decrypted finally: self._call_svc(session, 'Close', IFACE_SESSION)
def setup_class(cls): super(CryptoTest, cls).setup_class() cls.provider = CryptoProvider()
def __init__(self): super(SyncAPIApplication, self).__init__() self.crypto = CryptoProvider() self.allow_pairing = False self.key_exchanges = {}
class SyncAPIApplication(WSGIApplication): """A WSGI application that implements our SyncAPI.""" def __init__(self): super(SyncAPIApplication, self).__init__() self.crypto = CryptoProvider() self.allow_pairing = False self.key_exchanges = {} def _do_auth_hmac_cb(self, uuid): """Perform mutual HMAC_CB authentication.""" wwwauth = create_option_header('HMAC_CB', realm=uuid) headers = [('WWW-Authenticate', wwwauth)] auth = self.environ.get('HTTP_AUTHORIZATION') if auth is None: raise HTTPReturn(http.UNAUTHORIZED, headers) try: method, options = parse_option_header(auth) except ValueError: raise HTTPReturn(http.UNAUTHORIZED, headers) if method != 'HMAC_CB': raise HTTPReturn(http.UNAUTHORIZED, headers) if 'name' in options: # pair step 1 - ask user for permission to pair name = options['name'] if not self.allow_pairing: raise HTTPReturn('403 Pairing Disabled') bus = instance(MessageBusServer) kxid = self.crypto.random(16).encode('hex') pin = '%06d' % (self.crypto.randint(bits=31) % 1000000) approved = bus.call_method(None, 'get_pairing_approval', name, uuid, pin, kxid, timeout=60) if not approved: raise HTTPReturn('403 Approval Denied') restrictions = {} self.key_exchanges[kxid] = (time.time(), restrictions, pin) wwwauth = create_option_header('HMAC_CB', kxid=kxid) headers = [('WWW-Authenticate', wwwauth)] raise HTTPReturn(http.UNAUTHORIZED, headers) elif 'kxid' in options: # pair step 2 - check auth and do the actual pairing kxid = options['kxid'] if kxid not in self.key_exchanges: raise HTTPReturn(http.FORBIDDEN) starttime, restrictions, pin = self.key_exchanges.pop(kxid) signature = base64.try_decode(options.get('signature', '')) if not signature: raise HTTPReturn(http.FORBIDDEN) now = time.time() if now - starttime > 60: raise HTTPReturn('403 Request Timeout') cb = self.environ['SSL_CHANNEL_BINDING_TLS_UNIQUE'] check = self.crypto.hmac(adjust_pin(pin, +1), cb, 'sha1') if check != signature: raise HTTPReturn('403 Invalid PIN') bus = instance(MessageBusServer) bus.send_signal(None, 'PairingComplete', kxid) # Prove to the other side we also know the PIN signature = self.crypto.hmac(adjust_pin(pin, -1), cb, 'sha1') signature = base64.encode(signature) authinfo = create_option_header('HMAC_CB', kxid=kxid, signature=signature) self.headers.append(('Authentication-Info', authinfo)) else: raise HTTPReturn(http.UNAUTHORIZED, headers) def _do_auth_rsa_cb(self, uuid): """Perform mutual RSA_CB authentication.""" wwwauth = create_option_header('RSA_CB', realm=uuid) headers = [('WWW-Authenticate', wwwauth)] auth = self.environ.get('HTTP_AUTHORIZATION') if auth is None: raise HTTPReturn(http.UNAUTHORIZED, headers) try: method, opts = parse_option_header(auth) except ValueError: raise HTTPReturn(http.UNAUTHORIZED, headers) if method != 'RSA_CB': raise HTTPReturn(http.UNAUTHORIZED, headers) if 'node' not in opts or not check_uuid4(opts['node']): raise HTTPReturn(http.UNAUTHORIZED, headers) if 'signature' not in opts or not base64.check(opts['signature']): raise HTTPReturn(http.UNAUTHORIZED, headers) model = instance(Model) cert = model.get_certificate(uuid, opts['node']) if cert is None: raise HTTPReturn(http.UNAUTHORIZED, headers) signature = base64.decode(opts['signature']) pubkey = base64.decode(cert['payload']['keys']['auth']['key']) cb = self.environ['SSL_CHANNEL_BINDING_TLS_UNIQUE'] if not self.crypto.rsa_verify(cb, signature, pubkey, 'pss-sha1'): raise HTTPReturn(http.UNAUTHORIZED, headers) # The peer was authenticated. Authenticate ourselves as well. privkey = model.get_auth_key(uuid) vault = model.get_vault(uuid) node = vault['node'] signature = self.crypto.rsa_sign(cb, privkey, 'pss-sha1') signature = base64.encode(signature) auth = create_option_header('RSA_CB', node=node, signature=signature) self.headers.append(('Authentication-Info', auth)) @expose('/api/vaults/:vault/pair', method='POST') def pair(self, env): uuid = env['mapper.vault'] if not check_uuid4(uuid): raise HTTPReturn(http.NOT_FOUND) model = instance(Model) vault = model.get_vault(uuid) if not vault: raise HTTPReturn(http.NOT_FOUND) self._do_auth_hmac_cb(uuid) # Sign the certificate request that was sent to tus certinfo = self.entity if not certinfo or not isinstance(certinfo, dict): raise HTTPReturn(http.BAD_REQUEST) model.add_certificate(uuid, certinfo) # And send our own certificate request in return certinfo = { 'node': vault['node'], 'name': socket.gethostname() } certkeys = certinfo['keys'] = {} for key in vault['keys']: certkeys[key] = { 'key': vault['keys'][key]['public'], 'keytype': vault['keys'][key]['keytype'] } return certinfo @expose('/api/vaults/:vault/items', method='GET') def sync_outbound(self, env): uuid = env['mapper.vault'] if not check_uuid4(uuid): raise HTTPReturn(http.NOT_FOUND) model = instance(Model) vault = model.get_vault(uuid) if vault is None: raise HTTPReturn(http.NOT_FOUND) self._do_auth_rsa_cb(uuid) args = parse_qs(env.get('QUERY_STRING', '')) vector = args.get('vector', [''])[0] if vector: try: vector = parse_vector(vector) except ValueError: raise HTTPReturn(http.BAD_REQUEST) items = model.get_items(uuid, vector) myvector = model.get_vector(uuid) self.headers.append(('X-Vector', dump_vector(myvector))) return items @expose('/api/vaults/:vault/items', method='POST') def sync_inbound(self, env): uuid = env['mapper.vault'] if not check_uuid4(uuid): raise HTTPReturn(http.NOT_FOUND) model = instance(Model) vault = model.get_vault(uuid) if vault is None: raise HTTPReturn(http.NOT_FOUND) self._do_auth_rsa_cb(uuid) items = self.entity if items is None or not isinstance(items, list): raise HTTPReturn(http.BAD_REQUEST) model.import_items(uuid, items)
class SyncAPIClient(object): """ SyncAPI client. This classs implements a client to the Bluepass HTTP based synchronization API. The two main functions are pairing (pair_step1() and pair_step2()) and synchronization (sync()). """ def __init__(self, address, **ssl_args): """Create a new client for the syncapi API at `address`.""" self.address = address ssl_args.setdefault('dhparams', dhparams['skip2048']) ssl_args.setdefault('ciphers', 'ADH+AES') self.ssl_args = ssl_args self.connection = None logger = logging.getLogger(__name__) self.logger = ContextLogger(logger) self.crypto = CryptoProvider() def _make_request(self, method, url, headers=None, body=None): """Make an HTTP request to the API. This returns the HTTPResponse object on success, or None on failure. """ logger = self.logger if headers is None: headers = [] headers.append(('User-Agent', 'Bluepass/%s' % _version.version)) headers.append(('Accept', 'text/json')) if body is None: body = '' else: body = json.dumps(body) headers.append(('Content-Type', 'text/json')) connection = self.connection assert connection is not None try: logger.debug('client request: %s %s', method, url) connection.request(method, url, body, dict(headers)) response = connection.getresponse() headers = response.getheaders() body = response.read() except (socket.error, HTTPException) as e: logger.error('error when making HTTP request: %s', str(e)) return ctype = response.getheader('Content-Type') if ctype == 'text/json': parsed = json.try_loads(body) if parsed is None: logger.error('response body contains invalid JSON') return response.entity = parsed logger.debug('parsed "%s" request body (%d bytes)', ctype, len(body)) else: response.entity = None return response def connect(self): """Connect to the remote syncapi.""" ssl_args = { 'dhparams': dhparams['skip2048'], 'ciphers': 'ADH+AES' } # Support both dict style addresses for arbitrary address families, # as well as (host, port) tuples for IPv4. if isinstance(self.address, dict): host = self.address['host']; port = None sockinfo = self.address else: host, port = self.address sockinfo = None connection = HTTPSConnection(host, port, sockinfo=sockinfo, **ssl_args) try: connection.connect() except socket.error as e: self.logger.error('could not connect to %s:%d' % self.address) raise SyncAPIError('RemoteError', 'Could not connect') self.connection = connection def close(self): """Close the connection.""" if self.connection is not None: try: conection.close() except Exception: pass self.connection = None def _get_hmac_cb_auth(self, kxid, pin): """Return the headers for a client to server HMAC_CB auth.""" cb = self.connection.sock.get_channel_binding('tls-unique') signature = self.crypto.hmac(adjust_pin(pin, +1), cb, 'sha1') signature = base64.encode(signature) auth = create_option_header('HMAC_CB', kxid=kxid, signature=signature) headers = [('Authorization', auth)] return headers def _check_hmac_cb_auth(self, response, pin): """Check a server to client HMAC_CB auth.""" logger = self.logger authinfo = response.getheader('Authentication-Info', '') try: method, options = parse_option_header(authinfo) except ValueError: logger.error('illegal Authentication-Info header: %s', authinfo) return False if 'signature' not in options or not base64.check(options['signature']): logger.error('illegal Authentication-Info header: %s', authinfo) return False signature = base64.decode(options['signature']) cb = self.connection.sock.get_channel_binding('tls-unique') check = self.crypto.hmac(adjust_pin(pin, -1), cb, 'sha1') if check != signature: logger.error('HMAC_CB signature did not match') return False return True def pair_step1(self, uuid, name): """Perform step 1 in a pairing exchange. If succesful, this returns a key exchange ID. On error, a SyncAPIError exception is raised. """ if self.connection is None: raise SyncAPIError('ProgrammingError', 'Not connected') logger = self.logger logger.setContext('pair step #1') url = '/api/vaults/%s/pair' % uuid headers = [('Authorization', 'HMAC_CB name=%s' % name)] response = self._make_request('POST', url, headers) if response is None: raise SyncAPIError('RemoteError', 'Could not make HTTP request') status = response.status if status != 401: logger.error('expecting HTTP status 401 (got: %s)', status) raise SyncAPIError('RemoteError', response.reason) wwwauth = response.getheader('WWW-Authenticate', '') try: method, options = parse_option_header(wwwauth) except ValueError: raise SyncAPIError('RemoteError', 'Illegal response') if method != 'HMAC_CB' or 'kxid' not in options: logger.error('illegal WWW-Authenticate header: %s', wwwauth) raise SyncAPIError('RemoteError', 'Illegal response') return options['kxid'] def pair_step2(self, uuid, kxid, pin, certinfo): """Perform step 2 in pairing exchange. If successfull, this returns the peer certificate. On error, a SyncAPIError is raised. """ if self.connection is None: raise SyncAPIError('ProgrammingError', 'Not connected') logger = self.logger logger.setContext('pair step #2') url = '/api/vaults/%s/pair' % uuid headers = self._get_hmac_cb_auth(kxid, pin) response = self._make_request('POST', url, headers, certinfo) if response is None: raise SyncAPIError('RemoteError', 'Could not make syncapi request') status = response.status if status != 200: logger.error('expecting HTTP status 200 (got: %s)', status) raise SyncAPIError('RemoteError', response.reason) if not self._check_hmac_cb_auth(response, pin): raise SyncAPIError('RemoteError', 'Illegal syncapi response') peercert = response.entity if peercert is None or not isinstance(peercert, dict): raise SyncAPIError('RemoteError', 'Illegal syncapi response') return peercert def _get_rsa_cb_auth(self, uuid, model): """Return the headers for RSA_CB authentication.""" cb = self.connection.sock.get_channel_binding('tls-unique') privkey = model.get_auth_key(uuid) assert privkey is not None signature = self.crypto.rsa_sign(cb, privkey, 'pss-sha1') signature = base64.encode(signature) vault = model.get_vault(uuid) auth = create_option_header('RSA_CB', node=vault['node'], signature=signature) headers = [('Authorization', auth)] return headers def _check_rsa_cb_auth(self, uuid, response, model): """Verify RSA_CB authentication.""" logger = self.logger authinfo = response.getheader('Authentication-Info', '') try: method, options = parse_option_header(authinfo) except ValueError: logger.error('illegal Authentication-Info header') return False if 'signature' not in options or 'node' not in options \ or not base64.check(options['signature']) \ or not check_uuid4(options['node']): logger.error('illegal Authentication-Info header') return False cb = self.connection.sock.get_channel_binding('tls-unique') signature = base64.decode(options['signature']) cert = model.get_certificate(uuid, options['node']) if cert is None: logger.error('unknown node in RSA_CB authentication', node) return False pubkey = base64.decode(cert['payload']['keys']['auth']['key']) try: status = self.crypto.rsa_verify(cb, signature, pubkey, 'pss-sha1') except CryptoError: logger.error('corrupt RSA_CB signature') return False if not status: logger.error('RSA_CB signature did not match') return status def sync(self, uuid, model, notify=True): """Synchronize vault `uuid` with the remote peer.""" if self.connection is None: raise SyncAPIError('ProgrammingError', 'Not connected') logger = self.logger logger.setContext('sync') vault = model.get_vault(uuid) if vault is None: raise SyncAPIError('NotFound', 'Vault not found') vector = model.get_vector(uuid) vector = dump_vector(vector) url = '/api/vaults/%s/items?vector=%s' % (vault['id'], vector) headers = self._get_rsa_cb_auth(uuid, model) response = self._make_request('GET', url, headers) if not response: raise SyncAPIError('RemoteError', 'Could not make HTTP request') status = response.status if status != 200: logger.error('expecting HTTP status 200 (got: %s)', status) raise SyncAPIError('RemoteError', 'Illegal syncapi response') if not self._check_rsa_cb_auth(uuid, response, model): raise SyncAPIError('RemoteError', 'Illegal syncapi response') initems = response.entity if initems is None or not isinstance(initems, list): raise SyncAPIError('RemoteError', 'Illegal syncapi response') nitems = model.import_items(uuid, initems, notify=notify) logger.debug('imported %d items into model', nitems) vector = response.getheader('X-Vector', '') try: vector = parse_vector(vector) except ValueError as e: logger.error('illegal X-Vector header: %s (%s)', vector, str(e)) raise SyncAPIError('RemoteError', 'Invalid response') outitems = model.get_items(uuid, vector) url = '/api/vaults/%s/items' % uuid response = self._make_request('POST', url, headers, outitems) if not response: raise SyncAPIError('RemoteError', 'Illegal syncapi response') if status != 200: logger.error('expecting HTTP status 200 (got: %s)', status) raise SyncAPIError('RemoteError', 'Illegal syncapi response') if not self._check_rsa_cb_auth(uuid, response, model): raise SyncAPIError('RemoteError', 'Illegal syncapi response') logger.debug('succesfully retrieved %d items from peer', len(initems)) logger.debug('succesfully pushed %d items to peer', len(outitems)) return len(initems) + len(outitems)
class SyncAPIApplication(WSGIApplication): """A WSGI application that implements our SyncAPI.""" def __init__(self): super(SyncAPIApplication, self).__init__() self.crypto = CryptoProvider() self.allow_pairing = False self.key_exchanges = {} def _do_auth_hmac_cb(self, uuid): """Perform mutual HMAC_CB authentication.""" wwwauth = create_option_header('HMAC_CB', realm=uuid) headers = [('WWW-Authenticate', wwwauth)] auth = self.environ.get('HTTP_AUTHORIZATION') if auth is None: raise HTTPReturn(http.UNAUTHORIZED, headers) try: method, options = parse_option_header(auth) except ValueError: raise HTTPReturn(http.UNAUTHORIZED, headers) if method != 'HMAC_CB': raise HTTPReturn(http.UNAUTHORIZED, headers) if 'name' in options: # pair step 1 - ask user for permission to pair name = options['name'] if not self.allow_pairing: raise HTTPReturn('403 Pairing Disabled') bus = instance(MessageBusServer) kxid = self.crypto.random(16).encode('hex') pin = '%06d' % (self.crypto.randint(bits=31) % 1000000) approved = bus.call_method(None, 'get_pairing_approval', name, uuid, pin, kxid, timeout=60) if not approved: raise HTTPReturn('403 Approval Denied') restrictions = {} self.key_exchanges[kxid] = (time.time(), restrictions, pin) wwwauth = create_option_header('HMAC_CB', kxid=kxid) headers = [('WWW-Authenticate', wwwauth)] raise HTTPReturn(http.UNAUTHORIZED, headers) elif 'kxid' in options: # pair step 2 - check auth and do the actual pairing kxid = options['kxid'] if kxid not in self.key_exchanges: raise HTTPReturn(http.FORBIDDEN) starttime, restrictions, pin = self.key_exchanges.pop(kxid) signature = base64.try_decode(options.get('signature', '')) if not signature: raise HTTPReturn(http.FORBIDDEN) now = time.time() if now - starttime > 60: raise HTTPReturn('403 Request Timeout') cb = self.environ['SSL_CHANNEL_BINDING_TLS_UNIQUE'] check = self.crypto.hmac(adjust_pin(pin, +1), cb, 'sha1') if check != signature: raise HTTPReturn('403 Invalid PIN') bus = instance(MessageBusServer) bus.send_signal(None, 'PairingComplete', kxid) # Prove to the other side we also know the PIN signature = self.crypto.hmac(adjust_pin(pin, -1), cb, 'sha1') signature = base64.encode(signature) authinfo = create_option_header('HMAC_CB', kxid=kxid, signature=signature) self.headers.append(('Authentication-Info', authinfo)) else: raise HTTPReturn(http.UNAUTHORIZED, headers) def _do_auth_rsa_cb(self, uuid): """Perform mutual RSA_CB authentication.""" wwwauth = create_option_header('RSA_CB', realm=uuid) headers = [('WWW-Authenticate', wwwauth)] auth = self.environ.get('HTTP_AUTHORIZATION') if auth is None: raise HTTPReturn(http.UNAUTHORIZED, headers) try: method, opts = parse_option_header(auth) except ValueError: raise HTTPReturn(http.UNAUTHORIZED, headers) if method != 'RSA_CB': raise HTTPReturn(http.UNAUTHORIZED, headers) if 'node' not in opts or not check_uuid4(opts['node']): raise HTTPReturn(http.UNAUTHORIZED, headers) if 'signature' not in opts or not base64.check(opts['signature']): raise HTTPReturn(http.UNAUTHORIZED, headers) model = instance(Model) cert = model.get_certificate(uuid, opts['node']) if cert is None: raise HTTPReturn(http.UNAUTHORIZED, headers) signature = base64.decode(opts['signature']) pubkey = base64.decode(cert['payload']['keys']['auth']['key']) cb = self.environ['SSL_CHANNEL_BINDING_TLS_UNIQUE'] if not self.crypto.rsa_verify(cb, signature, pubkey, 'pss-sha1'): raise HTTPReturn(http.UNAUTHORIZED, headers) # The peer was authenticated. Authenticate ourselves as well. privkey = model.get_auth_key(uuid) vault = model.get_vault(uuid) node = vault['node'] signature = self.crypto.rsa_sign(cb, privkey, 'pss-sha1') signature = base64.encode(signature) auth = create_option_header('RSA_CB', node=node, signature=signature) self.headers.append(('Authentication-Info', auth)) @expose('/api/vaults/:vault/pair', method='POST') def pair(self, env): uuid = env['mapper.vault'] if not check_uuid4(uuid): raise HTTPReturn(http.NOT_FOUND) model = instance(Model) vault = model.get_vault(uuid) if not vault: raise HTTPReturn(http.NOT_FOUND) self._do_auth_hmac_cb(uuid) # Sign the certificate request that was sent to tus certinfo = self.entity if not certinfo or not isinstance(certinfo, dict): raise HTTPReturn(http.BAD_REQUEST) model.add_certificate(uuid, certinfo) # And send our own certificate request in return certinfo = {'node': vault['node'], 'name': socket.gethostname()} certkeys = certinfo['keys'] = {} for key in vault['keys']: certkeys[key] = { 'key': vault['keys'][key]['public'], 'keytype': vault['keys'][key]['keytype'] } return certinfo @expose('/api/vaults/:vault/items', method='GET') def sync_outbound(self, env): uuid = env['mapper.vault'] if not check_uuid4(uuid): raise HTTPReturn(http.NOT_FOUND) model = instance(Model) vault = model.get_vault(uuid) if vault is None: raise HTTPReturn(http.NOT_FOUND) self._do_auth_rsa_cb(uuid) args = parse_qs(env.get('QUERY_STRING', '')) vector = args.get('vector', [''])[0] if vector: try: vector = parse_vector(vector) except ValueError: raise HTTPReturn(http.BAD_REQUEST) items = model.get_items(uuid, vector) myvector = model.get_vector(uuid) self.headers.append(('X-Vector', dump_vector(myvector))) return items @expose('/api/vaults/:vault/items', method='POST') def sync_inbound(self, env): uuid = env['mapper.vault'] if not check_uuid4(uuid): raise HTTPReturn(http.NOT_FOUND) model = instance(Model) vault = model.get_vault(uuid) if vault is None: raise HTTPReturn(http.NOT_FOUND) self._do_auth_rsa_cb(uuid) items = self.entity if items is None or not isinstance(items, list): raise HTTPReturn(http.BAD_REQUEST) model.import_items(uuid, items)
class SyncAPIClient(object): """ SyncAPI client. This classs implements a client to the Bluepass HTTP based synchronization API. The two main functions are pairing (pair_step1() and pair_step2()) and synchronization (sync()). """ def __init__(self, address, **ssl_args): """Create a new client for the syncapi API at `address`.""" self.address = address ssl_args.setdefault('dhparams', dhparams['skip2048']) ssl_args.setdefault('ciphers', 'ADH+AES') self.ssl_args = ssl_args self.connection = None logger = logging.getLogger(__name__) self.logger = ContextLogger(logger) self.crypto = CryptoProvider() def _make_request(self, method, url, headers=None, body=None): """Make an HTTP request to the API. This returns the HTTPResponse object on success, or None on failure. """ logger = self.logger if headers is None: headers = [] headers.append(('User-Agent', 'Bluepass/%s' % _version.version)) headers.append(('Accept', 'text/json')) if body is None: body = '' else: body = json.dumps(body) headers.append(('Content-Type', 'text/json')) connection = self.connection assert connection is not None try: logger.debug('client request: %s %s', method, url) connection.request(method, url, body, dict(headers)) response = connection.getresponse() headers = response.getheaders() body = response.read() except (socket.error, HTTPException) as e: logger.error('error when making HTTP request: %s', str(e)) return ctype = response.getheader('Content-Type') if ctype == 'text/json': parsed = json.try_loads(body) if parsed is None: logger.error('response body contains invalid JSON') return response.entity = parsed logger.debug('parsed "%s" request body (%d bytes)', ctype, len(body)) else: response.entity = None return response def connect(self): """Connect to the remote syncapi.""" ssl_args = {'dhparams': dhparams['skip2048'], 'ciphers': 'ADH+AES'} # Support both dict style addresses for arbitrary address families, # as well as (host, port) tuples for IPv4. if isinstance(self.address, dict): host = self.address['host'] port = None sockinfo = self.address else: host, port = self.address sockinfo = None connection = HTTPSConnection(host, port, sockinfo=sockinfo, **ssl_args) try: connection.connect() except socket.error as e: self.logger.error('could not connect to %s:%d' % self.address) raise SyncAPIError('RemoteError', 'Could not connect') self.connection = connection def close(self): """Close the connection.""" if self.connection is not None: try: conection.close() except Exception: pass self.connection = None def _get_hmac_cb_auth(self, kxid, pin): """Return the headers for a client to server HMAC_CB auth.""" cb = self.connection.sock.get_channel_binding('tls-unique') signature = self.crypto.hmac(adjust_pin(pin, +1), cb, 'sha1') signature = base64.encode(signature) auth = create_option_header('HMAC_CB', kxid=kxid, signature=signature) headers = [('Authorization', auth)] return headers def _check_hmac_cb_auth(self, response, pin): """Check a server to client HMAC_CB auth.""" logger = self.logger authinfo = response.getheader('Authentication-Info', '') try: method, options = parse_option_header(authinfo) except ValueError: logger.error('illegal Authentication-Info header: %s', authinfo) return False if 'signature' not in options or not base64.check( options['signature']): logger.error('illegal Authentication-Info header: %s', authinfo) return False signature = base64.decode(options['signature']) cb = self.connection.sock.get_channel_binding('tls-unique') check = self.crypto.hmac(adjust_pin(pin, -1), cb, 'sha1') if check != signature: logger.error('HMAC_CB signature did not match') return False return True def pair_step1(self, uuid, name): """Perform step 1 in a pairing exchange. If succesful, this returns a key exchange ID. On error, a SyncAPIError exception is raised. """ if self.connection is None: raise SyncAPIError('ProgrammingError', 'Not connected') logger = self.logger logger.setContext('pair step #1') url = '/api/vaults/%s/pair' % uuid headers = [('Authorization', 'HMAC_CB name=%s' % name)] response = self._make_request('POST', url, headers) if response is None: raise SyncAPIError('RemoteError', 'Could not make HTTP request') status = response.status if status != 401: logger.error('expecting HTTP status 401 (got: %s)', status) raise SyncAPIError('RemoteError', response.reason) wwwauth = response.getheader('WWW-Authenticate', '') try: method, options = parse_option_header(wwwauth) except ValueError: raise SyncAPIError('RemoteError', 'Illegal response') if method != 'HMAC_CB' or 'kxid' not in options: logger.error('illegal WWW-Authenticate header: %s', wwwauth) raise SyncAPIError('RemoteError', 'Illegal response') return options['kxid'] def pair_step2(self, uuid, kxid, pin, certinfo): """Perform step 2 in pairing exchange. If successfull, this returns the peer certificate. On error, a SyncAPIError is raised. """ if self.connection is None: raise SyncAPIError('ProgrammingError', 'Not connected') logger = self.logger logger.setContext('pair step #2') url = '/api/vaults/%s/pair' % uuid headers = self._get_hmac_cb_auth(kxid, pin) response = self._make_request('POST', url, headers, certinfo) if response is None: raise SyncAPIError('RemoteError', 'Could not make syncapi request') status = response.status if status != 200: logger.error('expecting HTTP status 200 (got: %s)', status) raise SyncAPIError('RemoteError', response.reason) if not self._check_hmac_cb_auth(response, pin): raise SyncAPIError('RemoteError', 'Illegal syncapi response') peercert = response.entity if peercert is None or not isinstance(peercert, dict): raise SyncAPIError('RemoteError', 'Illegal syncapi response') return peercert def _get_rsa_cb_auth(self, uuid, model): """Return the headers for RSA_CB authentication.""" cb = self.connection.sock.get_channel_binding('tls-unique') privkey = model.get_auth_key(uuid) assert privkey is not None signature = self.crypto.rsa_sign(cb, privkey, 'pss-sha1') signature = base64.encode(signature) vault = model.get_vault(uuid) auth = create_option_header('RSA_CB', node=vault['node'], signature=signature) headers = [('Authorization', auth)] return headers def _check_rsa_cb_auth(self, uuid, response, model): """Verify RSA_CB authentication.""" logger = self.logger authinfo = response.getheader('Authentication-Info', '') try: method, options = parse_option_header(authinfo) except ValueError: logger.error('illegal Authentication-Info header') return False if 'signature' not in options or 'node' not in options \ or not base64.check(options['signature']) \ or not check_uuid4(options['node']): logger.error('illegal Authentication-Info header') return False cb = self.connection.sock.get_channel_binding('tls-unique') signature = base64.decode(options['signature']) cert = model.get_certificate(uuid, options['node']) if cert is None: logger.error('unknown node in RSA_CB authentication', node) return False pubkey = base64.decode(cert['payload']['keys']['auth']['key']) try: status = self.crypto.rsa_verify(cb, signature, pubkey, 'pss-sha1') except CryptoError: logger.error('corrupt RSA_CB signature') return False if not status: logger.error('RSA_CB signature did not match') return status def sync(self, uuid, model, notify=True): """Synchronize vault `uuid` with the remote peer.""" if self.connection is None: raise SyncAPIError('ProgrammingError', 'Not connected') logger = self.logger logger.setContext('sync') vault = model.get_vault(uuid) if vault is None: raise SyncAPIError('NotFound', 'Vault not found') vector = model.get_vector(uuid) vector = dump_vector(vector) url = '/api/vaults/%s/items?vector=%s' % (vault['id'], vector) headers = self._get_rsa_cb_auth(uuid, model) response = self._make_request('GET', url, headers) if not response: raise SyncAPIError('RemoteError', 'Could not make HTTP request') status = response.status if status != 200: logger.error('expecting HTTP status 200 (got: %s)', status) raise SyncAPIError('RemoteError', 'Illegal syncapi response') if not self._check_rsa_cb_auth(uuid, response, model): raise SyncAPIError('RemoteError', 'Illegal syncapi response') initems = response.entity if initems is None or not isinstance(initems, list): raise SyncAPIError('RemoteError', 'Illegal syncapi response') nitems = model.import_items(uuid, initems, notify=notify) logger.debug('imported %d items into model', nitems) vector = response.getheader('X-Vector', '') try: vector = parse_vector(vector) except ValueError as e: logger.error('illegal X-Vector header: %s (%s)', vector, str(e)) raise SyncAPIError('RemoteError', 'Invalid response') outitems = model.get_items(uuid, vector) url = '/api/vaults/%s/items' % uuid response = self._make_request('POST', url, headers, outitems) if not response: raise SyncAPIError('RemoteError', 'Illegal syncapi response') if status != 200: logger.error('expecting HTTP status 200 (got: %s)', status) raise SyncAPIError('RemoteError', 'Illegal syncapi response') if not self._check_rsa_cb_auth(uuid, response, model): raise SyncAPIError('RemoteError', 'Illegal syncapi response') logger.debug('succesfully retrieved %d items from peer', len(initems)) logger.debug('succesfully pushed %d items to peer', len(outitems)) return len(initems) + len(outitems)