class EncryptedStore(SqliteStore): master_key = PluginOption(str, REQUIRED, None) master_enctype = PluginOption(str, 'A256CBC-HS512', None) def __init__(self, config, section): super(EncryptedStore, self).__init__(config, section) with open(self.master_key) as f: data = f.read() key = json_decode(data) self.mkey = JWK(**key) def get(self, key): value = super(EncryptedStore, self).get(key) if value is None: return None try: jwe = JWE() jwe.deserialize(value, self.mkey) return jwe.payload.decode('utf-8') except Exception: self.logger.exception("Error parsing key %s", key) raise CSStoreError('Error occurred while trying to parse key') def set(self, key, value, replace=False): protected = json_encode({'alg': 'dir', 'enc': self.master_enctype}) jwe = JWE(value, protected) jwe.add_recipient(self.mkey) cvalue = jwe.serialize(compact=True) return super(EncryptedStore, self).set(key, cvalue, replace)
class UserNameSpace(HTTPAuthorizer): path = PluginOption(str, '/', 'User namespace path') store = PluginOption('store', None, None) def handle(self, request): # Only check if we are in the right (sub)path path = request.get('path', '/') if not path.startswith(self.path): self.logger.debug('%s is not contained in %s', path, self.path) return None name = request.get('remote_user', None) if name is None: # UserNameSpace requires a user ... self.audit_svc_access(log.AUDIT_SVC_AUTHZ_FAIL, request['client_id'], path) return False # pylint: disable=no-member namespace = self.path.rstrip('/') + '/' + name + '/' if not path.startswith(namespace): # Not in the namespace self.audit_svc_access(log.AUDIT_SVC_AUTHZ_FAIL, request['client_id'], path) return False request['default_namespace'] = name self.audit_svc_access(log.AUDIT_SVC_AUTHZ_PASS, request['client_id'], path) return True
class SimpleClientCertAuth(HTTPAuthenticator): header = PluginOption(str, 'CUSTODIA_CERT_AUTH', "header name") def handle(self, request): cert_auth = request['headers'].get(self.header, "false").lower() client_cert = request['client_cert'] # {} or None if not client_cert or cert_auth not in {'1', 'yes', 'true', 'on'}: self.logger.debug('Ignoring request no relevant header or cert' ' provided') return None subject = client_cert.get('subject', {}) dn = [] name = None # TODO: check SAN first for rdn in subject: for key, value in rdn: dn.append('{}="{}"'.format(key, value.replace('"', r'\"'))) if key == 'commonName': name = value break dn = ', '.join(dn) self.logger.debug('Client cert subject: {}, serial: {}'.format( dn, client_cert.get('serialNumber'))) if name: self.audit_svc_access(log.AUDIT_SVC_AUTH_PASS, request['client_id'], name) request['remote_user'] = name return True self.audit_svc_access(log.AUDIT_SVC_AUTH_FAIL, request['client_id'], dn) return False
class HeadHandler(HTTPConsumer): store = PluginOption('store', None, None) def __init__(self, config, section): super(HeadHandler, self).__init__(config, section) if self.store_name is not None: self.add_sub('secrets', HeadSecrets(config, section)) def _find_handler(self, request): base = self command = request.get('command', 'GET') if command not in SUPPORTED_COMMANDS: raise HTTPError(501) trail = request.get('trail', None) if trail is not None: for comp in trail: subs = getattr(base, 'subs', {}) if comp in subs: base = subs[comp] trail.pop(0) else: break handler = getattr(base, command) if handler is None: raise HTTPError(400) return handler
class ContainerAuth(HTTPAuthenticator): matchers = PluginOption('str_list', ['docker', 'rkt'], None) docker_regex = PluginOption('regex', DOCKER_REGEX, None) rkt_regex = PluginOption('regex', RKT_REGEX, None) def _read_cgroup(self, pid): with open('/proc/{:d}/cgroup'.format(pid)) as f: return list(f) def _match_docker(self, lines): for line in lines: # pylint: disable=no-member mo = self.docker_regex.search(line) if mo is not None: return mo.group(1) return None, None def _match_rkt(self, lines): for line in lines: # pylint: disable=no-member mo = self.docker_regex.search(line) if mo is not None: return '-'.join(mo.groups()) def handle(self, request): creds = request.get('creds') if creds is None: self.logger.debug('Missing "creds" on request') return None pid = int(creds['pid']) try: lines = self._read_cgroup(pid) except OSError: self.logger.exception("Failed to read cgroup for pid %i", pid) return None for matcher in self.matchers: # pylint: disable=not-an-iterable func = getattr(self, '_match_{}'.format(matcher)) result = func(lines) if result is not None: creds['container'] = (matcher, result) self.logger.info("Detected %s://%s for pid %i", matcher, result, pid) return True return False
class SimpleCredsAuth(HTTPAuthenticator): uid = PluginOption('pwd_uid', 0, "User id or name") gid = PluginOption('grp_gid', 0, "Group id or name") def handle(self, request): creds = request.get('creds') if creds is None: self.logger.debug('SCA: Missing "creds" from request') return False uid = int(creds['gid']) gid = int(creds['uid']) if self.gid == gid or self.uid == uid: self.audit_svc_access(log.AUDIT_SVC_AUTH_PASS, request['client_id'], "%d, %d" % (uid, gid)) return True else: self.audit_svc_access(log.AUDIT_SVC_AUTH_FAIL, request['client_id'], "%d, %d" % (uid, gid)) return False
class Root(HTTPConsumer): store = PluginOption('store', None, None) def __init__(self, config, section): super(Root, self).__init__(config, section) if self.store_name is not None: self.add_sub('secrets', Secrets(config, section)) def GET(self, request, response): msg = json.dumps({'message': "Quis custodiet ipsos custodes?"}) return msg.encode('utf-8')
class SimpleHeaderAuth(HTTPAuthenticator): header = PluginOption(str, 'REMOTE_USER', "header name") value = PluginOption('str_set', None, "Comma-separated list of required values") def handle(self, request): if self.header not in request['headers']: self.logger.debug('SHA: No "headers" in request') return None value = request['headers'][self.header] if self.value is not None: # pylint: disable=unsupported-membership-test if value not in self.value: self.audit_svc_access(log.AUDIT_SVC_AUTH_FAIL, request['client_id'], value) return False self.audit_svc_access(log.AUDIT_SVC_AUTH_PASS, request['client_id'], value) request['remote_user'] = value return True
class SimpleAuthKeys(HTTPAuthenticator): id_header = PluginOption(str, 'CUSTODIA_AUTH_ID', "auth id header name") key_header = PluginOption(str, 'CUSTODIA_AUTH_KEY', "auth key header name") store = PluginOption('store', None, None) store_namespace = PluginOption(str, 'custodiaSAK', "") def _db_key(self, name): return os.path.join(self.store_namespace, name) def handle(self, request): name = request['headers'].get(self.id_header, None) key = request['headers'].get(self.key_header, None) if name is None and key is None: self.logger.debug('Ignoring request no relevant headers provided') return None validated = False try: val = self.store.get(self._db_key(name)) if val is None: raise ValueError("No such ID") if constant_time.bytes_eq(val.encode('utf-8'), key.encode('utf-8')): validated = True except Exception: # pylint: disable=broad-except self.audit_svc_access(log.AUDIT_SVC_AUTH_FAIL, request['client_id'], name) return False if validated: self.audit_svc_access(log.AUDIT_SVC_AUTH_PASS, request['client_id'], name) request['remote_user'] = name return True self.audit_svc_access(log.AUDIT_SVC_AUTH_FAIL, request['client_id'], name) return False
class IPAVault(CSStore): # vault arguments principal = PluginOption( str, None, "Service principal for service vault (auto-discovered from GSSAPI)" ) user = PluginOption( str, None, "User name for user vault (auto-discovered from GSSAPI)" ) vault_type = PluginOption( str, None, "vault type, one of 'user', 'service', 'shared', or " "auto-discovered from GSSAPI" ) def __init__(self, config, section=None, api=None): super(IPAVault, self).__init__(config, section) self._vault_args = None self.ipa = None def finalize_init(self, config, cfgparser, context=None): super(IPAVault, self).finalize_init(config, cfgparser, context) if self.ipa is not None: return self.ipa = IPAInterface.from_config(config) self.ipa.finalize_init(config, cfgparser, context=self) # connect with self.ipa: # retrieve and cache KRA transport cert response = self.ipa.Command.vaultconfig_show() servers = response[u'result'].get(u'kra_server_server', ()) if servers: self.logger.info("KRA server(s) %s", ', '.join(servers)) service, user_host, realm = krb5_unparse_principal_name( self.ipa.principal) self._init_vault_args(service, user_host, realm) def _init_vault_args(self, service, user_host, realm): if self.vault_type is None: self.vault_type = 'user' if service is None else 'service' self.logger.info("Setting vault type to '%s' from Kerberos", self.vault_type) if self.vault_type == 'shared': self._vault_args = {'shared': True} elif self.vault_type == 'user': if self.user is None: if service is not None: msg = "{!r}: User vault requires 'user' parameter" raise ValueError(msg.format(self)) else: self.user = user_host self.logger.info(u"Setting username '%s' from Kerberos", self.user) if six.PY2 and isinstance(self.user, str): self.user = self.user.decode('utf-8') self._vault_args = {'username': self.user} elif self.vault_type == 'service': if self.principal is None: if service is None: msg = "{!r}: Service vault requires 'principal' parameter" raise ValueError(msg.format(self)) else: self.principal = u'/'.join((service, user_host)) self.logger.info(u"Setting principal '%s' from Kerberos", self.principal) if six.PY2 and isinstance(self.principal, str): self.principal = self.principal.decode('utf-8') self._vault_args = {'service': self.principal} else: msg = '{!r}: Invalid vault type {}' raise ValueError(msg.format(self, self.vault_type)) def _mangle_key(self, key): if '__' in key: raise ValueError key = key.replace('/', '__') if isinstance(key, bytes): key = key.decode('utf-8') return key def get(self, key): key = self._mangle_key(key) with self.ipa as ipa: try: result = ipa.Command.vault_retrieve( key, **self._vault_args) except NotFound as e: self.logger.info("Key '%s' not found: %s", key, e) return None except Exception: msg = "Failed to retrieve entry {}".format(key) self.logger.exception(msg) raise CSStoreError(msg) else: return result[u'result'][u'data'] def set(self, key, value, replace=False): key = self._mangle_key(key) if not isinstance(value, bytes): value = value.encode('utf-8') with self.ipa as ipa: try: ipa.Command.vault_add( key, ipavaulttype=u"standard", **self._vault_args) except DuplicateEntry as e: self.logger.info("Vault '%s' already exists: %s", key, e) if not replace: raise CSStoreExists(key) except AuthorizationError: msg = "vault_add denied for entry {}".format(key) self.logger.exception(msg) raise CSStoreDenied(msg) except Exception: msg = "Failed to add entry {}".format(key) self.logger.exception(msg) raise CSStoreError(msg) try: ipa.Command.vault_archive( key, data=value, **self._vault_args) except AuthorizationError: msg = "vault_archive denied for entry {}".format(key) self.logger.exception(msg) raise CSStoreDenied(msg) except Exception: msg = "Failed to archive entry {}".format(key) self.logger.exception(msg) raise CSStoreError(msg) def span(self, key): raise CSStoreUnsupported("span is not implemented") def list(self, keyfilter=None): with self.ipa as ipa: try: result = ipa.Command.vault_find( ipavaulttype=u"standard", **self._vault_args) except AuthorizationError: msg = "vault_find denied" self.logger.exception(msg) raise CSStoreDenied(msg) except Exception: msg = "Failed to list entries" self.logger.exception(msg) raise CSStoreError(msg) names = [] for entry in result[u'result']: cn = entry[u'cn'][0] key = cn.replace('__', '/') if keyfilter is not None and not key.startswith(keyfilter): continue names.append(key.rsplit('/', 1)[-1]) return names def cut(self, key): key = self._mangle_key(key) with self.ipa as ipa: try: ipa.Command.vault_del(key, **self._vault_args) except NotFound: return False except AuthorizationError: msg = "vault_del denied for entry {}".format(key) self.logger.exception(msg) raise CSStoreDenied(msg) except Exception: msg = "Failed to delete entry {}".format(key) self.logger.exception(msg) raise CSStoreError(msg) else: return True
class Secrets(HTTPConsumer): allowed_keytypes = PluginOption('str_set', 'simple', None) store = PluginOption('store', None, None) def __init__(self, config, section): super(Secrets, self).__init__(config, section) self._validator = Validator(self.allowed_keytypes) def _db_key(self, trail): if len(trail) < 2: self.logger.debug( "Forbidden action: Operation only permitted within a " "container") raise HTTPError(403) return os.path.join('keys', *trail) def _db_container_key(self, default, trail): f = None if len(trail) > 1: f = self._db_key(trail) elif len(trail) == 1 and trail[0] != '': self.logger.debug( "Forbidden action: Wrong container path. Container names must " "end with '/'") raise HTTPError(403) elif default is None: self.logger.debug("Forbidden action: No default namespace") raise HTTPError(403) else: # Use the default namespace f = self._db_key([default, '']) return f def _parse(self, request, query, name): return self._validator.parse(request, query, name) def _parse_query(self, request, name): # default to simple query = request.get('query', '') if len(query) == 0: query = {'type': 'simple', 'value': ''} return self._parse(request, query, name) def _parse_bin_body(self, request, name): body = request.get('body') if body is None: raise HTTPError(400) value = b64encode(bytes(body)).decode('utf-8') payload = {'type': 'simple', 'value': value} return self._parse(request, payload, name) def _parse_body(self, request, name): body = request.get('body') if body is None: raise HTTPError(400) value = json.loads(bytes(body).decode('utf-8')) return self._parse(request, value, name) def _parse_maybe_body(self, request, name): body = request.get('body') if body is None: value = {'type': 'simple', 'value': ''} else: value = json.loads(bytes(body).decode('utf-8')) return self._parse(request, value, name) def _parent_exists(self, default, trail): # check that the containers exist basename = self._db_container_key(trail[0], trail[:-1] + ['']) try: keylist = self.root.store.list(basename) except CSStoreError: raise HTTPError(500) self.logger.debug('parent_exists: %s (%s, %r) -> %r', basename, default, trail, keylist) if keylist is not None: return True # create default namespace if it is the only missing piece if len(trail) == 2 and default == trail[0]: container = self._db_container_key(default, '') self.root.store.span(container) return True return False def _format_reply(self, request, response, handler, output): reply = handler.reply(output) # special case to allow *very* simple clients if handler.msg_type == 'simple': binary = False accept = request.get('headers', {}).get('Accept', None) if accept is not None: types = accept.split(',') for t in types: if t.strip() == 'application/json': binary = False break elif t.strip() == 'application/octet-stream': binary = True if binary is True: response['headers'][ 'Content-Type'] = 'application/octet-stream' response['output'] = b64decode(reply['value']) return if reply is not None: response['headers'][ 'Content-Type'] = 'application/json; charset=utf-8' response['output'] = reply def GET(self, request, response): trail = request.get('trail', []) if len(trail) == 0 or trail[-1] == '': self._list(trail, request, response) else: self._get_key(trail, request, response) def PUT(self, request, response): trail = request.get('trail', []) if len(trail) == 0 or trail[-1] == '': raise HTTPError(405) else: self._set_key(trail, request, response) def DELETE(self, request, response): trail = request.get('trail', []) if len(trail) == 0: raise HTTPError(405) if trail[-1] == '': self._destroy(trail, request, response) else: self._del_key(trail, request, response) def POST(self, request, response): trail = request.get('trail', []) if len(trail) > 0 and trail[-1] == '': self._create(trail, request, response) else: raise HTTPError(405) def _list(self, trail, request, response): try: name = '/'.join(trail) msg = self._parse_query(request, name) except Exception as e: raise HTTPError(406, str(e)) default = request.get('default_namespace', None) basename = self._db_container_key(default, trail) try: keylist = self.root.store.list(basename) self.logger.debug('list %s returned %r', basename, keylist) if keylist is None: raise HTTPError(404) response['headers'][ 'Content-Type'] = 'application/json; charset=utf-8' response['output'] = msg.reply(keylist) except CSStoreDenied: self.logger.exception( "List: Permission to perform this operation was denied") raise HTTPError(403) except CSStoreError: self.logger.exception('List: Internal server error') raise HTTPError(500) except CSStoreUnsupported: self.logger.exception('List: Unsupported operation') raise HTTPError(501) def _create(self, trail, request, response): try: name = '/'.join(trail) msg = self._parse_maybe_body(request, name) except Exception as e: raise HTTPError(406, str(e)) default = request.get('default_namespace', None) basename = self._db_container_key(None, trail) try: if len(trail) > 2: ok = self._parent_exists(default, trail[:-1]) if not ok: raise HTTPError(404) self.root.store.span(basename) except CSStoreDenied: self.logger.exception( "Create: Permission to perform this operation was denied") raise HTTPError(403) except CSStoreExists: self.logger.exception('Create: Key already exists') raise HTTPError(409) except CSStoreError: self.logger.exception('Create: Internal server error') raise HTTPError(500) except CSStoreUnsupported: self.logger.exception('Create: Unsupported operation') raise HTTPError(501) output = msg.reply(None) if output is not None: response['headers'][ 'Content-Type'] = 'application/json; charset=utf-8' response['output'] = output response['code'] = 201 def _destroy(self, trail, request, response): try: name = '/'.join(trail) msg = self._parse_maybe_body(request, name) except Exception as e: raise HTTPError(406, str(e)) basename = self._db_container_key(None, trail) try: keylist = self.root.store.list(basename) if keylist is None: raise HTTPError(404) if len(keylist) != 0: raise HTTPError(409) ret = self.root.store.cut(basename.rstrip('/')) except CSStoreDenied: self.logger.exception( "Delete: Permission to perform this operation was denied") raise HTTPError(403) except CSStoreError: self.logger.exception('Delete: Internal server error') raise HTTPError(500) except CSStoreUnsupported: self.logger.exception('Delete: Unsupported operation') raise HTTPError(501) if ret is False: raise HTTPError(404) output = msg.reply(None) if output is None: response['code'] = 204 else: response['headers'][ 'Content-Type'] = 'application/json; charset=utf-8' response['output'] = output response['code'] = 200 def _client_name(self, request): if 'remote_user' in request: return request['remote_user'] elif 'creds' in request: creds = request['creds'] return '<pid={pid:d} uid={uid:d} gid={gid:d}>'.format(**creds) else: return 'Unknown' def _audit(self, ok, fail, fn, trail, request, response): action = fail client = self._client_name(request) key = '/'.join(trail) try: fn(trail, request, response) action = ok finally: self.audit_key_access(action, client, key) def _get_key(self, trail, request, response): self._audit(log.AUDIT_GET_ALLOWED, log.AUDIT_GET_DENIED, self._int_get_key, trail, request, response) def _int_get_key(self, trail, request, response): try: name = '/'.join(trail) handler = self._parse_query(request, name) except Exception as e: raise HTTPError(406, str(e)) key = self._db_key(trail) try: output = self.root.store.get(key) if output is None: raise HTTPError(404) elif len(output) == 0: raise HTTPError(406) self._format_reply(request, response, handler, output) except CSStoreDenied: self.logger.exception( "Get: Permission to perform this operation was denied") raise HTTPError(403) except CSStoreError: self.logger.exception('Get: Internal server error') raise HTTPError(500) except CSStoreUnsupported: self.logger.exception('Get: Unsupported operation') raise HTTPError(501) def _set_key(self, trail, request, response): self._audit(log.AUDIT_SET_ALLOWED, log.AUDIT_SET_DENIED, self._int_set_key, trail, request, response) def _int_set_key(self, trail, request, response): try: name = '/'.join(trail) content_type = request.get('headers', {}).get('Content-Type', '') content_type_value = content_type.split(';')[0].strip() if content_type_value == 'application/octet-stream': msg = self._parse_bin_body(request, name) elif content_type_value == 'application/json': msg = self._parse_body(request, name) else: raise ValueError('Invalid Content-Type') except UnknownMessageType as e: raise HTTPError(406, str(e)) except UnallowedMessage as e: raise HTTPError(406, str(e)) except Exception as e: raise HTTPError(400, str(e)) # must _db_key first as access control is done here for now # otherwise users would e able to probe containers in namespaces # they do not have access to. key = self._db_key(trail) try: default = request.get('default_namespace', None) ok = self._parent_exists(default, trail) if not ok: raise HTTPError(404) ok = self.root.store.set(key, msg.payload) except CSStoreDenied: self.logger.exception( "Set: Permission to perform this operation was denied") raise HTTPError(403) except CSStoreExists: self.logger.exception('Set: Key already exist') raise HTTPError(409) except CSStoreError: self.logger.exception('Set: Internal Server Error') raise HTTPError(500) except CSStoreUnsupported: self.logger.exception('Set: Unsupported operation') raise HTTPError(501) output = msg.reply(None) if output is not None: response['headers'][ 'Content-Type'] = 'application/json; charset=utf-8' response['output'] = output response['code'] = 201 def _del_key(self, trail, request, response): self._audit(log.AUDIT_DEL_ALLOWED, log.AUDIT_DEL_DENIED, self._int_del_key, trail, request, response) def _int_del_key(self, trail, request, response): try: name = '/'.join(trail) msg = self._parse_maybe_body(request, name) except Exception as e: raise HTTPError(406, str(e)) key = self._db_key(trail) try: ret = self.root.store.cut(key) except CSStoreDenied: self.logger.exception( "Delete: Permission to perform this operation was denied") raise HTTPError(403) except CSStoreError: self.logger.exception('Delete: Internal Server Error') raise HTTPError(500) except CSStoreUnsupported: self.logger.exception('Delete: Unsupported operation') raise HTTPError(501) if ret is False: raise HTTPError(404) output = msg.reply(None) if output is None: response['code'] = 204 else: response['headers'][ 'Content-Type'] = 'application/json; charset=utf-8' response['output'] = output response['code'] = 200
class SqliteStore(CSStore): dburi = PluginOption(str, REQUIRED, None) table = PluginOption(str, "CustodiaSecrets", None) filemode = PluginOption(oct, '600', None) def __init__(self, config, section): super(SqliteStore, self).__init__(config, section) # Initialize the DB by trying to create the default table try: conn = sqlite3.connect(self.dburi) os.chmod(self.dburi, self.filemode) with conn: c = conn.cursor() self._create(c) except sqlite3.Error: self.logger.exception("Error creating table %s", self.table) raise CSStoreError('Error occurred while trying to init db') def get(self, key): self.logger.debug("Fetching key %s", key) query = "SELECT value from %s WHERE key=?" % self.table try: conn = sqlite3.connect(self.dburi) c = conn.cursor() r = c.execute(query, (key, )) value = r.fetchall() except sqlite3.Error: self.logger.exception("Error fetching key %s", key) raise CSStoreError('Error occurred while trying to get key') self.logger.debug("Fetched key %s got result: %r", key, value) if len(value) > 0: return value[0][0] else: return None def _create(self, cur): create = "CREATE TABLE IF NOT EXISTS %s " \ "(key PRIMARY KEY UNIQUE, value)" % self.table cur.execute(create) def set(self, key, value, replace=False): self.logger.debug("Setting key %s to value %s (replace=%s)", key, value, replace) if key.endswith('/'): raise ValueError('Invalid Key name, cannot end in "/"') if replace: query = "INSERT OR REPLACE into %s VALUES (?, ?)" else: query = "INSERT into %s VALUES (?, ?)" setdata = query % (self.table, ) try: conn = sqlite3.connect(self.dburi) with conn: c = conn.cursor() self._create(c) c.execute(setdata, (key, value)) except sqlite3.IntegrityError as err: raise CSStoreExists(str(err)) except sqlite3.Error as err: self.logger.exception("Error storing key %s", key) raise CSStoreError('Error occurred while trying to store key') def span(self, key): name = key.rstrip('/') self.logger.debug("Creating container %s", name) query = "INSERT into %s VALUES (?, '')" setdata = query % (self.table, ) try: conn = sqlite3.connect(self.dburi) with conn: c = conn.cursor() self._create(c) c.execute(setdata, (name, )) except sqlite3.IntegrityError as err: raise CSStoreExists(str(err)) except sqlite3.Error: self.logger.exception("Error creating key %s", name) raise CSStoreError('Error occurred while trying to span container') def list(self, keyfilter=''): path = keyfilter.rstrip('/') self.logger.debug("Listing keys matching %s", path) child_prefix = path if path == '' else path + '/' search = "SELECT key FROM %s WHERE key LIKE ?" % self.table key = "%s%%" % (path, ) try: conn = sqlite3.connect(self.dburi) r = conn.execute(search, (key, )) rows = r.fetchall() except sqlite3.Error: self.logger.exception("Error listing %s: [%r]", keyfilter) raise CSStoreError('Error occurred while trying to list keys') self.logger.debug("Searched for %s got result: %r", path, rows) if len(rows) > 0: parent_exists = False value = list() for row in rows: if row[0] == path or row[0] == child_prefix: parent_exists = True continue if not row[0].startswith(child_prefix): continue value.append(row[0][len(child_prefix):].lstrip('/')) if value: self.logger.debug("Returning sorted values %r", value) return sorted(value) elif parent_exists: self.logger.debug("Returning empty list") return [] elif keyfilter == '': self.logger.debug("Returning empty list") return [] self.logger.debug("Returning 'Not Found'") return None def cut(self, key): self.logger.debug("Removing key %s", key) query = "DELETE from %s WHERE key=?" % self.table try: conn = sqlite3.connect(self.dburi) with conn: c = conn.cursor() r = c.execute(query, (key, )) except sqlite3.Error: self.logger.error("Error removing key %s", key) raise CSStoreError('Error occurred while trying to cut key') self.logger.debug("Key %s %s", key, "removed" if r.rowcount > 0 else "not found") if r.rowcount > 0: return True return False
class EncryptedOverlay(CSStore): """Encrypted overlay for storage backends Arguments: backing_store (required): name of backing storage master_key (required) path to master key (JWK JSON) autogen_master_key (default: false) auto-generate key file if missing? master_enctype (default: A256CBC_HS512) JWE algorithm name secret_protection (default: 'encrypt'): Determine the kind of protection used to save keys: - 'encrypt': this is the classic method (backwards compatible) - 'pinning': this adds a protected header with the key name as add data, to prevent key swapping in the db - 'migrate': as pinning, but on missing key information the secret is updated instead of throwing an exception. """ key_sizes = { 'A128CBC-HS256': 256, 'A256CBC-HS512': 512, } backing_store = PluginOption(str, REQUIRED, None) master_enctype = PluginOption(str, 'A256CBC-HS512', None) master_key = PluginOption(str, REQUIRED, None) autogen_master_key = PluginOption(bool, False, None) secret_protection = PluginOption(str, False, 'encrypt') def __init__(self, config, section): super(EncryptedOverlay, self).__init__(config, section) self.store_name = self.backing_store self.store = None self.protected_header = None if (not os.path.isfile(self.master_key) and self.autogen_master_key): # XXX https://github.com/latchset/jwcrypto/issues/50 size = self.key_sizes.get(self.master_enctype, 512) key = JWK(generate='oct', size=size) with open(self.master_key, 'w') as f: os.fchmod(f.fileno(), 0o600) f.write(key.export()) with open(self.master_key) as f: data = f.read() key = json_decode(data) self.mkey = JWK(**key) def get(self, key): value = self.store.get(key) if value is None: return None try: jwe = JWE() jwe.deserialize(value, self.mkey) value = jwe.payload.decode('utf-8') except Exception as err: self.logger.error("Error parsing key %s: [%r]" % (key, repr(err))) raise CSStoreError('Error occurred while trying to parse key') if self.secret_protection == 'encrypt': return value if 'custodia.key' not in jwe.jose_header: if self.secret_protection == 'migrate': self.set(key, value, replace=True) else: raise CSStoreError('Secret Pinning check failed!' + 'Missing custodia.key element') elif jwe.jose_header['custodia.key'] != key: raise CSStoreError( 'Secret Pinning check failed! Expected {} got {}'.format( key, jwe.jose_header['custodia.key'])) return value def set(self, key, value, replace=False): self.protected_header = {'alg': 'dir', 'enc': self.master_enctype} if self.secret_protection != 'encrypt': self.protected_header['custodia.key'] = key protected = json_encode(self.protected_header) jwe = JWE(value, protected) jwe.add_recipient(self.mkey) cvalue = jwe.serialize(compact=True) return self.store.set(key, cvalue, replace) def span(self, key): return self.store.span(key) def list(self, keyfilter=''): return self.store.list(keyfilter) def cut(self, key): return self.store.cut(key)
class EncryptedOverlay(CSStore): """Encrypted overlay for storage backends Arguments: backing_store (required): name of backing storage master_key (required) path to master key (JWK JSON) autogen_master_key (default: false) auto-generate key file if missing? master_enctype (default: A256CBC_HS512) JWE algorithm name """ key_sizes = { 'A128CBC-HS256': 256, 'A256CBC-HS512': 512, } backing_store = PluginOption(str, REQUIRED, None) master_enctype = PluginOption(str, 'A256CBC-HS512', None) master_key = PluginOption(str, REQUIRED, None) autogen_master_key = PluginOption(bool, False, None) def __init__(self, config, section): super(EncryptedOverlay, self).__init__(config, section) self.store_name = self.backing_store self.store = None if (not os.path.isfile(self.master_key) and self.autogen_master_key): # XXX https://github.com/latchset/jwcrypto/issues/50 size = self.key_sizes.get(self.master_enctype, 512) key = JWK(generate='oct', size=size) with open(self.master_key, 'w') as f: os.fchmod(f.fileno(), 0o600) f.write(key.export()) with open(self.master_key) as f: data = f.read() key = json_decode(data) self.mkey = JWK(**key) def get(self, key): value = self.store.get(key) if value is None: return None try: jwe = JWE() jwe.deserialize(value, self.mkey) return jwe.payload.decode('utf-8') except Exception as err: self.logger.error("Error parsing key %s: [%r]" % (key, repr(err))) raise CSStoreError('Error occurred while trying to parse key') def set(self, key, value, replace=False): protected = json_encode({'alg': 'dir', 'enc': self.master_enctype}) jwe = JWE(value, protected) jwe.add_recipient(self.mkey) cvalue = jwe.serialize(compact=True) return self.store.set(key, cvalue, replace) def span(self, key): return self.store.span(key) def list(self, keyfilter=''): return self.store.list(keyfilter) def cut(self, key): return self.store.cut(key)
class IPACertRequest(CSStore): """IPA cert request store The IPACertRequest store plugin generates or revokes certificates on the fly. It uses a backing store to cache certs and private keys. The request ```GET /secrets/certs/HTTP/client1.ipa.example``` generates a private key and CSR for the service ```HTTP/client1.ipa.example``` with DNS subject alternative name ```client1.ipa.example```. A DELETE request removes the cert/key pair from the backing store and revokes the cert at the same time. """ backing_store = PluginOption(str, REQUIRED, None) key_size = PluginOption(int, 2048, 'RSA key size') cert_profile = PluginOption(str, 'caIPAserviceCert', 'IPA cert profile') add_principal = PluginOption(bool, True, 'Add missing principal') chain = PluginOption(bool, True, 'Return full cert chain') allowed_services = PluginOption('str_set', {'HTTP'}, 'Service prefixes') revocation_reason = PluginOption(int, 4, 'Cert revocation reason (4: superseded)') def __init__(self, config, section=None): super(IPACertRequest, self).__init__(config, section) self.store_name = self.backing_store self.store = None self.ipa = IPAInterface.get_instance() if not isinstance(self.cert_profile, six.text_type): self.cert_profile = self.cert_profile.decode('utf-8') def _parse_key(self, key): if not isinstance(key, six.text_type): key = key.decode('utf-8') parts = key.split(u'/') # XXX why is 'keys' added in in Secrets._db_key()? if len(parts) != 3 or parts[0] != 'keys': raise CSStoreError("Invalid cert request key '{}'".format(key)) service, hostname = parts[1:3] # pylint: disable=unsupported-membership-test if service not in self.allowed_services: raise CSStoreError("Invalid service '{}'".format(key)) principal = krb5_format_service_principal_name(service, hostname, self.ipa.env.realm) # use cert prefix in storage key key = u"cert/{}/{}".format(service, hostname) return key, hostname, principal def get(self, key): # check key first key, hostname, principal = self._parse_key(key) value = self.store.get(key) if value is not None: # TODO: validate certificate self.logger.info("Found cached certificate for %s", principal) return value # found no cached key/cert pair, generate one try: data = self._request_cert(hostname, principal) except AuthorizationError: msg = "Unauthorized request for '{}' ({})".format( hostname, principal) self.logger.info(msg, exc_info=True) raise CSStoreError(msg) except NotFound: msg = "Host '{}' or principal '{}' not found".format( hostname, principal) self.logger.info(msg, exc_info=True) raise CSStoreError(msg) except Exception: msg = "Failed to request cert '{}' ({})".format( hostname, principal) self.logger.error(msg, exc_info=True) raise CSStoreError(msg) self.store.set(key, data, replace=True) return data def set(self, key, value, replace=False): key, hostname, principal = self._parse_key(key) del hostname, principal return self.store.set(key, value, replace) def span(self, key): key, hostname, principal = self._parse_key(key) del hostname, principal return self.store.span(key) def list(self, keyfilter=''): return self.store.list(keyfilter) def cut(self, key): key, hostname, principal = self._parse_key(key) certs = self._revoke_certs(hostname, principal) return self.store.cut(key) or certs def _request_cert(self, hostname, principal): self.logger.info("Requesting certificate for %s", hostname) csrgen = _ServerCSRGenerator(plugin=self) builder = csrgen.build_csr(hostname=hostname) response, pem = csrgen.request_cert(builder, principal=principal) self.logger.info( "Got certificate for '%s', request id %s, serial number %s", response[u'result'][u'subject'], response[u'result'][u'request_id'], response[u'result'][u'serial_number'], ) return pem def _revoke_certs(self, hostname, principal): with self.ipa as ipa: response = ipa.Command.cert_find( service=principal, validnotafter_from=datetime.datetime.utcnow(), ) # XXX cert_find has no filter for valid cert certs = list(cert for cert in response['result'] if not cert[u'revoked']) for cert in certs: self.logger.info('Revoking cert %i (subject: %s, issuer: %s)', cert[u'serial_number'], cert[u'subject'], cert[u'issuer']) ipa.Command.cert_revoke( cert[u'serial_number'], revocation_reason=self.revocation_reason, ) return certs
class IPAInterface(HTTPAuthenticator): """IPA interface authenticator Custodia uses a forking server model. We can bootstrap FreeIPA API in the main process. Connections must be created in the client process. """ # Kerberos flags krb5config = PluginOption(str, None, "Kerberos krb5.conf override") keytab = PluginOption(str, None, "Kerberos keytab for auth") ccache = PluginOption(str, None, "Kerberos ccache, e,g. FILE:/path/to/ccache") # ipalib.api arguments ipa_confdir = PluginOption(str, None, "IPA confdir override") ipa_context = PluginOption(str, "cli", "IPA bootstrap context") ipa_debug = PluginOption(bool, False, "debug mode for ipalib") # filled by gssapi() principal = False # singleton _instance = None @classmethod def get_instance(cls): if cls._instance is None: raise RuntimeError("{} not initialized".format(IPA_SECTIONNAME)) return cls._instance @classmethod def set_instance(cls, instance): cls._instance = instance def __init__(self, config, section=None, api=None): super(IPAInterface, self).__init__(config, section) # only one instance of this plugin is supported if section != IPA_SECTIONNAME: raise ValueError(section) if api is None: self._api = ipalib.api else: self._api = api if self._api.isdone('bootstrap'): raise RuntimeError("IPA API already initialized") self._ipa_config = dict( context=self.ipa_context, debug=self.ipa_debug, log=None, # disable logging to file ) if self.ipa_confdir is not None: self._ipa_config['confdir'] = self.ipa_confdir self._gssapi_config() self._bootstrap() with self: self.logger.info("IPA server '%s': %s", self.env.server, self.Command.ping()[u'summary']) self.set_instance(self) def handle(self, request): request[IPA_SECTIONNAME] = self return None # rest is interface and initialization def _gssapi_config(self): # set client keytab env var for authentication if self.keytab is not None: os.environ['KRB5_CLIENT_KTNAME'] = self.keytab if self.ccache is not None: os.environ['KRB5CCNAME'] = self.ccache if self.krb5config is not None: os.environ['KRB5_CONFIG'] = self.krb5config self.principal = self._gssapi_cred() self.logger.info(u"Kerberos principal '%s'", self.principal) def _gssapi_cred(self): try: return get_principal() except Exception: self.logger.error( "Unable to get principal from GSSAPI. Are you missing a " "TGT or valid Kerberos keytab?") raise def _bootstrap(self): # TODO: bandaid for "A PKCS #11 module returned CKR_DEVICE_ERROR" # https://github.com/avocado-framework/avocado/issues/1112#issuecomment-206999400 os.environ['NSS_STRICT_NOFORK'] = 'DISABLED' self._api.bootstrap(**self._ipa_config) self._api.finalize() @property def Command(self): return self._api.Command # pylint: disable=no-member @property def env(self): return self._api.env # pylint: disable=no-member def __enter__(self): # pylint: disable=no-member self._gssapi_cred() if not self._api.Backend.rpcclient.isconnected(): self._api.Backend.rpcclient.connect() # pylint: enable=no-member return self def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=no-member if self._api.Backend.rpcclient.isconnected(): self._api.Backend.rpcclient.disconnect()
class OpenShiftHostnameAuthz(HTTPAuthorizer): """ https://github.com/openshift/origin/blob/master/docs/cluster_up_down.md $ oc project test $ oc create serviceaccount custodia $ oc policy add-role-to-user view system:serviceaccount:test:custodia $ oc describe serviceaccount custodia ... Mountable secrets: custodia-token-... ... $ oc describe secret custodia-token-... """ oc_uri = PluginOption(str, REQUIRED, None) token = PluginOption(str, REQUIRED, None) project = PluginOption(str, REQUIRED, None) tls_verify = PluginOption(bool, True, None) tls_cafile = PluginOption(str, INHERIT_GLOBAL(None), 'Path to CA file') hostname_annotation = PluginOption( str, "latchset.github.io/custodia.hostname", None ) def __init__(self, config, section=None): super(OpenShiftHostnameAuthz, self).__init__(config, section) self.session = None self.oc_uri = self.oc_uri.rstrip('/') def finalize_init(self, config, cfgparser, context=None): super(OpenShiftHostnameAuthz, self).finalize_init( config, cfgparser, context) self.session = requests.Session() self.session.headers["Authorization"] = "Bearer {}".format(self.token) if self.tls_cafile is not None: self.session.verify = self.tls_cafile else: self.session.verify = self.tls_verify def get_pods(self): url = "{0.oc_uri}/api/v1/namespaces/{0.project}/pods".format(self) response = self.session.get(url) response.raise_for_status() return response.json() def find_pod(self, data, containerid): for pod in data['items']: for status in pod[u'status'][u'containerStatuses']: if status[u'containerID'] == containerid: return pod def handle(self, request): creds = request.get('creds') if creds is None: self.logger.debug('Missing "creds" on request') return None container = creds.get('container') if not container: self.logger.debug('Missing "container" on request') return None containerid = "{}://{}".format(*container) pods = self.get_pods() pod = self.find_pod(pods, containerid) if pod is None: self.logger.error('No pod for container %s found', containerid) self.audit_svc_access(log.AUDIT_SVC_AUTHZ_FAIL, request['client_id'], request['path']) return False metadata = pod[u'metadata'] self.logger.info( "Found pod %s for %s", metadata[u'selfLink'], containerid ) hostname = metadata[u'annotations'].get(self.hostname_annotation) if not hostname: self.logger.error("No hostname configured for %s", metadata[u'selfLink']) self.audit_svc_access(log.AUDIT_SVC_AUTHZ_FAIL, request['client_id'], request['path']) return False if hostname != request['path_chain'][-1]: self.logger.error( "Path %s does not match hostname '%s' for %s", request['path_chain'], hostname, containerid ) self.audit_svc_access(log.AUDIT_SVC_AUTHZ_FAIL, request['client_id'], request['path']) return False self.logger.debug( "Container %s matches %s: '%s'", metadata[u'selfLink'], self.hostname_annotation, hostname ) self.audit_svc_access(log.AUDIT_SVC_AUTHZ_PASS, request['client_id'], request['path']) return True
class IPAInterface(HTTPAuthenticator): """IPA interface authenticator Custodia uses a forking server model. We can bootstrap FreeIPA API in the main process. Connections must be created in the client process. """ # Kerberos flags krb5config = PluginOption(str, None, "Kerberos krb5.conf override") keytab = PluginOption(str, None, "Kerberos keytab for auth") ccache = PluginOption(str, None, "Kerberos ccache, e,g. FILE:/path/to/ccache") # ipalib.api arguments ipa_confdir = PluginOption(str, None, "IPA confdir override") ipa_context = PluginOption(str, "cli", "IPA bootstrap context") ipa_debug = PluginOption(bool, False, "debug mode for ipalib") # filled by gssapi() principal = False def __init__(self, config, section=None, api=None): super(IPAInterface, self).__init__(config, section) # only one instance of this plugin is supported if section != IPA_SECTIONNAME: raise ValueError(section) if api is None: self._api = ipalib.api else: self._api = api if self._api.isdone('bootstrap'): raise RuntimeError("IPA API already initialized") self._ipa_config = dict( context=self.ipa_context, debug=self.ipa_debug, log=None, # disable logging to file ) if self.ipa_confdir is not None: self._ipa_config['confdir'] = self.ipa_confdir @classmethod def from_config(cls, config): return config['authenticators']['ipa'] def finalize_init(self, config, cfgparser, context=None): super(IPAInterface, self).finalize_init(config, cfgparser, context) if self.principal: # already initialized return # get rundir from own section or DEFAULT rundir = cfgparser.get(self.section, 'rundir', fallback=None) if rundir: self._ipa_config['dot_ipa'] = rundir self._ipa_config['home'] = rundir # workaround https://pagure.io/freeipa/issue/6761#comment-440329 # monkey-patch ipalib.constants and all loaded ipa modules ipalib.constants.USER_CACHE_PATH = rundir for name, mod in six.iteritems(sys.modules): if (name.startswith(('ipalib.', 'ipaclient.')) and hasattr(mod, 'USER_CACHE_PATH')): mod.USER_CACHE_PATH = rundir self._gssapi_config() self._bootstrap() with self: self.logger.info("IPA server '%s': %s", self.env.server, self.Command.ping()[u'summary']) def handle(self, request): request[IPA_SECTIONNAME] = self return None # rest is interface and initialization def _gssapi_config(self): # set client keytab env var for authentication if self.keytab is not None: os.environ['KRB5_CLIENT_KTNAME'] = self.keytab if self.ccache is not None: os.environ['KRB5CCNAME'] = self.ccache if self.krb5config is not None: os.environ['KRB5_CONFIG'] = self.krb5config self.principal = self._gssapi_cred() self.logger.info(u"Kerberos principal '%s'", self.principal) def _gssapi_cred(self): try: return get_principal() except Exception: self.logger.exception( "Unable to get principal from GSSAPI. Are you missing a " "TGT or valid Kerberos keytab?") raise def _bootstrap(self): # TODO: bandaid for "A PKCS #11 module returned CKR_DEVICE_ERROR" # https://github.com/avocado-framework/avocado/issues/1112#issuecomment-206999400 os.environ['NSS_STRICT_NOFORK'] = 'DISABLED' self._api.bootstrap(**self._ipa_config) self._api.finalize() @property def Command(self): return self._api.Command # pylint: disable=no-member @property def env(self): return self._api.env # pylint: disable=no-member def __enter__(self): # pylint: disable=no-member self._gssapi_cred() if not self._api.Backend.rpcclient.isconnected(): self._api.Backend.rpcclient.connect() # pylint: enable=no-member return self def __exit__(self, exc_type, exc_val, exc_tb): # pylint: disable=no-member if self._api.Backend.rpcclient.isconnected(): self._api.Backend.rpcclient.disconnect()
class Forwarder(HTTPConsumer): forward_uri = PluginOption(str, REQUIRED, None) tls_cafile = PluginOption(str, INHERIT_GLOBAL(None), 'Path to CA file') tls_certfile = PluginOption(str, None, 'Path to cert file for client cert auth') tls_keyfile = PluginOption(str, None, 'Path to key file for client cert auth') forward_headers = PluginOption('json', '{}', None) prefix_remote_user = PluginOption(bool, True, None) def __init__(self, config, section): super(Forwarder, self).__init__(config, section) self.client = CustodiaHTTPClient(self.forward_uri) if self.tls_certfile is not None: self.client.set_client_cert(self.tls_certfile, self.tls_keyfile) if self.tls_cafile is not None: self.client.set_ca_cert(self.tls_cafile) self.uuid = str(uuid.uuid4()) # pylint: disable=unsubscriptable-object # pylint: disable=unsupported-assignment-operation self.forward_headers['X-LOOP-CUSTODIA'] = self.uuid def _path(self, request): trail = request.get('trail', []) if self.prefix_remote_user: prefix = [request.get('remote_user', 'guest').rstrip('/')] else: prefix = [] return '/'.join(prefix + trail) def _headers(self, request): headers = {} headers.update(self.forward_headers) loop = request['headers'].get('X-LOOP-CUSTODIA', None) if loop is not None: headers['X-LOOP-CUSTODIA'] += ',' + loop return headers def _response(self, reply, response): if reply.status_code < 200 or reply.status_code > 299: raise HTTPError(reply.status_code) response['code'] = reply.status_code if reply.content: response['output'] = reply.content def _request(self, cmd, request, response, path, **kwargs): if self.uuid in request['headers'].get('X-LOOP-CUSTODIA', ''): raise HTTPError(502, "Loop detected") reply = cmd(path, **kwargs) self._response(reply, response) def GET(self, request, response): self._request(self.client.get, request, response, self._path(request), params=request.get('query', None), headers=self._headers(request)) def PUT(self, request, response): self._request(self.client.put, request, response, self._path(request), data=request.get('body', None), params=request.get('query', None), headers=self._headers(request)) def DELETE(self, request, response): self._request(self.client.delete, request, response, self._path(request), params=request.get('query', None), headers=self._headers(request)) def POST(self, request, response): self._request(self.client.post, request, response, self._path(request), data=request.get('body', None), params=request.get('query', None), headers=self._headers(request))
class EtcdStore(CSStore): etcd_server = PluginOption(str, '127.0.0.1', None) etcd_port = PluginOption(int, '4001', None) namespace = PluginOption(str, '/custodia', None) def __init__(self, config, section): super(EtcdStore, self).__init__(config, section) # Initialize the DB by trying to create the default table try: self.etcd = Client(self.etcd_server, self.etcd_port) self.etcd.write(self.namespace, None, dir=True) except EtcdNotFile: # Already exists pass except EtcdException: self.logger.exception("Error creating namespace %s", self.namespace) raise CSStoreError('Error occurred while trying to init db') def _absolute_key(self, key): """Get absolute path to key and validate key""" if '//' in key: raise ValueError("Invalid empty components in key '%s'" % key) parts = key.split('/') if set(parts).intersection({'.', '..'}): raise ValueError("Invalid relative components in key '%s'" % key) return '/'.join([self.namespace] + parts).replace('//', '/') def get(self, key): self.logger.debug("Fetching key %s", key) try: result = self.etcd.get(self._absolute_key(key)) except EtcdException: self.logger.exception("Error fetching key %s", key) raise CSStoreError('Error occurred while trying to get key') self.logger.debug("Fetched key %s got result: %r", key, result) return result.value # pylint: disable=no-member def set(self, key, value, replace=False): self.logger.debug("Setting key %s to value %s (replace=%s)", key, value, replace) path = self._absolute_key(key) try: self.etcd.write(path, value, prevExist=replace) except EtcdAlreadyExist as err: raise CSStoreExists(str(err)) except EtcdException: self.logger.exception("Error storing key %s", key) raise CSStoreError('Error occurred while trying to store key') def span(self, key): path = self._absolute_key(key) self.logger.debug("Creating directory %s", path) try: self.etcd.write(path, None, dir=True, prevExist=False) except EtcdAlreadyExist as err: raise CSStoreExists(str(err)) except EtcdException: self.logger.exception("Error storing key %s", key) raise CSStoreError('Error occurred while trying to store key') def list(self, keyfilter='/'): path = self._absolute_key(keyfilter) if path != '/': path = path.rstrip('/') self.logger.debug("Listing keys matching %s", path) try: result = self.etcd.read(path, recursive=True) except EtcdKeyNotFound: return None except EtcdException: self.logger.exception("Error listing %s", keyfilter) raise CSStoreError('Error occurred while trying to list keys') self.logger.debug("Searched for %s got result: %r", path, result) value = set() for entry in result.get_subtree(): if entry.key == path: continue name = entry.key[len(path):] if entry.dir and not name.endswith('/'): name += '/' value.add(name.lstrip('/')) return sorted(value) def cut(self, key): self.logger.debug("Removing key %s", key) try: self.etcd.delete(self._absolute_key(key)) except EtcdKeyNotFound: self.logger.debug("Key %s not found", key) return False except EtcdException: self.logger.exception("Error removing key %s", key) raise CSStoreError('Error occurred while trying to cut key') self.logger.debug("Key %s removed", key) return True