zuul/zuul/lib/keystorage.py

379 lines
14 KiB
Python

# Copyright 2018 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import abc
import io
import json
import logging
import os
import tempfile
import kazoo
import paramiko
from zuul.lib import encryption
from zuul.zk import ZooKeeperBase
RSA_KEY_SIZE = 2048
class Migration(object):
log = logging.getLogger("zuul.KeyStorage")
version = 0
parent = None
def verify(self, root):
fn = os.path.join(root, '.version')
if not os.path.exists(fn):
return False
with open(fn) as f:
data = int(f.read().strip())
if data == self.version:
return True
return False
def writeVersion(self, root):
fn = os.path.join(root, '.version')
with open(fn, 'w') as f:
f.write(str(self.version))
def upgrade(self, root):
pass
def verifyAndUpgrade(self, root):
if self.verify(root):
return
if self.parent:
self.parent.verifyAndUpgrade(root)
self.log.info("Upgrading key storage to version %s" % self.version)
self.upgrade(root)
self.writeVersion(root)
self.log.info("Finished upgrading key storage to version %s" %
self.version)
if not self.verify(root):
raise Exception("Inconsistent result after migration")
class MigrationV1(Migration):
version = 1
parent = None
"""Upgrade from the unversioned schema to version 1.
The original schema had secret keys in key_dir/connection/project.pem
This updates us to:
key_dir/
secrets/
project/
<connection>/
<project>/
<keyid>.pem
ssh/
project/
<connection>/
<project>/
<keyid>.pem
tenant/
<tenant>/
<keyid>.pem
Where keyids are integers to support future key rollover. In this
case, they will all be 0.
"""
def upgrade(self, root):
tmpdir = tempfile.mkdtemp(dir=root)
tmpdirname = os.path.basename(tmpdir)
connection_names = []
for connection_name in os.listdir(root):
if connection_name == tmpdirname:
continue
# Move existing connections out of the way (in case one of
# them was called 'secrets' or 'ssh'.
os.rename(os.path.join(root, connection_name),
os.path.join(tmpdir, connection_name))
connection_names.append(connection_name)
os.makedirs(os.path.join(root, 'secrets', 'project'), 0o700)
os.makedirs(os.path.join(root, 'ssh', 'project'), 0o700)
os.makedirs(os.path.join(root, 'ssh', 'tenant'), 0o700)
for connection_name in connection_names:
connection_root = os.path.join(tmpdir, connection_name)
for (dirpath, dirnames, filenames) in os.walk(connection_root):
subdir = os.path.relpath(dirpath, connection_root)
for fn in filenames:
key_name = os.path.join(subdir, fn)
project_name = key_name[:-len('.pem')]
key_dir = os.path.join(root, 'secrets', 'project',
connection_name, project_name)
os.makedirs(key_dir, 0o700)
old = os.path.join(tmpdir, connection_name, key_name)
new = os.path.join(key_dir, '0.pem')
self.log.debug("Moving key from %s to %s", old, new)
os.rename(old, new)
for (dirpath, dirnames, filenames) in os.walk(
connection_root, topdown=False):
os.rmdir(dirpath)
os.rmdir(tmpdir)
class KeyStorage(abc.ABC):
log = logging.getLogger("zuul.KeyStorage")
@abc.abstractmethod
def getProjectSSHKeys(self, connection_name, project_name):
"""Return the private and public SSH keys for the project
A new key will be created if necessary.
:returns: A tuple containing the PEM encoded private key and
base64 encoded public key.
"""
pass
@abc.abstractmethod
def getProjectSecretsKeys(self, connection_name, project_name):
"""Return the private and public secrets keys for the project
A new key will be created if necessary.
:returns: A tuple (private_key, public_key)
"""
pass
class FileKeyStorage(KeyStorage):
log = logging.getLogger("zuul.FileKeyStorage")
current_version = MigrationV1
def __init__(self, root):
self.root = root
migration = self.current_version()
migration.verifyAndUpgrade(root)
def getProjectSecretsKeyFile(self, connection, project, version=None):
"""Return the path to the private key used for the project's secrets"""
# We don't actually support multiple versions yet
if version is None:
version = '0'
return os.path.join(self.root, 'secrets', 'project',
connection, project, version + '.pem')
def getProjectSSHKeyFile(self, connection, project, version=None):
"""Return the path to the private ssh key for the project"""
# We don't actually support multiple versions yet
if version is None:
version = '0'
return os.path.join(self.root, 'ssh', 'project',
connection, project, version + '.pem')
def hasProjectSSHKeys(self, connection_name, project_name):
return os.path.isfile(
self.getProjectSSHKeyFile(connection_name, project_name))
def setProjectSSHKey(self, connection_name, project_name, private_key):
private_key_file = self.getProjectSSHKeyFile(connection_name,
project_name)
key_dir = os.path.dirname(private_key_file)
if not os.path.isdir(key_dir):
os.makedirs(key_dir, 0o700)
private_key.write_private_key_file(private_key_file)
return private_key_file
def getProjectSSHKeys(self, connection_name, project_name):
private_key_file = self._ensureSSHKeyFile(connection_name,
project_name)
key = paramiko.RSAKey.from_private_key_file(private_key_file)
with open(private_key_file, 'r') as f:
private_key = f.read()
public_key = key.get_base64()
return (private_key, 'ssh-rsa ' + public_key)
def _ensureSSHKeyFile(self, connection_name, project_name):
private_key_file = self.getProjectSSHKeyFile(connection_name,
project_name)
if os.path.exists(private_key_file):
return private_key_file
self.log.info("Generating SSH public key for project %s", project_name)
pk = paramiko.RSAKey.generate(bits=RSA_KEY_SIZE)
return self.setProjectSSHKey(connection_name, project_name, pk)
def hasProjectSecretsKeys(self, connection_name, project_name):
return os.path.isfile(
self.getProjectSecretsKeyFile(connection_name, project_name))
def setProjectSecretsKey(self, connection_name, project_name, private_key):
filename = self.getProjectSecretsKeyFile(connection_name, project_name)
key_dir = os.path.dirname(filename)
if not os.path.isdir(key_dir):
os.makedirs(key_dir, 0o700)
# Dump keys to filesystem. We only save the private key
# because the public key can be constructed from it.
self.log.info("Saving RSA keypair for project %s to %s",
project_name, filename)
pem_private_key = encryption.serialize_rsa_private_key(private_key)
# Ensure private key is read/write for zuul user only.
with open(os.open(filename,
os.O_CREAT | os.O_WRONLY, 0o600), 'wb') as f:
f.write(pem_private_key)
return filename
def getProjectSecretsKeys(self, connection_name, project_name):
private_key_file = self._ensureSecretsKeyFile(connection_name,
project_name)
# Load keypair
with open(private_key_file, "rb") as f:
return encryption.deserialize_rsa_keypair(f.read())
def _ensureSecretsKeyFile(self, connection_name, project_name):
filename = self.getProjectSecretsKeyFile(connection_name, project_name)
if os.path.isfile(filename):
return filename
self.log.info("Generating RSA keypair for project %s", project_name)
private_key, public_key = encryption.generate_rsa_keypair()
return self.setProjectSecretsKey(connection_name, project_name,
private_key)
class ZooKeeperKeyStorage(ZooKeeperBase, KeyStorage):
log = logging.getLogger("zuul.ZooKeeperKeyStorage")
SECRETS_PATH = "/keystorage/{}/{}/secrets"
SSH_PATH = "/keystorage/{}/{}/ssh"
def __init__(self, zookeeper_client, password, backup=None):
super().__init__(zookeeper_client)
self.password = password
self.password_bytes = password.encode("utf-8")
self.backup = backup
def getProjectSSHKeys(self, connection_name, project_name):
key_path = self.SSH_PATH.format(connection_name, project_name)
try:
key = self._getSSHKey(key_path)
except kazoo.exceptions.NoNodeError:
self.log.debug("Could not find existing SSH key for %s/%s",
connection_name, project_name)
if self.backup and self.backup.hasProjectSSHKeys(
connection_name, project_name
):
self.log.info("Using SSH key for %s/%s from backup key store",
connection_name, project_name)
pk, _ = self.backup.getProjectSSHKeys(connection_name,
project_name)
with io.StringIO(pk) as o:
key = paramiko.RSAKey.from_private_key(o)
else:
self.log.info("Generating a new SSH key for %s/%s",
connection_name, project_name)
key = paramiko.RSAKey.generate(bits=RSA_KEY_SIZE)
try:
self._storeSSHKey(key_path, key)
except kazoo.exceptions.NodeExistsError:
# Handle race condition between multiple schedulers
# creating the same SSH key.
key = self._getSSHKey(key_path)
# Make sure the SSH key is also stored in the backup keystore
if self.backup:
self.backup.setProjectSSHKey(connection_name, project_name, key)
with io.StringIO() as o:
key.write_private_key(o)
private_key = o.getvalue()
public_key = "ssh-rsa {}".format(key.get_base64())
return private_key, public_key
def _getSSHKey(self, key_path):
raw_pk, _ = self.kazoo_client.get(key_path)
encrypted_key = raw_pk.decode("utf-8")
with io.StringIO(encrypted_key) as o:
return paramiko.RSAKey.from_private_key(o, self.password)
def _storeSSHKey(self, key_path, key):
with io.StringIO() as o:
key.write_private_key(o, self.password)
private_key = o.getvalue()
raw_pk = private_key.encode("utf-8")
self.kazoo_client.create(key_path, value=raw_pk, makepath=True)
def getProjectSecretsKeys(self, connection_name, project_name):
key_path = self.SECRETS_PATH.format(connection_name, project_name)
try:
pem_private_key, _ = self._getSecretsKeys(key_path)
except kazoo.exceptions.NoNodeError:
self.log.debug("Could not find existing secrets key for %s/%s",
connection_name, project_name)
if self.backup and self.backup.hasProjectSecretsKeys(
connection_name, project_name):
self.log.info(
"Using secrets key for %s/%s from backup key store",
connection_name, project_name)
private_key, public_key = self.backup.getProjectSecretsKeys(
connection_name, project_name)
else:
self.log.info("Generating a new secrets key for %s/%s",
connection_name, project_name)
private_key, public_key = encryption.generate_rsa_keypair()
pem_private_key = encryption.serialize_rsa_private_key(
private_key, self.password_bytes)
pem_public_key = encryption.serialize_rsa_public_key(public_key)
try:
self._storeSecretsKeys(key_path, pem_private_key,
pem_public_key)
except kazoo.exceptions.NodeExistsError:
# Handle race condition between multiple schedulers
# creating the same secrets key.
pem_private_key, _ = self._getSecretsKeys(key_path)
private_key, public_key = encryption.deserialize_rsa_keypair(
pem_private_key, self.password_bytes)
# Make sure the private key is also stored in the backup keystore
if self.backup:
self.backup.setProjectSecretsKey(connection_name, project_name,
private_key)
return private_key, public_key
def _getSecretsKeys(self, key_path):
data, _ = self.kazoo_client.get(key_path)
keys = json.loads(data)
return (
keys["private_key"].encode("utf-8"),
keys["public_key"].encode("utf-8"),
)
def _storeSecretsKeys(self, key_path, private_key, public_key):
keys = {
"private_key": private_key.decode("utf-8"),
"public_key": public_key.decode("utf-8"),
}
data = json.dumps(keys).encode("utf-8")
self.kazoo_client.create(key_path, value=data, makepath=True)