def get_wrapped_key_as_jwe(priv_dek: bytearray, tenant: str, jwe_kid: str, nonce: str = '') -> str: """Creates a JWE.""" trace_enter(inspect.currentframe()) logger.info('Creating JWE token for request with kid "%s"...', jwe_kid) # Generate a 256 bit AES content encryption key (32 bytes * 8). try: cek = bytearray(get_random_bytes(32)) except Exception as exc: ret = '' logger.error('Failed to get random bytes: %s', exc) trace_exit(inspect.currentframe(), ret) return ret if config.get_config_by_keypath('DEV_MODE'): logger.debug('Generated cek (BYOK AES key): %s (hex)', cek.hex()) if not (b64_cek_ciphertext := _encrypt_cek_with_key_consumer_key( tenant, jwe_kid, cek)): logger.error( 'Cannot encrypt content encryption key with key consumer ' 'key of %s/%s.', tenant, jwe_kid) trace_exit(inspect.currentframe(), '') return ''
def _create_jwe_token_json(jwe_kid: str, b64_protected_header: bytes, b64_cek_ciphertext: bytes, b64_iv: bytes, b64_encrypted_dek: bytes, b64_tag: bytes) -> str: """ Creates JWE token according to: https://tools.ietf.org/html/rfc7516#section-3.3 Compact Serialization representation: BASE64URL(UTF8(JWE Protected Header)) || '.' || BASE64URL(JWE Encrypted Key) || '.' || BASE64URL(JWE Initialization Vector) || '.' || BASE64URL(JWE Ciphertext) || '.' || BASE64URL(JWE Authentication Tag) """ trace_enter(inspect.currentframe()) try: jwe = b64_protected_header + b'.' + b64_cek_ciphertext + b'.' + \ b64_iv + b'.' + b64_encrypted_dek + b'.' + b64_tag jwe_token = {'kid': jwe_kid, 'jwe': jwe.decode()} json_jwe_token = json.dumps(jwe_token) except Exception as exc: ret = '' logger.error('Failed to create JWE token: %s', exc) trace_exit(inspect.currentframe(), ret) return ret logger.debug('Created JWE token: %s', json_jwe_token) trace_exit(inspect.currentframe(), json_jwe_token) return json_jwe_token
def _get_key_consumer_cert(tenant: str, jwe_kid: str) -> str: """ Retrieves the key consumer certificate. This cert is used to wrap the cek (content encryption key) """ trace_enter(inspect.currentframe()) # try to fetch dedicated key consumer cert key_consumer_cert_path = config.get_key_consumer_cert(tenant, jwe_kid) if not key_consumer_cert_path: ret = '' logger.error( 'Cannot find dedicated key consumer certificate' 'nor backend-wide key consumer certificate for' '"%s/%s"', tenant, jwe_kid) trace_exit(inspect.currentframe(), ret) return ret try: with open(key_consumer_cert_path) as file: cert = file.read().strip() except Exception as exc: ret = '' logger.error('Cannot read key consumer certificate at ' '"%s": %s.', key_consumer_cert_path, exc) trace_exit(inspect.currentframe(), ret) return ret trace_exit(inspect.currentframe(), cert) return cert
def get_wrapped_key(view_args: Dict, query_args: Dict, header_args: Dict, **kwargs): """ tenant: Tenant (key consumer) that makes a request. E.g. Salesforce. Mandatory. jwe_kid: kid provided by key consumer. Mandatory. nonce: Nonce (?requestId=x) provided by key consumer (to prevent replay attacks). Mandatory. """ trace_enter(inspect.currentframe()) session['view_args'] = view_args session['query_args'] = query_args session['header_args'] = header_args if _is_replay_attack(query_args['requestId']): ret = '' err_msg = 'Replay attack detected for nonce: %s' % \ query_args['requestId'] app.logger.error(err_msg) ret = '{"status": "fail", "output": "%s"}' % err_msg trace_exit(inspect.currentframe(), ret) _http_error(500, ret) if not (token := _authenticate(view_args['tenant'], header_args['priv_jwt'])): if not (jwt_audience := config.get_jwt_audience_by_tenant( view_args['tenant'])): jwt_audience = 'unknown'
def _is_cfg_path_valid(path: str) -> bool: """Validates format of config path supplied via env var.""" trace_enter(inspect.currentframe()) if not isinstance(path, str): logger.error('Config path is not a string.') ret = False trace_exit(inspect.currentframe(), ret) return ret max_path_length = 150 if len(path) > max_path_length: logger.error('Config path is longer than %i chars.', max_path_length) ret = False trace_exit(inspect.currentframe(), ret) return ret parts = path.split('/') path_end = 'config.json' if parts[-1] != path_end: logger.error('Config path does not end with "%s"', path_end) ret = False trace_exit(inspect.currentframe(), ret) return ret ret = True trace_exit(inspect.currentframe(), ret) return ret
def __x_real_ip_validator(x_real_ip: str) -> None: """Validates the X-Real-IP header.""" trace_enter(inspect.currentframe()) if not 6 < len(x_real_ip) < 16: err_msg = 'X-Real-Ip must be between 7 and 15 characters long.' logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422) parts = x_real_ip.split('.') if len(parts) != 4: err_msg = ('X-Real-Ip format does not match: ' 'digits.digits.digits.digits.') logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422) for part in parts: if not 0 < len(part) < 4: err_msg = ('X-Real-Ip format does not match: ' 'x.x.x.x-xxx.xxx.xxx.xxx') logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422)
def __user_agent_validator(user_agent: str) -> None: """ Validates the user agent header. User agent specs: https://developer.mozilla.org/en-US/docs/Web/ HTTP/Headers/User-Agent Enforce a minimum pattern of "uname/version". """ trace_enter(inspect.currentframe()) if len(user_agent) > 150: err_msg = 'User agent contains more than 150 characters.' logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422) parts = user_agent.split('/') err_msg = 'User agent pattern does not match "name/version"' if len(parts) < 2: logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422) if (len(parts[0]) < 1) or (len(parts[1]) < 1): logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422)
def _get_jwt_from_header(priv_token: str) -> str: trace_enter(inspect.currentframe()) if not priv_token.startswith('Bearer'): ret = '' app.logger.error('Cannot fetch Bearer token from Authorization ' 'header.') app.logger.debug('Malformed token w/o Bearer: %s', priv_token) trace_exit(inspect.currentframe(), ret) return ret parts = priv_token.split('Bearer') if len(parts) != 2: ret = '' app.logger.error('Token format does not match "Bearer Token".') trace_exit(inspect.currentframe(), ret) return ret if len(parts[1].strip()) == 0: ret = '' app.logger.error('Found "Bearer" string in auth header, but no JWT.') trace_exit(inspect.currentframe(), ret) return ret ret = parts[1].strip() trace_exit(inspect.currentframe(), CAMOUFLAGE_SIGN) return ret
def get_jwt_issuer_by_tenant(tenant: str) -> str: """Returns JWT issuer claim.""" trace_enter(inspect.currentframe()) cfg_keypath = f'TENANT_CFG.{tenant}.auth.jwt_issuer' ret = get_config_by_keypath(cfg_keypath) trace_exit(inspect.currentframe(), ret) return ret
def get_vault_path_by_tenant_and_kid(tenant: str, jwe_kid: str) -> str: """Returns Vault path.""" trace_enter(inspect.currentframe()) cfg_keypath = f'TENANT_CFG.{tenant}.backend.{jwe_kid}.vault_path' ret = get_config_by_keypath(cfg_keypath) trace_exit(inspect.currentframe(), ret) return ret
def _http_20x(status_code: int, msg: str, headers: dict = None) -> Response: trace_enter(inspect.currentframe()) ret = Response(response=msg, status=status_code, content_type='application/json; charset=utf-8', headers=headers) trace_exit(inspect.currentframe(), ret) return ret
def get_jwt_validation_cert_by_tenant_and_kid(tenant: str, jwt_kid: str) -> str: """Returns JWT validation certificate.""" trace_enter(inspect.currentframe()) cfg_keypath = f'TENANT_CFG.{tenant}.auth.jwt_validation_certs.{jwt_kid}' ret = get_config_by_keypath(cfg_keypath) trace_exit(inspect.currentframe(), ret) return ret
def _get_dek_from_vault(priv_jwt_token: str, tenant: str, jwe_kid: str) -> bytearray: trace_enter(inspect.currentframe()) if not (vault_path := config.get_vault_path_by_tenant_and_kid( tenant, jwe_kid)): # kid not found in config, # assume kid and vault path are the same # and fetch latest version of secret vault_path = jwe_kid + ':latest'
def get_vault_ca_cert(tenant: str) -> str: trace_enter(inspect.currentframe()) precedence: List[str] = [] precedence.append(f'TENANT_CFG.{tenant}.backend.VAULT.cacert') precedence.append('VAULT.cacert') ret = get_config_by_keypath(precedence) trace_exit(inspect.currentframe(), ret) return ret
def _authenticate(tenant: str, priv_auth_header: str) -> str: """ Authentication requires a bearer token in JWT format. """ trace_enter(inspect.currentframe()) if not (token := _get_jwt_from_header(priv_auth_header)): ret = '' app.logger.error('Cannot get JWT from request.') app.logger.debug('Request header: %s', priv_auth_header) trace_exit(inspect.currentframe(), ret) return ret
def __authenticate_vault_client(client: hvac.Client, tenant: str, priv_jwt_token: str) -> hvac.Client: trace_enter(inspect.currentframe()) vault_auth_jwt_path = config.get_vault_auth_jwt_path(tenant) if not vault_auth_jwt_path: logger.error('Failed to load auth jwt path for tenant "%s"', tenant) return None if config.get_config_by_keypath('DEV_MODE'): logger.debug('Attempting to authenticate against Vault using JWT: %s', priv_jwt_token) cache_id = utils.get_vault_token_cache_id(tenant, priv_jwt_token) if cache_id in __VAULT_TOKEN_CACHE: logger.debug('Cache hit: Found token for "%s".', cache_id) client.token = __VAULT_TOKEN_CACHE[cache_id] else: logger.debug('Cache miss: Token for "%s" not found.', cache_id) token = __get_vault_token(client, tenant, priv_jwt_token, vault_auth_jwt_path) if not token: ret = None logger.error('Failed to get Vault token.') trace_exit(inspect.currentframe(), ret) return ret client.token = token __VAULT_TOKEN_CACHE[cache_id] = token if not client.is_authenticated(): # token might be invalid/has expired del __VAULT_TOKEN_CACHE[cache_id] ret = None logger.error('Failed to validate Vault client. ' 'Review configuration (config/config.json). ' 'Retry as token might have expired.') trace_exit(inspect.currentframe(), ret) return ret logger.debug('Successfully authenticated Vault client ' 'for tenant "%s"', tenant) trace_exit(inspect.currentframe(), client) return client
def _encrypt_dek_with_cek(priv_cek: bytearray, initialization_vector: bytes, priv_dek: bytearray, ascii_b64_protected_header: bytes) \ -> Tuple[bytes, bytes]: """ Wrap dek with cek: - Perform authenticated encryption on dek with the AES GCM algorithm. - Use cek as encryption key, the initialization vector, and the protected header as Additional Authenticated Data value. - Request a 128-bit Authentication Tag output. """ trace_enter(inspect.currentframe()) try: # mac_len=16 bytes: 128 bit authentication tag dek_cipher = AES.new(priv_cek, AES.MODE_GCM, nonce=initialization_vector, mac_len=16) # add additional authenticated data (aad) dek_cipher.update(ascii_b64_protected_header) # TODO: Autom. padding helpful? Might replace pycryptodome anyway. # from Cryptodome.Util.Padding import pad # encrypted_dek, tag = \ # dek_cipher.encrypt_and_digest(pad(dek, AES.block_size)) encrypted_dek, tag = dek_cipher.encrypt_and_digest(priv_dek) # Remove sensitive data from memory del priv_dek[:] del priv_cek[:] b64_encrypted_dek = base64.urlsafe_b64encode(encrypted_dek) b64_tag = base64.urlsafe_b64encode(tag) except Exception as exc: ret = (b'', b'') logger.error('Failed to encrypt dek: %s', exc) trace_exit(inspect.currentframe(), ret) return ret if config.get_config_by_keypath('DEV_MODE'): logger.debug('Additional authenticated data (aad): ' '%s', ascii_b64_protected_header.decode()) logger.debug('Encrypted dek: "%s" (hex), ' 'tag :"%s" (hex).', encrypted_dek.hex(), tag.hex()) trace_exit(inspect.currentframe(), (b64_encrypted_dek, b64_tag)) return b64_encrypted_dek, b64_tag
def _encrypt_cek_with_key_consumer_key(tenant: str, jwe_kid: str, priv_cek: bytearray) -> bytes: trace_enter(inspect.currentframe()) # Encrypt cek with public key from key consumer using RSAES-OAEP # (BASE64URL(JWE Encrypted CEK Key)) if not (key_consumer_cert := _get_key_consumer_cert(tenant, jwe_kid)): ret = b'' logger.error( 'Cannot get key consumer certificate for tenant ' '"%s" with JWE kid "%s".', tenant, jwe_kid) trace_exit(inspect.currentframe(), ret) return ret
def _decode_jwt(tenant: str, priv_jwt_token: str, cert: str) \ -> Tuple[str, str]: """ The jwt_token must... - have a valid signature, - contain 'iss' & 'sub' claims - not have expired (leeway of 10s for clock skew is tolerated) """ trace_enter(inspect.currentframe()) if not (aud := config.get_jwt_audience_by_tenant(tenant)): app.logger.error( 'Cannot get JWT audience for tenant "%s" from ' 'config.', tenant) trace_exit(inspect.currentframe(), ('', '')) _http_error(500, '{"error": "internal error"}')
def __get_vault_token(client: hvac.Client, tenant: str, priv_jwt_token: str, vault_auth_jwt_path: str) -> str: trace_enter(inspect.currentframe()) default_role = config.get_vault_default_role(tenant) if not default_role: logger.error('Failed to load Vault default role for tenant "%s"', tenant) return '' try: response = client.auth.jwt.jwt_login(role=default_role, jwt=priv_jwt_token, path=vault_auth_jwt_path) except Exception as exc: ret = '' logger.error('Failed to authenticate against Vault: %s', exc) trace_exit(inspect.currentframe(), ret) return ret if config.get_config_by_keypath('DEV_MODE'): logger.debug('Vault login response: %s', response) try: vault_token = response['auth']['client_token'] except KeyError as exc: ret = '' logger.error( 'Failed to access the Vault token from auth response: ' 'KeyError on key %s. ' 'This is most likely a permission issue.', exc) trace_exit(inspect.currentframe(), ret) return ret if config.get_config_by_keypath('DEV_MODE'): logger.debug('Vault client token returned: %s', vault_token) logger.debug('Retrieved new Vault token.') trace_exit(inspect.currentframe(), CAMOUFLAGE_SIGN) return vault_token
def get_vault_token_cache_id(tenant: str, priv_token) -> str: """ Returns cache id to reference a Vault token in distributey's IN-MEM cache. Cache id is base64 encoded concatenation of `tenant + jwt kid`. """ trace_enter(inspect.currentframe()) jwt_kid = get_kid_from_jwt(priv_token) if not jwt_kid: ret = '' trace_exit(inspect.currentframe(), ret) return ret cache_id = f'{tenant}-{jwt_kid}' ret = base64.b64encode(cache_id.encode()).decode() trace_exit(inspect.currentframe(), ret) return ret
def get_kid_from_jwt(priv_token: str) -> str: """ Extracts KID from JWT token. Only use this method after JWT token has been validated. """ trace_enter(inspect.currentframe()) try: protected_header_unverified = jwt.get_unverified_header(priv_token) except jwt.DecodeError as exc: ret = '' logger.error('Cannot decode JWT to get kid: %s', exc) logger.debug('JWT: %s', priv_token) trace_exit(inspect.currentframe(), ret) return ret ret = protected_header_unverified.get('kid', '') trace_exit(inspect.currentframe(), ret) return ret
def get_healthz(): """ This healthz implementation adheres to: https://tools.ietf.org/html/draft-inadarei-api-health-check-04 TODO: add user-agent & x-real-ip to input validation & flask.session in order to log request properly. """ trace_enter(inspect.currentframe()) if not config.get_config_by_keypath('LOG_LEVEL'): ret = '{"status": "fail", "output": "Config not loaded"}' trace_exit(inspect.currentframe(), ret) _http_error(500, ret) ret = _http_20x(200, '{"status": "pass"}') trace_exit(inspect.currentframe(), ret) return ret
def get_key_consumer_cert(tenant: str, jwe_kid: str) -> str: """ First, attempt fetching key consumer cert specific to a service of a tenant. If no specific cert exists, attempt fetching key consumer cert specific to tenant(backend-wide cert). """ trace_enter(inspect.currentframe()) cfg_keypath = f'TENANT_CFG.{tenant}.backend.{jwe_kid}.key_consumer_cert' ret = get_config_by_keypath(cfg_keypath) if ret: trace_exit(inspect.currentframe(), ret) return ret cfg_keypath = f'TENANT_CFG.{tenant}.backend.backend_wide_key_consumer_cert' ret = get_config_by_keypath(cfg_keypath) trace_exit(inspect.currentframe(), ret) return ret
def _get_jwe_protected_header(jwe_kid: str, nonce: str) -> bytes: """Creates JWE protected header (BASE64URL(UTF8(JWE Protected Header))).""" trace_enter(inspect.currentframe()) protected_header = {'alg': 'RSA-OAEP', 'enc': 'A256GCM', 'kid': jwe_kid} if nonce: protected_header['jti'] = nonce try: b64_protected_header = base64.urlsafe_b64encode( json.dumps(protected_header).encode('utf-8')) except Exception as exc: ret = b'' logger.error('Failed to create protected header: %s', exc) trace_exit(inspect.currentframe(), ret) return ret trace_exit(inspect.currentframe(), b64_protected_header) return b64_protected_header
def __handle_request_parsing_error( validation_error: typeValidationError, request: werkzeug.local.LocalProxy, schema: SchemaABC, error_status_code: int = None, error_headers: Mapping[str, str] = None) -> None: """Handles errors, or raised exceptions respectively.""" trace_enter(inspect.currentframe()) input_data = validation_error.__dict__['data'] logger.error('Input validation failed with error "%s". ' 'Input: "%s".', validation_error, input_data) resp = Response(response=json.dumps(validation_error.__dict__['messages']), status=422, content_type='application/json; charset=utf-8') trace_exit(inspect.currentframe(), resp) abort(resp)
def __request_id_validator(request_id: str) -> None: # Replay attack detection specs of Salesforce's cache-only service: # https://developer.salesforce.com/docs/atlas.en-us.securityImplGuide.meta/ # securityImplGuide/security_pe_byok_cache_replay.htm trace_enter(inspect.currentframe()) request_id_length = 32 if len(request_id) != request_id_length: err_msg = ('requestId/nonce length must be %s alphanummeric ' 'chars.' % request_id_length) logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422) result = re.match('^[a-zA-Z0-9]+$', request_id) if not result: err_msg = ('requestId/nonce must consist of alphanummeric chars only.') logger.error(err_msg) trace_exit(inspect.currentframe(), err_msg) raise ValidationError(err_msg, status_code=422)
def _dev_mode_warning_banner() -> None: trace_enter(inspect.currentframe()) dev_mode = config.get_config_by_keypath('DEV_MODE') log_level = config.get_config_by_keypath('LOG_LEVEL') banner = r""" _____ ________ __ __ __ ____ _____ ______ | __ \| ____\ \ / / | \/ |/ __ \| __ \| ____| | | | | |__ \ \ / / | \ / | | | | | | | |__ | | | | __| \ \/ / | |\/| | | | | | | | __| | |__| | |____ \ / | | | | |__| | |__| | |____ |_____/|______| \/ |_| |_|\____/|_____/|______| Sensitive data, such as data encryption keys are logged in plain-text. """ if dev_mode and log_level == 'debug': app.logger.info(banner) trace_exit(inspect.currentframe(), None)
def get_config_by_keypath(keypath: Union[str, list]) -> Any: """Returns config by key path.""" trace_enter(inspect.currentframe()) try: with open(__CFG_PATH, 'r') as file: cfg = json.load(file) except FileNotFoundError as exc: ret = False logger.error( 'Config not found. Has "01-fix-cfg-perms.sh" ' 'been executed? %s', exc) trace_exit(inspect.currentframe(), ret) return ret except Exception as exc: ret = False logger.error('Failed to load config: %s', exc) trace_exit(inspect.currentframe(), ret) return ret # normalize to list if isinstance(keypath, str): keypath = [keypath] for kp in keypath: try: cfg_value = glom.glom(cfg, kp) trace_exit(inspect.currentframe(), cfg_value) return cfg_value except glom.core.PathAccessError: continue # no cfg found cfg_value = False logger.error('Failed to load config at: %s', keypath) trace_exit(inspect.currentframe(), cfg_value) return cfg_value
def _initialize_cache_db() -> bool: """Init temporary database to store nonces to prevent replay attacks.""" trace_enter(inspect.currentframe()) cache_db = Path(__CACHE_DB) if cache_db.is_file(): ret = True trace_exit(inspect.currentframe(), ret) return ret try: # create empty file with open(__CACHE_DB, 'a') as file: file.write('') except Exception as exc: ret = False app.logger.error('Failed to create cache db: %s', exc) trace_exit(inspect.currentframe(), ret) return ret ret = True trace_exit(inspect.currentframe(), ret) return ret