diff --git a/app.py b/app.py index 88272056d..d01a7ada9 100644 --- a/app.py +++ b/app.py @@ -32,6 +32,7 @@ from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuth DexOAuthConfig) from util.security.signing import Signer +from util.security.instancekeys import InstanceKeys from util.saas.cloudwatch import start_cloudwatch_sender from util.saas.metricqueue import MetricQueue from util.config.provider import get_config_provider @@ -178,6 +179,8 @@ authentication = UserAuthentication(app, config_provider, OVERRIDE_CONFIG_DIRECT userevents = UserEventsBuilderModule(app) superusers = SuperUserManager(app) signer = Signer(app, config_provider) +instance_keys = InstanceKeys(app) + start_cloudwatch_sender(metric_queue, app) tf = app.config['DB_TRANSACTION_FACTORY'] diff --git a/auth/registry_jwt_auth.py b/auth/registry_jwt_auth.py index 79f240187..a4c48d749 100644 --- a/auth/registry_jwt_auth.py +++ b/auth/registry_jwt_auth.py @@ -1,29 +1,23 @@ import logging -import re from functools import wraps from jsonschema import validate, ValidationError from flask import request, url_for from flask.ext.principal import identity_changed, Identity -from cryptography.x509 import load_pem_x509_certificate -from cryptography.hazmat.backends import default_backend -from cachetools import lru_cache -from app import app, get_app_url +from app import app, get_app_url, instance_keys from .auth_context import set_grant_context, get_grant_context from .permissions import repository_read_grant, repository_write_grant from util.names import parse_namespace_repository from util.http import abort -from util.security import strictjwt -from util.security.registry_jwt import ANONYMOUS_SUB +from util.security.registry_jwt import (ANONYMOUS_SUB, decode_bearer_token, + InvalidBearerTokenException) from data import model logger = logging.getLogger(__name__) - -TOKEN_REGEX = re.compile(r'^Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)$') CONTEXT_KINDS = ['user', 'token', 'oauth'] ACCESS_SCHEMA = { @@ -142,34 +136,18 @@ def get_auth_headers(repository=None, scopes=None): return headers -def identity_from_bearer_token(bearer_token, max_signed_s, public_key): +def identity_from_bearer_token(bearer_token): """ Process a bearer token and return the loaded identity, or raise InvalidJWTException if an identity could not be loaded. Expects tokens and grants in the format of the Docker registry v2 auth spec: https://docs.docker.com/registry/spec/auth/token/ """ logger.debug('Validating auth header: %s', bearer_token) - # Extract the jwt token from the header - match = TOKEN_REGEX.match(bearer_token) - if match is None: - raise InvalidJWTException('Invalid bearer token format') - - encoded = match.group(1) - logger.debug('encoded JWT: %s', encoded) - - # Load the JWT returned. try: - expected_issuer = app.config['JWT_AUTH_TOKEN_ISSUER'] - audience = app.config['SERVER_HOSTNAME'] - max_exp = strictjwt.exp_max_s_option(max_signed_s) - payload = strictjwt.decode(encoded, public_key, algorithms=['RS256'], audience=audience, - issuer=expected_issuer, options=max_exp) - except strictjwt.InvalidTokenError: - logger.exception('Invalid token reason') - raise InvalidJWTException('Invalid token') - - if not 'sub' in payload: - raise InvalidJWTException('Missing sub field in JWT') + payload = decode_bearer_token(bearer_token, instance_keys) + except InvalidBearerTokenException as bte: + logger.exception('Invalid bearer token: %s', bte) + raise InvalidJWTException(bte) loaded_identity = Identity(payload['sub'], 'signed_jwt') @@ -203,13 +181,6 @@ def identity_from_bearer_token(bearer_token, max_signed_s, public_key): return loaded_identity, payload.get('context', default_context) -@lru_cache(maxsize=1) -def load_public_key(certificate_file_path): - with open(certificate_file_path) as cert_file: - cert_obj = load_pem_x509_certificate(cert_file.read(), default_backend()) - return cert_obj.public_key() - - def process_registry_jwt_auth(scopes=None): def inner(func): @wraps(func) @@ -217,14 +188,8 @@ def process_registry_jwt_auth(scopes=None): logger.debug('Called with params: %s, %s', args, kwargs) auth = request.headers.get('authorization', '').strip() if auth: - max_signature_seconds = app.config.get('JWT_AUTH_MAX_FRESH_S', 3660) - certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH'] - public_key = load_public_key(certificate_file_path) - try: - extracted_identity, context = identity_from_bearer_token(auth, max_signature_seconds, - public_key) - + extracted_identity, context = identity_from_bearer_token(auth) identity_changed.send(app, identity=extracted_identity) set_grant_context(context) logger.debug('Identity changed to %s', extracted_identity.id) diff --git a/boot.py b/boot.py index a1990d253..4fd826425 100644 --- a/boot.py +++ b/boot.py @@ -47,15 +47,16 @@ def setup_jwt_proxy(): return # Generate the key for this Quay instance to use. - minutes_until_expiration = app.config.get('QUAY_SERVICE_KEY_EXPIRATION', 120) + minutes_until_expiration = app.config.get('INSTANCE_SERVICE_KEY_EXPIRATION', 120) expiration = datetime.now() + timedelta(minutes=minutes_until_expiration) - quay_key, quay_key_id = generate_key('quay', get_audience(), expiration_date=expiration) + quay_key, quay_key_id = generate_key(app.config['INSTANCE_SERVICE_KEY_SERVICE'], + get_audience(), expiration_date=expiration) - with open('conf/quay.kid', mode='w') as f: + with open(app.config['INSTANCE_SERVICE_KEY_KID_LOCATION'], mode='w') as f: f.truncate(0) f.write(quay_key_id) - with open('conf/quay.pem', mode='w') as f: + with open(app.config['INSTANCE_SERVICE_KEY_LOCATION'], mode='w') as f: f.truncate(0) f.write(quay_key.exportKey()) diff --git a/config.py b/config.py index f4b59f208..1c6cb554c 100644 --- a/config.py +++ b/config.py @@ -260,10 +260,7 @@ class DefaultConfig(object): SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull # Registry v2 JWT Auth config - JWT_AUTH_MAX_FRESH_S = 60 * 60 + 60 # At most signed for one hour, accounting for clock skew - JWT_AUTH_TOKEN_ISSUER = 'quay-test-issuer' - JWT_AUTH_CERTIFICATE_PATH = None - JWT_AUTH_PRIVATE_KEY_PATH = None + REGISTRY_JWT_AUTH_MAX_FRESH_S = 60 * 60 + 60 # At most signed one hour, accounting for clock skew # The URL endpoint to which we redirect OAuth when generating a token locally. LOCAL_OAUTH_HANDLER = '/oauth/localapp' @@ -340,14 +337,23 @@ class DefaultConfig(object): # lowest user in the database will be used. SERVICE_LOG_ACCOUNT_ID = None - # The location of the private key generated for this instance + # The service key ID for the instance service. + # NOTE: If changed, jwtproxy_conf.yaml.jnj must also be updated. + INSTANCE_SERVICE_KEY_SERVICE = 'quay' + + # The location of the key ID file generated for this instance. + INSTANCE_SERVICE_KEY_KID_LOCATION = 'conf/quay.kid' + + # The location of the private key generated for this instance. + # NOTE: If changed, jwtproxy_conf.yaml.jnj must also be updated. INSTANCE_SERVICE_KEY_LOCATION = 'conf/quay.pem' - # This instance's service key expiration in minutes + # This instance's service key expiration in minutes. INSTANCE_SERVICE_KEY_EXPIRATION = 120 - # Number of minutes between expiration refresh in minutes - INSTANCE_SERVICE_KEY_REFRESH = 60 + # Number of minutes between expiration refresh in minutes. Should be the expiration / 2 minus + # some additional window time. + INSTANCE_SERVICE_KEY_REFRESH = 55 # The whitelist of client IDs for OAuth applications that allow for direct login. DIRECT_OAUTH_CLIENTID_WHITELIST = [] diff --git a/data/users/externaljwt.py b/data/users/externaljwt.py index 55008aa9d..59740efd5 100644 --- a/data/users/externaljwt.py +++ b/data/users/externaljwt.py @@ -3,7 +3,7 @@ import json import os from data.users.federated import FederatedUsers, VerifiedCredentials -from util.security import strictjwt +from util.security import jwtutil logger = logging.getLogger(__name__) @@ -45,12 +45,12 @@ class ExternalJWTAuthN(FederatedUsers): # Load the JWT returned. encoded = result_data.get('token', '') - exp_limit_options = strictjwt.exp_max_s_option(self.max_fresh_s) + exp_limit_options = jwtutil.exp_max_s_option(self.max_fresh_s) try: - payload = strictjwt.decode(encoded, self.public_key, algorithms=['RS256'], - audience='quay.io/jwtauthn', issuer=self.issuer, - options=exp_limit_options) - except strictjwt.InvalidTokenError: + payload = jwtutil.decode(encoded, self.public_key, algorithms=['RS256'], + audience='quay.io/jwtauthn', issuer=self.issuer, + options=exp_limit_options) + except jwtutil.InvalidTokenError: logger.exception('Exception when decoding returned JWT') return (None, 'Invalid username or password') diff --git a/endpoints/key_server.py b/endpoints/key_server.py index 37b29e167..70c0da0eb 100644 --- a/endpoints/key_server.py +++ b/endpoints/key_server.py @@ -1,12 +1,7 @@ import logging - from datetime import datetime, timedelta -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers -from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers from flask import Blueprint, jsonify, abort, request, make_response -from jwkest.jwk import keyrep, RSAKey, ECKey from jwt import get_unverified_header import data.model @@ -14,8 +9,7 @@ import data.model.service_keys from data.model.log import log_action from app import app -from auth.registry_jwt_auth import TOKEN_REGEX -from util.security import strictjwt +from util.security import jwtutil logger = logging.getLogger(__name__) @@ -39,23 +33,13 @@ def _validate_jwk(jwk): abort(400) -def _jwk_dict_to_public_key(jwk): - jwkest_key = keyrep(jwk) - if isinstance(jwkest_key, RSAKey): - pycrypto_key = jwkest_key.key - return RSAPublicNumbers(e=pycrypto_key.e, n=pycrypto_key.n).public_key(default_backend()) - elif isinstance(jwkest_key, ECKey): - x, y = jwkest_key.get_key() - return EllipticCurvePublicNumbers(x, y, jwkest_key.curve).public_key(default_backend()) - - def _validate_jwt(encoded_jwt, jwk, service): - public_key = _jwk_dict_to_public_key(jwk) + public_key = jwtutil.jwk_dict_to_public_key(jwk) try: - strictjwt.decode(encoded_jwt, public_key, algorithms=['RS256'], + jwtutil.decode(encoded_jwt, public_key, algorithms=['RS256'], audience=JWT_AUDIENCE, issuer=service) - except strictjwt.InvalidTokenError: + except jwtutil.InvalidTokenError: logger.exception('JWT validation failure') abort(400) @@ -122,7 +106,7 @@ def put_service_key(service, kid): abort(400) jwt_header = request.headers.get(JWT_HEADER_NAME, '') - match = TOKEN_REGEX.match(jwt_header) + match = jwtutil.TOKEN_REGEX.match(jwt_header) if match is None: logger.error('Could not find matching bearer token') abort(400) @@ -180,7 +164,7 @@ def put_service_key(service, kid): @key_server.route('/services//keys/', methods=['DELETE']) def delete_service_key(service, kid): jwt_header = request.headers.get(JWT_HEADER_NAME, '') - match = TOKEN_REGEX.match(jwt_header) + match = jwtutil.TOKEN_REGEX.match(jwt_header) if match is None: abort(400) diff --git a/endpoints/oauthlogin.py b/endpoints/oauthlogin.py index 085134743..21f13bbd9 100644 --- a/endpoints/oauthlogin.py +++ b/endpoints/oauthlogin.py @@ -12,7 +12,7 @@ from auth.auth import require_session_login from data import model from endpoints.common import common_login, route_show_if from endpoints.web import render_page_template_with_routedata -from util.security.strictjwt import decode, InvalidTokenError +from util.security.jwtutil import decode, InvalidTokenError from util.validation import generate_valid_usernames diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index 7f857b70c..abae60b65 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -3,7 +3,7 @@ import re from flask import request, jsonify, abort -from app import app, userevents +from app import app, userevents, instance_keys from data import model from auth.auth import process_auth from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token @@ -14,7 +14,7 @@ from endpoints.v2 import v2_bp from endpoints.decorators import anon_protect from util.cache import no_cache from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX -from util.security.registry_jwt import generate_jwt_object, build_context_and_subject +from util.security.registry_jwt import generate_bearer_token, build_context_and_subject logger = logging.getLogger(__name__) @@ -152,8 +152,6 @@ def generate_registry_jwt(): # Build the signed JWT. context, subject = build_context_and_subject(user, token, oauthtoken) - - jwt_obj = generate_jwt_object(audience_param, subject, context, access, TOKEN_VALIDITY_LIFETIME_S, - app.config) - - return jsonify({'token': jwt_obj}) + token = generate_bearer_token(audience_param, subject, context, access, + TOKEN_VALIDITY_LIFETIME_S, instance_keys) + return jsonify({'token': token}) diff --git a/endpoints/web.py b/endpoints/web.py index 15510c00d..ebe2bdc51 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -53,7 +53,6 @@ logging.captureWarnings(True) web = Blueprint('web', __name__) STATUS_TAGS = app.config['STATUS_TAGS'] -JWT_ISSUER = app.config.get('JWT_AUTH_TOKEN_ISSUER') @web.route('/', methods=['GET'], defaults={'path': ''}) diff --git a/initdb.py b/initdb.py index 871ef22e5..e4d042490 100644 --- a/initdb.py +++ b/initdb.py @@ -673,6 +673,20 @@ def populate_database(minimal=False, with_storage=False): __generate_service_key('kid7', 'somewayexpiredkey', new_user_1, week_ago, ServiceKeyApprovalType.SUPERUSER, today - timedelta(days=30)) + # Add the test pull key as pre-approved for local and unittest registry testing. + # Note: this must match the private key found in the local/test config. + _TEST_JWK = { + 'e': 'AQAB', + 'kty': 'RSA', + 'n': 'yqdQgnelhAPMSeyH0kr3UGePK9oFOmNfwD0Ymnh7YYXr21VHWwyM2eVW3cnLd9KXywDFtGSe9oFDbnOuMCdUowdkBcaHju-isbv5KEbNSoy_T2Rip-6L0cY63YzcMJzv1nEYztYXS8wz76pSK81BKBCLapqOCmcPeCvV9yaoFZYvZEsXCl5jjXN3iujSzSF5Z6PpNFlJWTErMT2Z4QfbDKX2Nw6vJN6JnGpTNHZvgvcyNX8vkSgVpQ8DFnFkBEx54PvRV5KpHAq6AsJxKONMo11idQS2PfCNpa2hvz9O6UZe-eIX8jPo5NW8TuGZJumbdPT_nxTDLfCqfiZboeI0Pw' + } + + key = model.service_keys.create_service_key('test_service_key', 'test_service_key', 'quay', + _TEST_JWK, {}, None) + + model.service_keys.approve_service_key(key.kid, new_user_1, ServiceKeyApprovalType.SUPERUSER, + notes='Test service key for local/test registry testing') + model.log.log_action('org_create_team', org.username, performer=new_user_1, timestamp=week_ago, metadata={'team': 'readers'}) diff --git a/test/data/registry_v2_auth.crt b/test/data/registry_v2_auth.crt deleted file mode 100644 index 88c783efa..000000000 --- a/test/data/registry_v2_auth.crt +++ /dev/null @@ -1,20 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDVDCCAjwCCQDNYtlT1+tGbzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQGEwJV -UzERMA8GA1UECBMITmV3IFlvcmsxETAPBgNVBAcTCE5ldyBZb3JrMRQwEgYDVQQK -EwtDb3JlT1MsIEluYzENMAsGA1UECxMEUXVheTESMBAGA1UEAxMJMTI3LjAuMC4x -MB4XDTE2MDUyMzE1MjUxOVoXDTI2MDUyMTE1MjUxOVowbDELMAkGA1UEBhMCVVMx -ETAPBgNVBAgTCE5ldyBZb3JrMREwDwYDVQQHEwhOZXcgWW9yazEUMBIGA1UEChML -Q29yZU9TLCBJbmMxDTALBgNVBAsTBFF1YXkxEjAQBgNVBAMTCTEyNy4wLjAuMTCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKRvOt/XGNIovlr1BWxl2oqs -KDlgnESj6bFENDjs9+YLrB3mSWX6w4Dk2IdNU0EKHeVnnsAuBs83jaFsIVJxrC99 -ndv0PaejBovUbWyYN3zCMur8iNGse/FT4WRqks2m0Wr0jmEAX5piX/eWo/7OQdea -wNAGyH7wE0voMpyVSZMBmxRw07zWnwWBihvhOiiCnXZh32GQMplq0wxk4DkBf3hC -SEaAqsFHKfEFPxVXfdPGeiKKK+P2SAh+uN4miJpGf7Xkuj/Mmzxr1ajNczhPT6OM -pw0R3h/mok1S8zcp8lN/eDdKwjMeP4Rx+Lc0cRluZNa8otq9qYPNSCIkvsSz5b8C -AwEAATANBgkqhkiG9w0BAQUFAAOCAQEAZaaD8fLWEh4RGZ7X38IM/ocwDKaXWpDp -0EC3KMEuar1MET3MtVIXy/k/BLr0HmLRQ2KSV3wFfyOInseVeCvIcKZZo/JF28gR -LJVBcjExSIr6X8RoPgmKt7AdjlUjPV5XpRzDpfYcMaqpjJa75x6RoxC2ybh5Apyk -EzL3Naysk6TVPi5ckUYMLfw3JEbCeaEY4KNwVgsNcs447EcBxwGHTBqGOYtpIfku -SMas81oniMo9LMKv19Bn1oOforaqh8P2c57yregDsCDmP6j0gqkYjhJFCj5JNAKK -KT35QIfTbVFeCXAoLw0+o9Ma1Q+j7LfwdxnikUHNVZmlmjQmTBMwqg== ------END CERTIFICATE----- diff --git a/test/data/registry_v2_auth_private.key b/test/data/registry_v2_auth_private.key deleted file mode 100644 index 739c5fa0c..000000000 --- a/test/data/registry_v2_auth_private.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEApG8639cY0ii+WvUFbGXaiqwoOWCcRKPpsUQ0OOz35gusHeZJ -ZfrDgOTYh01TQQod5WeewC4GzzeNoWwhUnGsL32d2/Q9p6MGi9RtbJg3fMIy6vyI -0ax78VPhZGqSzabRavSOYQBfmmJf95aj/s5B15rA0AbIfvATS+gynJVJkwGbFHDT -vNafBYGKG+E6KIKddmHfYZAymWrTDGTgOQF/eEJIRoCqwUcp8QU/FVd908Z6Ioor -4/ZICH643iaImkZ/teS6P8ybPGvVqM1zOE9Po4ynDRHeH+aiTVLzNynyU394N0rC -Mx4/hHH4tzRxGW5k1ryi2r2pg81IIiS+xLPlvwIDAQABAoIBAANdV0oPP63FMafw -zYybRO6DeUs7Q9dPt09uQtdLWgM2B+6QsL3KdMelZxzVozd4eoYgKaprBq6kx1wf -N0tVkh1ip6FBjSVp+49O6HJJZxFBdANE6ZPIwLx+Z+VDHP/iQvS6TlODy3EARFBv -n6luFQDRZNKc4OtgBDUQakCz+U5tuJLqoR8wk/WGQP4FJiZlVwJqNPXMA1A2Mrri -n6WkhfpB30Z5dl9zsR+zJRbwRBjgJCYN37YC7zdHRfIhBPBvDT+8ApR50BGvPGN3 -sLQuH2FsskbgPsIrWMfCxtWr2xbw028GOe7TSjEG63EG7oGAT0O2eQmAcuPc4Dqj -Urn8saECgYEA2LkCe6MysmOtattC/gi3B/rIoOCd+4l9yTnW7S7nk/hdeOzxyqX1 -P7OgVeoYLLk3UJy3qTrNDnc0eGTJz0XyPhLlX0f9lduiSMH92XpNsBG7ngnyMCQF -eAZz8ZlDZC39I8y9CzdcHSLxuHKmQ9jhgUm+EIuf8OlrkjchPdE06i8CgYEAwjxG -cDA5X1hKYgQTObq245vR3txkvETmLVB7hWkjWLzR//a4hXHJT1fg2LxD5EMtCKZ2 -WXKhcy3tbja+c/IEI1L1wA2v/aWlEvi9n354EQ1QzkvCBDFP5enLnItAUzJQ0IgE -dtSUskK+li8aY2LB0EPt0eJmYU0cZUJXbl/ZKXECgYAAtttjPO512A5CQ+a8n5q6 -1ADFRvg+U/2uJBqpPXZV7oOgWmeRm2prg1QL9HGP9CxSf7G7RQ5X9dyeaPahUEG0 -IqvO3JXhYI/wXXNQvC51XhmYM8AwmG3ML3lCWpb2RZCIBay51Lzg+7SAPyB9KMHV -g0C1HUCxspNAMB5T7dSW0QKBgGkxRaCarWeypE4jENpyAXyRNf8xcyj3U4V1EgB1 -qVv0nvK2BsbWkgTzfeVDSK2FqA0IQg49Y6zCUdUfttOKXa1Xz5ocj5SaMiVtKx0G -3DW39WxUYRXuMuw8SzZTwBmOpW/aSjik9ob4WMlzZyIuKPMG5vSFXZcSsO8yF7HC -HRUxAoGBAKtCRLT9I5Ap37gWT8W6AAZygoUqhlYO9qygQrBDaJsHj0ZSHM0TO3ig -Bwq/UxDHBKFV3hmqx5Zmpoa9ZrURb4cBw/+TLq2ppXPLEU+XmEVmqL2323Vyr/Ih -CAIVWFsY3EGQL7TArOfag+v0Nxq3pypOhjweqIWEMDg+gV2+GHhQ ------END RSA PRIVATE KEY----- diff --git a/test/data/test.db b/test/data/test.db index d9741741c..9fa42fdf8 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/test/data/test.kid b/test/data/test.kid new file mode 100644 index 000000000..8d42f166f --- /dev/null +++ b/test/data/test.kid @@ -0,0 +1 @@ +test_service_key \ No newline at end of file diff --git a/test/data/test.pem b/test/data/test.pem index 5dab4c8ba..0b7dfd346 100644 --- a/test/data/test.pem +++ b/test/data/test.pem @@ -1,27 +1,27 @@ -----BEGIN RSA PRIVATE KEY----- -MIIEogIBAAKCAQEAvzy/peQEnNvIebJ+3QGtry6e8wPOlDBfQ5V5Yh9r07igUE5U -GtUM8Yo/2Re4dtOoFwesPPFvwwu65Qp7L1XZda3KlOncCYmQ0/AHEKlyA07BBbJV -YvJ9yg+m/94W0fzUgFTZJ18UnjUip7LT4MPkGANrMpZeI1hGVihOHIHjGR/kpMdd -W862gCRwdFm4Hw+Wbt0UwXzhpr31CGY/Mzf6mwhzEk6Q0QjQC7R4zvj9Oi6Q1r1C -ipPpdZLYDF+joSxA9ZKHiLCDH42kQu88WDNcdXSvg5bs6+4L0SvWwUpGLHPDOG5J -ENM3cujv3/Pbx5QIQnWNOisMiLQsudWtf5V1sQIDAQABAoIBAA4QlbfJsV0n/PKW -YiY2/WMo9p/A4+yaMidyUt8YmIGVzpSZbi4bBTyugkuhJvv2TSKEefJxf1rE/hXi -U3UDx16UTZXuLTS2XWR4/swG6k+79w5IM2d3ljDKPeoLl3oSMj7N/rqaj5WjKs1S -paqePaRWfAfYb0wCLgogJL6L/vvV8BillimzIWqXyfGlRux7oM5auy7rZ98dq5Tc -f2wQ05W6oyK65pI1h8X9CMOdaBPxuhb+2w0sj3kICV4ZBksWqVFvqdDiAXQWev9M -w+xVDorfKtcbspGh9RxwMqkRDqB+EBmLrLNPyYk/OUQ12vch9/8+L8W0xecRw6Qx -rZjT0MECgYEA4rRTZUx1Gq38ltdBE1wTDMohO1m/q5rE4+iQQJEv8mMNOxYNf/KN -4PR+57kXf6subM7J6iIBOFgNiA36fa+cJVGMj28y3BsbTmPdXAzRh3dv67dOwqvM -ssjFBmFbLqZz3a8qXyQ85QgThGT3Yreq+GOjfHmwYBiBCCMPJM0Zn1UCgYEA1/Mg -HX0d9SuIReLSFKDb9vLwdGeaAkm2o8qj14M2k6lPg8G5B6zDD57rs3e0QV0xG7UL -qwyahKENXUO9FLqzWk7HjJJiH11A4jR8kP42IKCpvwDVkQE3uLg9ONUCBBu9KA2h -6LQM0q5aEQHv+3PsgeQECwV+8X4Gz0sS65ChJO0CgYEA0OZlqoSHrCwDA2QavSIt -E632bWBINHMLVw/oTPb8fZg0iuvJSMtTXaUug4yVULmGsBDlEnB/O1I4NdTbq5F0 -ixbYNRu8fAImaVewlK/jK7ctVMG3O79fgqdqlnSDtzr+rZpJqx4TVuDYSzlWlIq2 -aug1r+/aTNKHo93aiIjOQXkCf3bwcb/MKbPfRi83vn2eG4joRYfXh/u6nd2YvqT0 -oBq0Jhdrm32eqdDwtuEiDSXzLhkUnliXmIN0MOgtZvcD3cTfnwjNlz2vHw132yQA -388YrmWFEBvNj+Mtloq2x2V74bMtzv9cK7PeU70KVCMqthjUfWWUoVZhE18Y+lLE -Vf0CgYEAzAlbU3OiVr36x5fTcPmHi7925cZpsaLKp/DwSIfHCp/oBI2UwZu4Q44h -FR+JaKhCkmIquxrkdM8tWGotMoPKVvakMZtuebMJTI/C/tt4MBCdibM7JqgZ1Olr -t2ujgUTujkXSCS8SSGS3KKvKoDquowJwqnDS8h2+WVfPgGJYOfk= ------END RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAyqdQgnelhAPMSeyH0kr3UGePK9oFOmNfwD0Ymnh7YYXr21VH +WwyM2eVW3cnLd9KXywDFtGSe9oFDbnOuMCdUowdkBcaHju+isbv5KEbNSoy/T2Ri +p+6L0cY63YzcMJzv1nEYztYXS8wz76pSK81BKBCLapqOCmcPeCvV9yaoFZYvZEsX +Cl5jjXN3iujSzSF5Z6PpNFlJWTErMT2Z4QfbDKX2Nw6vJN6JnGpTNHZvgvcyNX8v +kSgVpQ8DFnFkBEx54PvRV5KpHAq6AsJxKONMo11idQS2PfCNpa2hvz9O6UZe+eIX +8jPo5NW8TuGZJumbdPT/nxTDLfCqfiZboeI0PwIDAQABAoIBAHVJhLUd3jObpx6Z +wLobHSvx49Dza9cxMHeoZJbyaCY3Rhw5LQUrLFHoA/B1HEeLIMMi/Um8eqwcgBRq +60N/X+LDIkadclNtqfHH4xpGcAZXk1m1tcuPqmiMnAEhx0ZzbfPknQEIs47w7pYl +M02ai71OZgIa1V57614XsMxMGTf0HadsmqC0cxLQ21UxROzCvv49N26ZTav7aLwl +1yW+scP/lo2HH6VJFTNJduOBgmnnMVIEhYLHa26lsf3biARf8TyV2xupex7s46iD +RegXZzzAlHx8/qkFoTfENNefAbBX87E+r1gs9zmWEo+2DaKYxmuDTqAbk107c1Jo +XQ59MRECgYEA0utdbhdgL0ll69tV4wGdZb49VTCzfmGrab8X+8Hy6aFzUDYqIsJD +1l5e7fPxY+MqeP4XoJ7q5RAqIBAXSubds743RElMxvLsiy6Q/HuZ5ICxjUZ2Amop +mItcGG9RXaiqXAKHk6ZMgFhO3/NAVv2it+XnP3uLucAgmqh7Wp7ML2MCgYEA9fet +kYirz32ZAvxWDrTapQjSDkyCAaCZGB+BQ5paBLeMzTIcVHiu2aggCJrc4YoqB91D +JHlynZhvxOK0m1KXHDnbPn9YqwsVZDTIU4PnpC0KEj357VujXDH/tD0ggzrm5ruQ +4o0SpfavI7MAe0vUlv46x+CfIzSq+kPRenrRBHUCgYEAyCAIk1fcrKFg8ow3jt/O +X2ZFPZqrBMRZZ0mo0PiyqljFWBs8maRnx3PdcLvgk11MxGabNozy5YsT3T5HS4uI +Wm6mc8V08uQ16s2xRc9lMnmlfh2YBSyD8ThxlsGwm0RY+FpyF3dX6QNhO37L0n5w +MTsT0pk/92xDw1sPR+maZW8CgYBp8GJ2k1oExUDZE1vxe53MhS8L75HzJ3uo8zDW +sC1jaLchThr7mvscThh1/FV0YvDVcExR8mkWTaieMVK+r2TcSGMQ2QKUsPJmtYEu +z1o+0RNMZhs2S0jiFbrfo5BUVVNMP68YlNBaYRRwGNH1SOTon9kra6i/HhkiL4GS +8kECXQKBgAs/DqfCobJsIMi7TDcG1FkQEKwPmnKmh8rDX3665gCRz7rOoIG8u05A +J6pQqrUrPRI+AAtVM4nW0z4KE07ruTJ/8wapTErm/5Bp5bikiaHy7NY2kHj3hVwr +KYh700ZUPV9vd+xUpfTNoVyvV2tu4QnG8ihKII6vfCPItEpE8glo +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/test/data/test.pem.pub b/test/data/test.pem.pub deleted file mode 100644 index 061c9c016..000000000 --- a/test/data/test.pem.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC/PL+l5ASc28h5sn7dAa2vLp7zA86UMF9DlXliH2vTuKBQTlQa1Qzxij/ZF7h206gXB6w88W/DC7rlCnsvVdl1rcqU6dwJiZDT8AcQqXIDTsEFslVi8n3KD6b/3hbR/NSAVNknXxSeNSKnstPgw+QYA2syll4jWEZWKE4cgeMZH+Skx11bzraAJHB0WbgfD5Zu3RTBfOGmvfUIZj8zN/qbCHMSTpDRCNALtHjO+P06LpDWvUKKk+l1ktgMX6OhLED1koeIsIMfjaRC7zxYM1x1dK+Dluzr7gvRK9bBSkYsc8M4bkkQ0zdy6O/f89vHlAhCdY06KwyItCy51a1/lXWx jzelinskie@hanazawa diff --git a/test/registry_tests.py b/test/registry_tests.py index 559be2cef..15cb54e7b 100644 --- a/test/registry_tests.py +++ b/test/registry_tests.py @@ -15,10 +15,8 @@ from cachetools import lru_cache from flask import request, jsonify, abort from flask.blueprints import Blueprint from flask.ext.testing import LiveServerTestCase -from cryptography.x509 import load_pem_x509_certificate -from cryptography.hazmat.backends import default_backend -from app import app, storage +from app import app, storage, instance_keys from data.database import close_db_filter, configure, DerivedStorageForImage, QueueItem, Image from data import model from endpoints.v1 import v1_bp @@ -30,7 +28,7 @@ from initdb import wipe_database, initialize_database, populate_database from endpoints.csrf import generate_csrf_token from tempfile import NamedTemporaryFile from jsonschema import validate as validate_schema -from util.security import strictjwt +from util.security.registry_jwt import decode_bearer_token import endpoints.decorated import json @@ -1824,36 +1822,19 @@ class V1LoginTests(V1RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, Base class V2LoginTests(V2RegistryLoginMixin, LoginTests, RegistryTestCaseMixin, BaseRegistryMixin, LiveServerTestCase): """ Tests for V2 login. """ - - @staticmethod - @lru_cache(maxsize=1) - def load_public_key(certificate_file_path): - with open(certificate_file_path) as cert_file: - cert_obj = load_pem_x509_certificate(cert_file.read(), default_backend()) - return cert_obj.public_key() - - def do_logincheck(self, username, password, scope, expected_actions=[], expect_success=True, **kwargs): + # Perform login to get an auth token. response = self.do_login(username, password, scope, expect_success=expect_success, **kwargs) - if not expect_success: return - # Validate the returned JWT. + # Validate the returned token. encoded = response.json()['token'] + token = 'Bearer ' + encoded - expected_issuer = app.config['JWT_AUTH_TOKEN_ISSUER'] - audience = app.config['SERVER_HOSTNAME'] - - max_signed_s = app.config.get('JWT_AUTH_MAX_FRESH_S', 3660) - certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH'] - - public_key = V2LoginTests.load_public_key(certificate_file_path) - - max_exp = strictjwt.exp_max_s_option(max_signed_s) - payload = strictjwt.decode(encoded, public_key, algorithms=['RS256'], audience=audience, - issuer=expected_issuer, options=max_exp) + payload = decode_bearer_token(token, instance_keys) + self.assertIsNotNone(payload) if scope is None: self.assertEquals(0, len(payload['access'])) diff --git a/test/test_registry_v2_auth.py b/test/test_registry_v2_auth.py index 33cb0b2ff..039dcd7da 100644 --- a/test/test_registry_v2_auth.py +++ b/test/test_registry_v2_auth.py @@ -2,15 +2,15 @@ import unittest import time import jwt -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import rsa - -from app import app +from app import app, instance_keys +from data import model +from data.database import ServiceKeyApprovalType +from initdb import setup_database_for_testing, finished_database_for_testing from endpoints.v2.v2auth import TOKEN_VALIDITY_LIFETIME_S -from auth.registry_jwt_auth import identity_from_bearer_token, load_public_key, InvalidJWTException +from auth.registry_jwt_auth import identity_from_bearer_token, InvalidJWTException from util.morecollections import AttrDict -from util.security.registry_jwt import (_load_certificate_bytes, _load_private_key, ANONYMOUS_SUB, - build_context_and_subject) +from util.security.registry_jwt import (ANONYMOUS_SUB, build_context_and_subject, + decode_bearer_token, generate_bearer_token) TEST_AUDIENCE = app.config['SERVER_HOSTNAME'] @@ -19,20 +19,18 @@ MAX_SIGNED_S = 3660 class TestRegistryV2Auth(unittest.TestCase): - def __init__(self, *args, **kwargs): - super(TestRegistryV2Auth, self).__init__(*args, **kwargs) - self.public_key = None - def setUp(self): - certificate_file_path = app.config['JWT_AUTH_CERTIFICATE_PATH'] - self.public_key = load_public_key(certificate_file_path) + setup_database_for_testing(self) + + def tearDown(self): + finished_database_for_testing(self) def _generate_token_data(self, access=[], audience=TEST_AUDIENCE, user=TEST_USER, iat=None, - exp=None, nbf=None, iss=app.config['JWT_AUTH_TOKEN_ISSUER']): + exp=None, nbf=None, iss=None): _, subject = build_context_and_subject(user, None, None) return { - 'iss': iss, + 'iss': iss or instance_keys.service_name, 'aud': audience, 'nbf': nbf if nbf is not None else int(time.time()), 'iat': iat if iat is not None else int(time.time()), @@ -41,28 +39,25 @@ class TestRegistryV2Auth(unittest.TestCase): 'access': access, } - def _generate_token(self, token_data): + def _generate_token(self, token_data, key_id=None, private_key=None, skip_header=False, alg=None): + key_id = key_id or instance_keys.local_key_id + private_key = private_key or instance_keys.local_private_key - certificate = _load_certificate_bytes(app.config['JWT_AUTH_CERTIFICATE_PATH']) + if alg == "none": + private_key = None token_headers = { - 'x5c': [certificate], + 'kid': key_id, } - private_key = _load_private_key(app.config['JWT_AUTH_PRIVATE_KEY_PATH']) - token_data = jwt.encode(token_data, private_key, 'RS256', headers=token_headers) + if skip_header: + token_headers = {} + + token_data = jwt.encode(token_data, private_key, alg or 'RS256', headers=token_headers) return 'Bearer {0}'.format(token_data) def _parse_token(self, token): - return identity_from_bearer_token(token, MAX_SIGNED_S, self.public_key)[0] - - def _generate_public_key(self): - key = rsa.generate_private_key( - public_exponent=65537, - key_size=1024, - backend=default_backend() - ) - return key.public_key() + return identity_from_bearer_token(token)[0] def test_accepted_token(self): token = self._generate_token(self._generate_token_data()) @@ -100,12 +95,6 @@ class TestRegistryV2Auth(unittest.TestCase): with self.assertRaises(InvalidJWTException): self._parse_token(token) - def test_bad_signature(self): - token = self._generate_token(self._generate_token_data()) - other_public_key = self._generate_public_key() - with self.assertRaises(InvalidJWTException): - identity_from_bearer_token(token, MAX_SIGNED_S, other_public_key) - def test_audience(self): token_data = self._generate_token_data(audience='someotherapp') token = self._generate_token(token_data) @@ -171,7 +160,6 @@ class TestRegistryV2Auth(unittest.TestCase): def test_iss(self): token_data = self._generate_token_data(iss='badissuer') - token = self._generate_token(token_data) with self.assertRaises(InvalidJWTException): self._parse_token(token) @@ -181,9 +169,94 @@ class TestRegistryV2Auth(unittest.TestCase): with self.assertRaises(InvalidJWTException): self._parse_token(no_iss_token) + def test_missing_header(self): + token_data = self._generate_token_data() + missing_header_token = self._generate_token(token_data, skip_header=True) + with self.assertRaises(InvalidJWTException): + self._parse_token(missing_header_token) + + def test_invalid_key(self): + token_data = self._generate_token_data() + invalid_key_token = self._generate_token(token_data, key_id='someunknownkey') + with self.assertRaises(InvalidJWTException): + self._parse_token(invalid_key_token) + + def test_expired_key(self): + token_data = self._generate_token_data() + expired_key_token = self._generate_token(token_data, key_id='kid7') + with self.assertRaises(InvalidJWTException): + self._parse_token(expired_key_token) + + def test_mixing_keys(self): + token_data = self._generate_token_data() + + # Create a new key for testing. + p, key = model.service_keys.generate_service_key(instance_keys.service_name, None, kid='newkey', + name='newkey', metadata={}) + + private_key = p.exportKey('PEM') + + # Test first with the new valid, but unapproved key. + unapproved_key_token = self._generate_token(token_data, key_id='newkey', private_key=private_key) + with self.assertRaises(InvalidJWTException): + self._parse_token(unapproved_key_token) + + # Approve the key and try again. + admin_user = model.user.get_user('devtable') + model.service_keys.approve_service_key(key.kid, admin_user, ServiceKeyApprovalType.SUPERUSER) + + valid_token = self._generate_token(token_data, key_id='newkey', private_key=private_key) + + identity = self._parse_token(valid_token) + self.assertEqual(identity.id, TEST_USER.username) + self.assertEqual(0, len(identity.provides)) + + # Try using a different private key with the existing key ID. + bad_private_token = self._generate_token(token_data, key_id='newkey', private_key=instance_keys.local_private_key) + with self.assertRaises(InvalidJWTException): + self._parse_token(bad_private_token) + + # Try using a different key ID with the existing private key. + kid_mismatch_token = self._generate_token(token_data, key_id=instance_keys.local_key_id, private_key=private_key) + with self.assertRaises(InvalidJWTException): + self._parse_token(kid_mismatch_token) + + # Delete the new key. + key.delete_instance(recursive=True) + + # Ensure it still works (via the cache.) + deleted_key_token = self._generate_token(token_data, key_id='newkey', private_key=private_key) + identity = self._parse_token(deleted_key_token) + self.assertEqual(identity.id, TEST_USER.username) + self.assertEqual(0, len(identity.provides)) + + # Break the cache. + instance_keys.clear_cache() + + # Ensure the key no longer works. + with self.assertRaises(InvalidJWTException): + self._parse_token(deleted_key_token) + + def test_bad_token(self): + with self.assertRaises(InvalidJWTException): + self._parse_token("some random token here") + + def test_bad_bearer_token(self): + with self.assertRaises(InvalidJWTException): + self._parse_token("Bearer: sometokenhere") + + def test_bad_bearer_newline_token(self): + with self.assertRaises(InvalidJWTException): + self._parse_token("\nBearer: dGVzdA") + + def test_ensure_no_none(self): + token_data = self._generate_token_data() + none_token = self._generate_token(token_data, alg='none', private_key=None) + + with self.assertRaises(InvalidJWTException): + self._parse_token(none_token) + if __name__ == '__main__': - import logging - logging.basicConfig(level=logging.DEBUG) unittest.main() diff --git a/test/testconfig.py b/test/testconfig.py index aa8b8e549..8e37113d6 100644 --- a/test/testconfig.py +++ b/test/testconfig.py @@ -56,8 +56,6 @@ class TestConfig(DefaultConfig): FEATURE_BITTORRENT = True FEATURE_ACI_CONVERSION = True - INSTANCE_SERVICE_KEY_LOCATION = 'test/data/test.pem' - CLOUDWATCH_NAMESPACE = None FEATURE_SECURITY_SCANNER = True @@ -73,5 +71,5 @@ class TestConfig(DefaultConfig): GPG2_PRIVATE_KEY_FILENAME = 'test/data/signing-private.gpg' GPG2_PUBLIC_KEY_FILENAME = 'test/data/signing-public.gpg' - JWT_AUTH_CERTIFICATE_PATH = 'test/data/registry_v2_auth.crt' - JWT_AUTH_PRIVATE_KEY_PATH = 'test/data/registry_v2_auth_private.key' + INSTANCE_SERVICE_KEY_KID_LOCATION = 'test/data/test.kid' + INSTANCE_SERVICE_KEY_LOCATION = 'test/data/test.pem' diff --git a/util/expiresdict.py b/util/expiresdict.py new file mode 100644 index 000000000..0eacf6a07 --- /dev/null +++ b/util/expiresdict.py @@ -0,0 +1,55 @@ +from datetime import datetime + +class ExpiresEntry(object): + """ A single entry under a ExpiresDict. """ + def __init__(self, value, expires=None): + self.value = value + self._expiration = expires + + @property + def expired(self): + if self._expiration is None: + return False + + return datetime.now() >= self._expiration + + +class ExpiresDict(object): + """ ExpiresDict defines a dictionary-like class whose keys have expiration. The rebuilder is + a function that returns the full contents of the cached dictionary as a dict of the keys + and whose values are TTLEntry's. + """ + def __init__(self, rebuilder): + self._rebuilder = rebuilder + self._items = {} + + def __getitem__(self, key): + found = self.get(key) + if found is None: + raise KeyError + + return found + + def get(self, key, default_value=None): + # Check the cache first. If the key is found and it has not yet expired, + # return it. + found = self._items.get(key) + if found is not None and not found.expired: + return found.value + + # Otherwise the key has expired or was not found. Rebuild the cache and check it again. + self._rebuild() + found = self._items.get(key) + if found is None: + return default_value + + return found.value + + def __contains__(self, key): + return self.get(key) is not None + + def _rebuild(self): + self._items = self._rebuilder() + + def set(self, key, value, expires=None): + self._items[key] = ExpiresEntry(value, expires=expires) diff --git a/util/registry/torrent.py b/util/registry/torrent.py index 61e25e05e..d81caa162 100644 --- a/util/registry/torrent.py +++ b/util/registry/torrent.py @@ -7,15 +7,12 @@ import urllib from cachetools import lru_cache -from app import app +from app import app, instance_keys -ANNOUNCE_URL = app.config.get('BITTORRENT_ANNOUNCE_URL') -PRIVATE_KEY_LOCATION = app.config.get('INSTANCE_SERVICE_KEY_LOCATION') -FILENAME_PEPPER = app.config.get('BITTORRENT_FILENAME_PEPPER') -REGISTRY_TITLE = app.config.get('REGISTRY_TITLE') -JWT_ISSUER = app.config.get('JWT_AUTH_TOKEN_ISSUER') - +ANNOUNCE_URL = app.config['BITTORRENT_ANNOUNCE_URL'] +FILENAME_PEPPER = app.config['BITTORRENT_FILENAME_PEPPER'] +REGISTRY_TITLE = app.config['REGISTRY_TITLE'] @lru_cache(maxsize=1) def _load_private_key(private_key_file_path): @@ -24,13 +21,12 @@ def _load_private_key(private_key_file_path): def _torrent_jwt(info_dict): token_data = { - 'iss': JWT_ISSUER, + 'iss': instance_keys.service_name, 'aud': ANNOUNCE_URL, 'infohash': _infohash(info_dict), } - private_key = _load_private_key(PRIVATE_KEY_LOCATION) - return jwt.encode(token_data, private_key, 'RS256') + return jwt.encode(token_data, instance_keys.local_private_key, 'RS256') def _infohash(infodict): digest = hashlib.sha1() diff --git a/util/secscan/api.py b/util/secscan/api.py index a07fd9d0b..cbe97745f 100644 --- a/util/secscan/api.py +++ b/util/secscan/api.py @@ -8,7 +8,8 @@ from data.database import CloseForLongOperation from data import model from data.model.storage import get_storage_locations from util.secscan.validator import SecurityConfigValidator -from util.security.registry_jwt import generate_jwt_object, build_context_and_subject +from util.security.instancekeys import InstanceKeys +from util.security.registry_jwt import generate_bearer_token, build_context_and_subject from util import get_app_url @@ -43,6 +44,7 @@ class SecurityScannerAPI(object): self._app = app self._config = config + self._instance_keys = InstanceKeys(app) self._client = client or config['HTTPCLIENT'] self._storage = storage self._default_storage_locations = config['DISTRIBUTED_STORAGE_PREFERENCE'] @@ -80,9 +82,10 @@ class SecurityScannerAPI(object): 'name': repository_and_namespace, 'actions': ['pull'], }] - auth_jwt = generate_jwt_object(audience, subject, context, access, TOKEN_VALIDITY_LIFETIME_S, - self._config) - auth_header = 'Bearer {}'.format(auth_jwt) + + auth_token = generate_bearer_token(audience, subject, context, access, + TOKEN_VALIDITY_LIFETIME_S, self._instance_keys) + auth_header = 'Bearer ' + auth_token with self._app.test_request_context('/'): relative_layer_url = url_for('v2.download_blob', repository=repository_and_namespace, diff --git a/util/security/instancekeys.py b/util/security/instancekeys.py new file mode 100644 index 000000000..0f9bef8e6 --- /dev/null +++ b/util/security/instancekeys.py @@ -0,0 +1,81 @@ +from cachetools import lru_cache +from data import model +from util.expiresdict import ExpiresDict, ExpiresEntry +from util.security import jwtutil + + +class InstanceKeys(object): + """ InstanceKeys defines a helper class for interacting with the Quay instance service keys + used for JWT signing of registry tokens as well as requests from Quay to other services + such as Clair. Each container will have a single registered instance key. + """ + def __init__(self, app): + self.app = app + self.instance_keys = ExpiresDict(self._load_instance_keys) + self.public_keys = {} + + def clear_cache(self): + """ Clears the cache of instance keys. """ + self.instance_keys = ExpiresDict(self._load_instance_keys) + self.public_keys = {} + + def _load_instance_keys(self): + # Load all the instance keys. + keys = {} + for key in model.service_keys.list_service_keys(self.service_name): + keys[key.kid] = ExpiresEntry(key, key.expiration_date) + + # Remove any expired or deleted keys from the public keys cache. + for key in self.public_keys: + if key not in keys: + self.public_keys.pop(key) + + return keys + + @property + def service_name(self): + """ Returns the name of the instance key's service (i.e. 'quay'). """ + return self.app.config['INSTANCE_SERVICE_KEY_SERVICE'] + + @property + def service_key_expiration(self): + """ Returns the defined expiration for instance service keys, in minutes. """ + return self.app.config.get('INSTANCE_SERVICE_KEY_EXPIRATION', 120) + + @property + @lru_cache(maxsize=1) + def local_key_id(self): + """ Returns the ID of the local instance service key. """ + return _load_file_contents(self.app.config['INSTANCE_SERVICE_KEY_KID_LOCATION']) + + @property + @lru_cache(maxsize=1) + def local_private_key(self): + """ Returns the private key of the local instance service key. """ + return _load_file_contents(self.app.config['INSTANCE_SERVICE_KEY_LOCATION']) + + def get_service_key_public_key(self, kid): + """ Returns the public key associated with the given instance service key or None if none. """ + + # Note: We do the lookup via instance_keys *first* to ensure that if a key has expired, we + # don't use the entry in the public key cache. + service_key = self.instance_keys.get(kid) + if service_key is None: + # Remove the kid from the cache just to be sure. + self.public_keys.pop(kid, None) + return None + + public_key = self.public_keys.get(kid) + if public_key is not None: + return public_key + + # Convert the JWK into a public key and cache it (since the conversion can take > 200ms). + public_key = jwtutil.jwk_dict_to_public_key(service_key.jwk) + self.public_keys[kid] = public_key + return public_key + + +def _load_file_contents(path): + """ Returns the contents of the specified file path. """ + with open(path) as f: + return f.read() diff --git a/util/security/strictjwt.py b/util/security/jwtutil.py similarity index 66% rename from util/security/strictjwt.py rename to util/security/jwtutil.py index 61bb61454..dc190838f 100644 --- a/util/security/strictjwt.py +++ b/util/security/jwtutil.py @@ -1,3 +1,5 @@ +import re + from datetime import datetime, timedelta from jwt import PyJWT from jwt.exceptions import ( @@ -5,8 +7,19 @@ from jwt.exceptions import ( ImmatureSignatureError, InvalidIssuedAtError, InvalidIssuerError, MissingRequiredClaimError ) +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers +from jwkest.jwk import keyrep, RSAKey, ECKey + + +# TOKEN_REGEX defines a regular expression for matching JWT bearer tokens. +TOKEN_REGEX = re.compile(r'\ABearer (([a-zA-Z0-9+\-_/]+\.)+[a-zA-Z0-9+\-_/]+)\Z') + class StrictJWT(PyJWT): + """ StrictJWT defines a JWT decoder with extra checks. """ + @staticmethod def _get_default_options(): # Weird syntax to call super on a staticmethod @@ -53,3 +66,16 @@ def exp_max_s_option(max_exp_s): decode = StrictJWT().decode + + +def jwk_dict_to_public_key(jwk): + """ Converts the specified JWK into a public key. """ + jwkest_key = keyrep(jwk) + if isinstance(jwkest_key, RSAKey): + pycrypto_key = jwkest_key.key + return RSAPublicNumbers(e=pycrypto_key.e, n=pycrypto_key.n).public_key(default_backend()) + elif isinstance(jwkest_key, ECKey): + x, y = jwkest_key.get_key() + return EllipticCurvePublicNumbers(x, y, jwkest_key.curve).public_key(default_backend()) + + raise Exception('Unsupported kind of JWK: %s', str(type(jwkest_key))) diff --git a/util/security/registry_jwt.py b/util/security/registry_jwt.py index bdf877dc8..be110f1a7 100644 --- a/util/security/registry_jwt.py +++ b/util/security/registry_jwt.py @@ -1,17 +1,80 @@ import time import jwt +import logging -from cachetools import lru_cache +from util.security import jwtutil +logger = logging.getLogger(__name__) ANONYMOUS_SUB = '(anonymous)' +ALGORITHM = 'RS256' -def generate_jwt_object(audience, subject, context, access, lifetime_s, app_config): - """ Generates a compact encoded JWT with the values specified. +class InvalidBearerTokenException(Exception): + pass + + +def decode_bearer_token(bearer_token, instance_keys): + """ decode_bearer_token decodes the given bearer token that contains both a Key ID as well as the + encoded JWT and returns the decoded and validated JWT. On any error, raises an + InvalidBearerTokenException with the reason for failure. """ + app_config = instance_keys.app.config + + # Extract the jwt token from the header + match = jwtutil.TOKEN_REGEX.match(bearer_token) + if match is None: + raise InvalidBearerTokenException('Invalid bearer token format') + + encoded_jwt = match.group(1) + logger.debug('encoded JWT: %s', encoded_jwt) + + # Decode the key ID. + headers = jwt.get_unverified_header(encoded_jwt) + kid = headers.get('kid', None) + if kid is None: + logger.error('Missing kid header on encoded JWT: %s', encoded_jwt) + raise InvalidBearerTokenException('Missing kid header') + + # Find the matching public key. + public_key = instance_keys.get_service_key_public_key(kid) + if public_key is None: + logger.error('Could not find requested service key %s', kid) + raise InvalidBearerTokenException('Unknown service key') + + # Load the JWT returned. + try: + expected_issuer = instance_keys.service_name + audience = app_config['SERVER_HOSTNAME'] + max_signed_s = app_config.get('REGISTRY_JWT_AUTH_MAX_FRESH_S', 3660) + + max_exp = jwtutil.exp_max_s_option(max_signed_s) + payload = jwtutil.decode(encoded_jwt, public_key, algorithms=[ALGORITHM], audience=audience, + issuer=expected_issuer, options=max_exp) + except jwtutil.InvalidTokenError as ite: + logger.exception('Invalid token reason: %s', ite) + raise InvalidBearerTokenException(ite) + + if not 'sub' in payload: + raise InvalidBearerTokenException('Missing sub field in JWT') + + return payload + + +def generate_bearer_token(audience, subject, context, access, lifetime_s, instance_keys): + """ Generates a registry bearer token (without the 'Bearer ' portion) based on the given + information. + """ + return _generate_jwt_object(audience, subject, context, access, lifetime_s, + instance_keys.service_name, instance_keys.local_key_id, + instance_keys.local_private_key) + + +def _generate_jwt_object(audience, subject, context, access, lifetime_s, issuer, key_id, + private_key): + """ Generates a compact encoded JWT with the values specified. """ token_data = { - 'iss': app_config['JWT_AUTH_TOKEN_ISSUER'], + 'iss': issuer, 'aud': audience, 'nbf': int(time.time()), 'iat': int(time.time()), @@ -21,15 +84,11 @@ def generate_jwt_object(audience, subject, context, access, lifetime_s, app_conf 'context': context, } - certificate = _load_certificate_bytes(app_config['JWT_AUTH_CERTIFICATE_PATH']) - token_headers = { - 'x5c': [certificate], + 'kid': key_id, } - private_key = _load_private_key(app_config['JWT_AUTH_PRIVATE_KEY_PATH']) - - return jwt.encode(token_data, private_key, 'RS256', headers=token_headers) + return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers) def build_context_and_subject(user, token, oauthtoken): @@ -64,14 +123,3 @@ def build_context_and_subject(user, token, oauthtoken): return (context, ANONYMOUS_SUB) -@lru_cache(maxsize=1) -def _load_certificate_bytes(certificate_file_path): - with open(certificate_file_path) as cert_file: - cert_lines = cert_file.readlines()[1:-1] - return ''.join([cert_line.rstrip('\n') for cert_line in cert_lines]) - - -@lru_cache(maxsize=1) -def _load_private_key(private_key_file_path): - with open(private_key_file_path) as private_key_file: - return private_key_file.read() diff --git a/workers/service_key_worker.py b/workers/service_key_worker.py index a21ef3f69..adbcf5465 100644 --- a/workers/service_key_worker.py +++ b/workers/service_key_worker.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timedelta -from app import app +from app import app, instance_keys from data.model.service_keys import set_key_expiration from workers.worker import Worker @@ -17,14 +17,9 @@ class ServiceKeyWorker(Worker): """ Refreshes active service keys so they don't get garbage collected. """ - with open("/conf/quay.kid") as f: - kid = f.read() - - minutes_until_expiration = app.config.get('INSTANCE_SERVICE_KEY_EXPIRATION', 120) - expiration = timedelta(minutes=minutes_until_expiration) - + expiration = timedelta(minutes=instance_keys.service_key_expiration) logger.debug('Starting refresh of automatic service keys') - set_key_expiration(kid, datetime.now() + expiration) + set_key_expiration(instance_keys.local_key_id, datetime.now() + expiration) logger.debug('Finished refresh of automatic service keys') if __name__ == "__main__":