Use the instance service key for registry JWT signing
This commit is contained in:
parent
a4aa5cc02a
commit
8887f09ba8
26 changed files with 457 additions and 278 deletions
3
app.py
3
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']
|
||||
|
|
|
@ -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)
|
||||
|
|
9
boot.py
9
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())
|
||||
|
||||
|
|
22
config.py
22
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 = []
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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/<service>/keys/<kid>', 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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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': ''})
|
||||
|
|
14
initdb.py
14
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'})
|
||||
|
||||
|
|
|
@ -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-----
|
|
@ -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-----
|
Binary file not shown.
1
test/data/test.kid
Normal file
1
test/data/test.kid
Normal file
|
@ -0,0 +1 @@
|
|||
test_service_key
|
|
@ -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-----
|
|
@ -1 +0,0 @@
|
|||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC/PL+l5ASc28h5sn7dAa2vLp7zA86UMF9DlXliH2vTuKBQTlQa1Qzxij/ZF7h206gXB6w88W/DC7rlCnsvVdl1rcqU6dwJiZDT8AcQqXIDTsEFslVi8n3KD6b/3hbR/NSAVNknXxSeNSKnstPgw+QYA2syll4jWEZWKE4cgeMZH+Skx11bzraAJHB0WbgfD5Zu3RTBfOGmvfUIZj8zN/qbCHMSTpDRCNALtHjO+P06LpDWvUKKk+l1ktgMX6OhLED1koeIsIMfjaRC7zxYM1x1dK+Dluzr7gvRK9bBSkYsc8M4bkkQ0zdy6O/f89vHlAhCdY06KwyItCy51a1/lXWx jzelinskie@hanazawa
|
|
@ -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']))
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
55
util/expiresdict.py
Normal file
55
util/expiresdict.py
Normal file
|
@ -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)
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
81
util/security/instancekeys.py
Normal file
81
util/security/instancekeys.py
Normal file
|
@ -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()
|
|
@ -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)))
|
|
@ -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()
|
||||
|
|
|
@ -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__":
|
||||
|
|
Reference in a new issue