Use the instance service key for registry JWT signing

This commit is contained in:
Joseph Schorr 2016-05-31 16:48:19 -04:00
parent a4aa5cc02a
commit 8887f09ba8
26 changed files with 457 additions and 278 deletions

3
app.py
View file

@ -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']

View file

@ -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)

View file

@ -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())

View file

@ -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 = []

View file

@ -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')

View file

@ -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)

View file

@ -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

View file

@ -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})

View file

@ -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': ''})

View file

@ -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'})

View file

@ -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-----

View file

@ -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
View file

@ -0,0 +1 @@
test_service_key

View file

@ -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-----

View file

@ -1 +0,0 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC/PL+l5ASc28h5sn7dAa2vLp7zA86UMF9DlXliH2vTuKBQTlQa1Qzxij/ZF7h206gXB6w88W/DC7rlCnsvVdl1rcqU6dwJiZDT8AcQqXIDTsEFslVi8n3KD6b/3hbR/NSAVNknXxSeNSKnstPgw+QYA2syll4jWEZWKE4cgeMZH+Skx11bzraAJHB0WbgfD5Zu3RTBfOGmvfUIZj8zN/qbCHMSTpDRCNALtHjO+P06LpDWvUKKk+l1ktgMX6OhLED1koeIsIMfjaRC7zxYM1x1dK+Dluzr7gvRK9bBSkYsc8M4bkkQ0zdy6O/f89vHlAhCdY06KwyItCy51a1/lXWx jzelinskie@hanazawa

View file

@ -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']))

View file

@ -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()

View file

@ -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
View 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)

View file

@ -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()

View file

@ -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,

View 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()

View file

@ -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)))

View file

@ -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()

View file

@ -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__":