379 lines
14 KiB
Python
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)
|