Skip to content

Commit

Permalink
Replace jwkest with authlib (quay#685)
Browse files Browse the repository at this point in the history
* Replace jwkest with authlib and PyCrypto with cryptography

Remove pycryptodome dependencies.
Remove post-fork random seed init - python-cryptography's pseudo RNG should be fork safe:
- https://cryptography.io/en/latest/security.html?highlight=fork
- https://cryptography.io/en/latest/hazmat/backends/openssl.html?highlight=fork#os-random-engine

* deps: Pin cryptography to 3.3.1

Latest available version available on RHEL.
As of 3.4, cryptography builds on rust, which is not readily available
on RHEL.
  • Loading branch information
kleesc authored Mar 15, 2021
1 parent 047b837 commit 7d9a49d
Show file tree
Hide file tree
Showing 32 changed files with 358 additions and 250 deletions.
9 changes: 5 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

from functools import partial

from Crypto.PublicKey import RSA
from authlib.jose import JsonWebKey
from cryptography.hazmat.primitives import serialization
from flask import Flask, request, Request
from flask_login import LoginManager
from flask_mail import Mail
from flask_principal import Principal
from jwkest.jwk import RSAKey
from werkzeug.contrib.fixers import ProxyFix
from werkzeug.exceptions import HTTPException

Expand Down Expand Up @@ -300,9 +300,10 @@ def _request_end(resp):
# Check for a key in config. If none found, generate a new signing key for Docker V2 manifests.
_v2_key_path = os.path.join(OVERRIDE_CONFIG_DIRECTORY, DOCKER_V2_SIGNINGKEY_FILENAME)
if os.path.exists(_v2_key_path):
docker_v2_signing_key = RSAKey().load(_v2_key_path)
with open(_v2_key_path) as key_file:
docker_v2_signing_key = JsonWebKey.import_key(key_file.read())
else:
docker_v2_signing_key = RSAKey(key=RSA.generate(2048))
docker_v2_signing_key = JsonWebKey.generate_key("RSA", 2048, is_private=True)

# Configure the database.
if app.config.get("DATABASE_SECRET_KEY") is None and app.config.get("SETUP_COMPLETE", False):
Expand Down
8 changes: 7 additions & 1 deletion auth/test/test_registry_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import jwt
import pytest

from cryptography.hazmat.primitives import serialization

from app import app, instance_keys
from auth.auth_context_type import ValidatedAuthContext
from auth.registry_jwt_auth import identity_from_bearer_token, InvalidJWTException
Expand Down Expand Up @@ -184,7 +186,11 @@ def test_mixing_keys_e2e(initialized_db):
p, key = model.service_keys.generate_service_key(
instance_keys.service_name, None, kid="newkey", name="newkey", metadata={}
)
private_key = p.exportKey("PEM")
private_key = p.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)

# Test first with the new valid, but unapproved key.
unapproved_key_token = _token(token_data, key_id="newkey", private_key=private_key)
Expand Down
10 changes: 0 additions & 10 deletions bill-of-materials.json
Original file line number Diff line number Diff line change
Expand Up @@ -444,16 +444,6 @@
"license": "BSD License",
"project": "pycparser"
},
{
"format": "Python",
"license": "BSD 2-Clause License",
"project": "pycryptodome"
},
{
"format": "Python",
"license": "BSD 2-Clause License",
"project": "pycryptodomex"
},
{
"format": "Python",
"license": "LGPL-3.0",
Expand Down
12 changes: 10 additions & 2 deletions boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import release
import os.path

from cryptography.hazmat.primitives import serialization

from app import app
from data.model import ServiceKeyDoesNotExist
from data.model.release import set_region_release
Expand Down Expand Up @@ -94,9 +96,15 @@ def setup_jwt_proxy():
f.truncate(0)
f.write(quay_key_id)

with open(app.config["INSTANCE_SERVICE_KEY_LOCATION"], mode="w") as f:
with open(app.config["INSTANCE_SERVICE_KEY_LOCATION"], mode="wb") as f:
f.truncate(0)
f.write(quay_key.exportKey().decode("utf-8"))
f.write(
quay_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)

# Generate the JWT proxy configuration.
audience = get_audience()
Expand Down
7 changes: 0 additions & 7 deletions conf/gunicorn_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import logging

from Crypto import Random
from util.log import logfile_path
from util.workers import get_worker_count, get_worker_connections_count

Expand All @@ -20,12 +19,6 @@
preload_app = True


def post_fork(server, worker):
# Reset the Random library to ensure it won't raise the "PID check failed." error after
# gunicorn forks.
Random.atfork()


def when_ready(server):
logger = logging.getLogger(__name__)
logger.debug(
Expand Down
7 changes: 0 additions & 7 deletions conf/gunicorn_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import logging

from Crypto import Random
from util.log import logfile_path
from util.workers import get_worker_count, get_worker_connections_count

Expand All @@ -24,12 +23,6 @@
preload_app = True


def post_fork(server, worker):
# Reset the Random library to ensure it won't raise the "PID check failed." error after
# gunicorn forks.
Random.atfork()


def when_ready(server):
logger = logging.getLogger(__name__)
logger.debug(
Expand Down
7 changes: 0 additions & 7 deletions conf/gunicorn_secscan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import logging

from Crypto import Random
from util.log import logfile_path
from util.workers import get_worker_count, get_worker_connections_count

Expand All @@ -24,12 +23,6 @@
preload_app = True


def post_fork(server, worker):
# Reset the Random library to ensure it won't raise the "PID check failed." error after
# gunicorn forks.
Random.atfork()


def when_ready(server):
logger = logging.getLogger(__name__)
logger.debug(
Expand Down
7 changes: 0 additions & 7 deletions conf/gunicorn_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import logging

from Crypto import Random
from util.log import logfile_path
from util.workers import get_worker_count, get_worker_connections_count

Expand All @@ -25,12 +24,6 @@
preload_app = True


def post_fork(server, worker):
# Reset the Random library to ensure it won't raise the "PID check failed." error after
# gunicorn forks.
Random.atfork()


def when_ready(server):
logger = logging.getLogger(__name__)
logger.debug("Starting web gunicorn with %s workers and %s worker class", workers, worker_class)
2 changes: 1 addition & 1 deletion data/logs_model/test/test_logs_producer.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def kinesis_logs_producer_config(app_config):

kinesis_stream_config = {
"stream_name": "test-stream",
"aws_region": "fake_region",
"aws_region": "fake-region-1",
"aws_access_key": "some_key",
"aws_secret_key": "some_secret",
}
Expand Down
23 changes: 14 additions & 9 deletions data/model/service_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
from datetime import datetime, timedelta
from peewee import JOIN

from Crypto.PublicKey import RSA
from jwkest.jwk import RSAKey
from authlib.jose import JsonWebKey

from data.database import db_for_update, User, ServiceKey, ServiceKeyApproval
from data.model import (
Expand All @@ -16,7 +15,6 @@
config,
)
from data.model.notification import create_notification, delete_all_notifications_by_path_prefix
from util.security.fingerprint import canonical_kid


_SERVICE_NAME_REGEX = re.compile(r"^[a-z0-9_]+$")
Expand Down Expand Up @@ -100,21 +98,28 @@ def create_service_key(name, kid, service, jwk, metadata, expiration_date, rotat
def generate_service_key(
service, expiration_date, kid=None, name="", metadata=None, rotation_duration=None
):
private_key = RSA.generate(2048)
jwk = RSAKey(key=private_key.publickey()).serialize()
if kid is None:
kid = canonical_kid(jwk)
"""
'kid' will default to the jwk thumbprint if not set explicitly.
Reference: https://tools.ietf.org/html/rfc7638
"""
options = {}
if kid:
options["kid"] = kid

jwk = JsonWebKey.generate_key("RSA", 2048, is_private=True, options=options)
kid = jwk.as_dict()["kid"]

key = create_service_key(
name,
kid,
service,
jwk,
jwk.as_dict(),
metadata or {},
expiration_date,
rotation_duration=rotation_duration,
)
return (private_key, key)
return (jwk.get_private_key(), key)


def replace_service_key(old_kid, kid, jwk, metadata, expiration_date):
Expand Down
17 changes: 15 additions & 2 deletions endpoints/api/superuser.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from flask import request, make_response, jsonify

from cryptography.hazmat.primitives import serialization

import features

from app import app, avatar, superusers, authentication, config_provider
Expand Down Expand Up @@ -687,13 +689,24 @@ def post(self):
log_action("service_key_create", None, key_log_metadata)
log_action("service_key_approve", None, key_log_metadata)

public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)

return jsonify(
{
"kid": key_id,
"name": key_name,
"service": body["service"],
"public_key": private_key.publickey().exportKey("PEM").decode("ascii"),
"private_key": private_key.exportKey("PEM").decode("ascii"),
"public_key": public_pem.decode("ascii"),
"private_key": private_pem.decode("ascii"),
}
)

Expand Down
72 changes: 47 additions & 25 deletions image/docker/schema1.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@

from jsonschema import validate as validate_schema, ValidationError

from jwkest.jws import SIGNER_ALGS, keyrep, BadSignature
from jwt.utils import base64url_encode, base64url_decode

from authlib.jose import JsonWebKey, JsonWebSignature
from authlib.jose.errors import BadSignatureError, UnsupportedAlgorithmError

from digest import digest_tools
from image.shared import ManifestException
from image.shared.types import ManifestImageLayer
Expand Down Expand Up @@ -215,26 +217,33 @@ def __init__(self, manifest_bytes, validate=True):
self._validate()

def _validate(self):
"""
Reference: https://docs.docker.com/registry/spec/manifest-v2-1/#signed-manifests
"""
if not self._signatures:
return

payload_str = self._payload
for signature in self._signatures:
bytes_to_verify = b"%s.%s" % (
Bytes.for_string_or_unicode(signature["protected"]).as_encoded_str(),
base64url_encode(payload_str),
)
signer = SIGNER_ALGS[signature["header"]["alg"]]
key = keyrep(signature["header"]["jwk"])
gk = key.get_key()
sig = base64url_decode(signature["signature"].encode("utf-8"))
protected = signature[DOCKER_SCHEMA1_PROTECTED_KEY]
sig = signature[DOCKER_SCHEMA1_SIGNATURE_KEY]

jwk = JsonWebKey.import_key(signature[DOCKER_SCHEMA1_HEADER_KEY]["jwk"])
jws = JsonWebSignature(algorithms=[signature[DOCKER_SCHEMA1_HEADER_KEY]["alg"]])

obj_to_verify = {
DOCKER_SCHEMA1_PROTECTED_KEY: protected,
DOCKER_SCHEMA1_SIGNATURE_KEY: sig,
DOCKER_SCHEMA1_HEADER_KEY: {"alg": signature[DOCKER_SCHEMA1_HEADER_KEY]["alg"]},
"payload": base64url_encode(payload_str),
}

try:
verified = signer.verify(bytes_to_verify, sig, gk)
except BadSignature:
data = jws.deserialize_json(obj_to_verify, jwk.get_public_key())
except (BadSignatureError, UnsupportedAlgorithmError):
raise InvalidSchema1Signature()

if not verified:
if not data:
raise InvalidSchema1Signature()

def validate(self, content_retriever):
Expand Down Expand Up @@ -734,6 +743,13 @@ def with_metadata_removed(self):
def build(self, json_web_key=None, ensure_ascii=True):
"""
Builds a DockerSchema1Manifest object, with optional signature.
NOTE: For backward compatibility, "JWS JSON Serialization" is used instead of "JWS Compact Serialization", since the latter **requires** that the
"alg" headers be carried in the **protected** headers, which was never done before migrating to authlib (One shouldn't be using schema1 anyways)
References:
- https://tools.ietf.org/html/rfc7515#section-10.7
- https://docs.docker.com/registry/spec/manifest-v2-1/#signed-manifests
"""
payload = OrderedDict(self._base_payload)
payload.update(
Expand All @@ -751,32 +767,38 @@ def build(self, json_web_key=None, ensure_ascii=True):
split_point = payload_str.rfind(b"\n}")

protected_payload = {
"formatTail": base64url_encode(payload_str[split_point:]).decode("ascii"),
"formatLength": split_point,
DOCKER_SCHEMA1_FORMAT_TAIL_KEY: base64url_encode(payload_str[split_point:]).decode(
"ascii"
),
DOCKER_SCHEMA1_FORMAT_LENGTH_KEY: split_point,
"time": datetime.utcnow().strftime(_ISO_DATETIME_FORMAT_ZULU),
}
protected = base64url_encode(
json.dumps(protected_payload, ensure_ascii=ensure_ascii).encode("utf-8")
)
logger.debug("Generated protected block: %s", protected)

bytes_to_sign = b"%s.%s" % (protected, base64url_encode(payload_str))
# Flattened JSON serialization header
jws = JsonWebSignature(algorithms=[_JWS_SIGNING_ALGORITHM])
headers = {
"protected": protected_payload,
"header": {"alg": _JWS_SIGNING_ALGORITHM},
}

signer = SIGNER_ALGS[_JWS_SIGNING_ALGORITHM]
signature = base64url_encode(signer.sign(bytes_to_sign, json_web_key.get_key()))
signed = jws.serialize_json(headers, payload_str, json_web_key.get_private_key())
protected = signed["protected"]
signature = signed["signature"]
logger.debug("Generated signature: %s", signature)
logger.debug("Generated protected block: %s", protected)

public_members = set(json_web_key.public_members)
public_members = set(json_web_key.REQUIRED_JSON_FIELDS + json_web_key.ALLOWED_PARAMS)
public_key = {
comp: value
for comp, value in list(json_web_key.to_dict().items())
for comp, value in list(json_web_key.as_dict().items())
if comp in public_members
}
public_key["kty"] = json_web_key.kty

signature_block = {
DOCKER_SCHEMA1_HEADER_KEY: {"jwk": public_key, "alg": _JWS_SIGNING_ALGORITHM},
DOCKER_SCHEMA1_SIGNATURE_KEY: signature.decode("ascii"),
DOCKER_SCHEMA1_PROTECTED_KEY: protected.decode("ascii"),
DOCKER_SCHEMA1_SIGNATURE_KEY: signature,
DOCKER_SCHEMA1_PROTECTED_KEY: protected,
}

logger.debug("Encoded signature block: %s", json.dumps(signature_block))
Expand Down
Loading

0 comments on commit 7d9a49d

Please sign in to comment.