452 lines
14 KiB
Python
452 lines
14 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2014 Google LLC. All Rights Reserved.
|
|
#
|
|
# 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.
|
|
"""Utility functions for managing customer supplied encryption keys."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import abc
|
|
import base64
|
|
import json
|
|
import re
|
|
|
|
from googlecloudsdk.api_lib.compute import exceptions
|
|
from googlecloudsdk.core import resources
|
|
from googlecloudsdk.core.console import console_io
|
|
import six
|
|
|
|
CSEK_HELP_URL = ('https://cloud.google.com/compute/docs/disks/'
|
|
'customer-supplied-encryption')
|
|
EXPECTED_RECORD_KEY_KEYS = {'uri', 'key', 'key-type'}
|
|
BASE64_RAW_KEY_LENGTH_IN_CHARS = 44
|
|
BASE64_RSA_ENCRYPTED_KEY_LENGTH_IN_CHARS = 344
|
|
|
|
|
|
class Error(exceptions.Error):
|
|
"""Base exception for Csek(customer supplied encryption keys) exceptions."""
|
|
|
|
|
|
class InvalidKeyFileException(Error):
|
|
"""There's a problem in a CSEK file."""
|
|
|
|
def __init__(self, base_message):
|
|
super(InvalidKeyFileException, self).__init__(
|
|
'{0}\nFor information on proper key file format see: '
|
|
'https://cloud.google.com/compute/docs/disks/'
|
|
'customer-supplied-encryption#key_file'.format(base_message))
|
|
|
|
|
|
class BadPatternException(InvalidKeyFileException):
|
|
"""A (e.g.) url pattern is bad and why."""
|
|
|
|
def __init__(self, pattern_type, pattern):
|
|
self.pattern_type = pattern_type
|
|
self.pattern = pattern
|
|
super(BadPatternException, self).__init__(
|
|
'Invalid value for [{0}] pattern: [{1}]'.format(
|
|
self.pattern_type,
|
|
self.pattern))
|
|
|
|
|
|
class InvalidKeyExceptionNoContext(InvalidKeyFileException):
|
|
"""Indicate that a particular key is bad and why."""
|
|
|
|
def __init__(self, key, issue):
|
|
self.key = key
|
|
self.issue = issue
|
|
super(InvalidKeyExceptionNoContext, self).__init__(
|
|
'Invalid key, [{0}] : {1}'.format(
|
|
self.key,
|
|
self.issue))
|
|
|
|
|
|
class InvalidKeyException(InvalidKeyFileException):
|
|
"""Indicate that a particular key is bad, why, and where."""
|
|
|
|
def __init__(self, key, key_id, issue):
|
|
self.key = key
|
|
self.key_id = key_id
|
|
self.issue = issue
|
|
super(InvalidKeyException, self).__init__(
|
|
'Invalid key, [{0}], for [{1}]: {2}'.format(
|
|
self.key,
|
|
self.key_id,
|
|
self.issue))
|
|
|
|
|
|
def ValidateKey(base64_encoded_string, expected_key_length):
|
|
"""ValidateKey(s, k) returns None or raises InvalidKeyExceptionNoContext."""
|
|
|
|
if expected_key_length < 1:
|
|
raise ValueError('ValidateKey requires expected_key_length > 1. Got {0}'
|
|
.format(expected_key_length))
|
|
|
|
if len(base64_encoded_string) != expected_key_length:
|
|
raise InvalidKeyExceptionNoContext(
|
|
base64_encoded_string,
|
|
'Key should contain {0} characters (including padding), '
|
|
'but is [{1}] characters long.'.format(
|
|
expected_key_length,
|
|
len(base64_encoded_string)))
|
|
|
|
if base64_encoded_string[-1] != '=':
|
|
raise InvalidKeyExceptionNoContext(
|
|
base64_encoded_string,
|
|
'Bad padding. Keys should end with an \'=\' character.')
|
|
|
|
try:
|
|
base64_encoded_string_as_str = base64_encoded_string.encode('ascii')
|
|
except UnicodeDecodeError:
|
|
raise InvalidKeyExceptionNoContext(
|
|
base64_encoded_string,
|
|
'Key contains non-ascii characters.')
|
|
|
|
if not re.match(r'^[a-zA-Z0-9+/=]*$', base64_encoded_string):
|
|
raise InvalidKeyExceptionNoContext(
|
|
base64_encoded_string_as_str,
|
|
'Key contains unexpected characters. Base64 encoded strings '
|
|
'contain only letters (upper or lower case), numbers, '
|
|
'plusses \'+\', slashes \'/\', or equality signs \'=\'.')
|
|
|
|
try:
|
|
base64.b64decode(base64_encoded_string_as_str)
|
|
except TypeError as t:
|
|
raise InvalidKeyExceptionNoContext(
|
|
base64_encoded_string,
|
|
'Key is not valid base64: [{0}].'.format(t.message))
|
|
|
|
|
|
class CsekKeyBase(six.with_metaclass(abc.ABCMeta, object)):
|
|
"""A class representing for CSEK keys."""
|
|
|
|
def __init__(self, key_material):
|
|
ValidateKey(key_material, expected_key_length=self.GetKeyLength())
|
|
self._key_material = key_material
|
|
|
|
@staticmethod
|
|
def MakeKey(key_material, key_type, allow_rsa_encrypted=False):
|
|
"""Make a CSEK key.
|
|
|
|
Args:
|
|
key_material: str, the key material for this key
|
|
key_type: str, the type of this key
|
|
allow_rsa_encrypted: bool, whether the key is allowed to be RSA-wrapped
|
|
|
|
Returns:
|
|
CsekRawKey or CsekRsaEncryptedKey derived from the given key material and
|
|
type.
|
|
|
|
Raises:
|
|
BadKeyTypeException: if the key is not a valid key type
|
|
"""
|
|
|
|
if key_type == 'raw':
|
|
return CsekRawKey(key_material)
|
|
|
|
if key_type == 'rsa-encrypted':
|
|
if allow_rsa_encrypted:
|
|
return CsekRsaEncryptedKey(key_material)
|
|
raise BadKeyTypeException(
|
|
key_type,
|
|
'this feature is only allowed in the alpha and beta versions of this '
|
|
'command.')
|
|
|
|
raise BadKeyTypeException(key_type)
|
|
|
|
@abc.abstractmethod
|
|
def GetKeyLength(self):
|
|
raise NotImplementedError('GetKeyLength() must be overridden.')
|
|
|
|
@abc.abstractmethod
|
|
def ToMessage(self, compute_client):
|
|
del compute_client
|
|
raise NotImplementedError('ToMessage() must be overridden.')
|
|
|
|
@property
|
|
def key_material(self):
|
|
return self._key_material
|
|
|
|
|
|
class CsekRawKey(CsekKeyBase):
|
|
"""Class representing raw CSEK keys."""
|
|
|
|
def GetKeyLength(self):
|
|
return BASE64_RAW_KEY_LENGTH_IN_CHARS
|
|
|
|
def ToMessage(self, compute_client):
|
|
return compute_client.MESSAGES_MODULE.CustomerEncryptionKey(
|
|
rawKey=str(self.key_material))
|
|
|
|
|
|
class CsekRsaEncryptedKey(CsekKeyBase):
|
|
"""Class representing rsa encrypted CSEK keys."""
|
|
|
|
def GetKeyLength(self):
|
|
return BASE64_RSA_ENCRYPTED_KEY_LENGTH_IN_CHARS
|
|
|
|
def ToMessage(self, compute_client):
|
|
return compute_client.MESSAGES_MODULE.CustomerEncryptionKey(
|
|
rsaEncryptedKey=str(self.key_material))
|
|
|
|
|
|
class BadKeyTypeException(InvalidKeyFileException):
|
|
"""A key type is bad and why."""
|
|
|
|
def __init__(self, key_type, explanation=''):
|
|
self.key_type = key_type
|
|
msg = 'Invalid key type [{0}]'.format(self.key_type)
|
|
if explanation:
|
|
msg += ': ' + explanation
|
|
msg += '.'
|
|
super(BadKeyTypeException, self).__init__(msg)
|
|
|
|
|
|
class MissingCsekException(Error):
|
|
|
|
def __init__(self, resource):
|
|
super(MissingCsekException, self).__init__(
|
|
'Key required for resource [{0}], but none found.'.format(resource))
|
|
|
|
|
|
def AddCsekKeyArgs(parser, flags_about_creation=True, resource_type='resource'):
|
|
"""Adds arguments related to csek keys."""
|
|
parser.add_argument(
|
|
'--csek-key-file',
|
|
metavar='FILE',
|
|
help="""\
|
|
Path to a Customer-Supplied Encryption Key (CSEK) key file that maps
|
|
Compute Engine {resource}s to user managed keys to be used when
|
|
creating, mounting, or taking snapshots of disks.
|
|
|
|
If you pass `-` as value of the flag, the CSEK is read from stdin.
|
|
See {csek_help} for more details.
|
|
""".format(resource=resource_type, csek_help=CSEK_HELP_URL))
|
|
|
|
if flags_about_creation:
|
|
parser.add_argument(
|
|
'--require-csek-key-create',
|
|
action='store_true',
|
|
default=True,
|
|
help="""\
|
|
Refuse to create {resource}s not protected by a user managed key in
|
|
the key file when --csek-key-file is given. This behavior is enabled
|
|
by default to prevent incorrect gcloud invocations from accidentally
|
|
creating {resource}s with no user managed key. Disabling the check
|
|
allows creation of some {resource}s without a matching
|
|
Customer-Supplied Encryption Key in the supplied --csek-key-file.
|
|
See {csek_help} for more details.
|
|
""".format(resource=resource_type, csek_help=CSEK_HELP_URL))
|
|
|
|
|
|
class UriPattern(object):
|
|
"""A uri-based pattern that maybe be matched against resource objects."""
|
|
|
|
def __init__(self, path_as_string):
|
|
if not path_as_string.startswith('http'):
|
|
raise BadPatternException('uri', path_as_string)
|
|
self._path_as_string = resources.REGISTRY.ParseURL(
|
|
path_as_string).RelativeName()
|
|
|
|
def Matches(self, resource):
|
|
"""Tests if its argument matches the pattern."""
|
|
return self._path_as_string == resource.RelativeName()
|
|
|
|
def __str__(self):
|
|
return 'Uri Pattern: ' + self._path_as_string
|
|
|
|
|
|
class CsekKeyStore(object):
|
|
"""Represents a map from resource patterns to keys."""
|
|
|
|
# Members
|
|
# self._state: dictionary from UriPattern to an instance of (a subclass of)
|
|
# CsekKeyBase
|
|
|
|
@classmethod
|
|
def FromFile(cls, fname, allow_rsa_encrypted):
|
|
"""FromFile loads a CsekKeyStore from a file.
|
|
|
|
Args:
|
|
fname: str, the name of a file intended to contain a well-formed key file
|
|
allow_rsa_encrypted: bool, whether to allow keys of type 'rsa-encrypted'
|
|
|
|
Returns:
|
|
A CsekKeyStore, if found
|
|
|
|
Raises:
|
|
googlecloudsdk.core.util.files.Error: If the file cannot be read or is
|
|
larger than max_bytes.
|
|
"""
|
|
|
|
content = console_io.ReadFromFileOrStdin(fname, binary=False)
|
|
return cls(content, allow_rsa_encrypted)
|
|
|
|
@staticmethod
|
|
def FromArgs(args, allow_rsa_encrypted=False):
|
|
"""FromFile attempts to load a CsekKeyStore from a command's args.
|
|
|
|
Args:
|
|
args: CLI args with a csek_key_file field set
|
|
allow_rsa_encrypted: bool, whether to allow keys of type 'rsa-encrypted'
|
|
|
|
Returns:
|
|
A CsekKeyStore, if a valid key file name is provided as csek_key_file
|
|
None, if args.csek_key_file is None
|
|
|
|
Raises:
|
|
exceptions.BadFileException: there's a problem reading fname
|
|
exceptions.InvalidKeyFileException: the key file failed to parse
|
|
or was otherwise invalid
|
|
"""
|
|
if args.csek_key_file is None:
|
|
return None
|
|
|
|
return CsekKeyStore.FromFile(args.csek_key_file, allow_rsa_encrypted)
|
|
|
|
@staticmethod
|
|
def _ParseAndValidate(s, allow_rsa_encrypted=False):
|
|
"""_ParseAndValidate(s) inteprets s as a csek key file.
|
|
|
|
Args:
|
|
s: str, an input to parse
|
|
allow_rsa_encrypted: bool, whether to allow RSA-wrapped keys
|
|
|
|
Returns:
|
|
a valid state object
|
|
|
|
Raises:
|
|
InvalidKeyFileException: if the input doesn't parse or is not well-formed.
|
|
"""
|
|
|
|
assert isinstance(s, six.string_types)
|
|
state = {}
|
|
|
|
try:
|
|
records = json.loads(s)
|
|
|
|
if not isinstance(records, list):
|
|
raise InvalidKeyFileException(
|
|
'Key file\'s top-level element must be a JSON list.')
|
|
|
|
for key_record in records:
|
|
if not isinstance(key_record, dict):
|
|
raise InvalidKeyFileException(
|
|
'Key file records must be JSON objects, but [{0}] found.'.format(
|
|
json.dumps(key_record)))
|
|
|
|
if set(key_record.keys()) != EXPECTED_RECORD_KEY_KEYS:
|
|
raise InvalidKeyFileException(
|
|
'Record [{0}] has incorrect json keys; [{1}] expected'.format(
|
|
json.dumps(key_record),
|
|
','.join(EXPECTED_RECORD_KEY_KEYS)))
|
|
|
|
pattern = UriPattern(key_record['uri'])
|
|
|
|
try:
|
|
state[pattern] = CsekKeyBase.MakeKey(
|
|
key_material=key_record['key'], key_type=key_record['key-type'],
|
|
allow_rsa_encrypted=allow_rsa_encrypted)
|
|
except InvalidKeyExceptionNoContext as e:
|
|
raise InvalidKeyException(key=e.key, key_id=pattern, issue=e.issue)
|
|
|
|
except ValueError as e:
|
|
raise InvalidKeyFileException(*e.args)
|
|
|
|
assert isinstance(state, dict)
|
|
return state
|
|
|
|
def __len__(self):
|
|
return len(self.state)
|
|
|
|
def LookupKey(self, resource, raise_if_missing=False):
|
|
"""Search for the unique key corresponding to a given resource.
|
|
|
|
Args:
|
|
resource: the resource to find a key for.
|
|
raise_if_missing: bool, raise an exception if the resource is not found.
|
|
|
|
Returns: CsekKeyBase, corresponding to the resource, or None if not found
|
|
and not raise_if_missing.
|
|
|
|
Raises:
|
|
InvalidKeyFileException: if there are two records matching the resource.
|
|
MissingCsekException: if raise_if_missing and no key is found
|
|
for the provided resource.
|
|
"""
|
|
|
|
assert isinstance(self.state, dict)
|
|
search_state = (None, None)
|
|
|
|
for pat, key in six.iteritems(self.state):
|
|
if pat.Matches(resource):
|
|
if search_state[0]:
|
|
raise InvalidKeyFileException(
|
|
'Uri patterns [{0}] and [{1}] both match '
|
|
'resource [{2}]. Bailing out.'.format(
|
|
search_state[0], pat, str(resource)))
|
|
|
|
search_state = (pat, key)
|
|
|
|
if raise_if_missing and (search_state[1] is None):
|
|
raise MissingCsekException(resource)
|
|
|
|
return search_state[1]
|
|
|
|
def __init__(self, json_string, allow_rsa_encrypted=False):
|
|
self.state = CsekKeyStore._ParseAndValidate(json_string,
|
|
allow_rsa_encrypted)
|
|
|
|
|
|
# Functions below make it easy for clients to operate on values that possibly
|
|
# either CsekKeyStores or None or else CsekKeyBases or None. Fellow functional
|
|
# programming geeks: basically we're faking the Maybe monad.
|
|
def MaybeToMessage(csek_key_or_none, compute):
|
|
return csek_key_or_none.ToMessage(compute) if csek_key_or_none else None
|
|
|
|
|
|
def MaybeLookupKey(csek_keys_or_none, resource):
|
|
if csek_keys_or_none and resource:
|
|
return csek_keys_or_none.LookupKey(resource)
|
|
|
|
return None
|
|
|
|
|
|
def MaybeLookupKeyMessage(csek_keys_or_none, resource, compute_client):
|
|
maybe_key = MaybeLookupKey(csek_keys_or_none, resource)
|
|
return MaybeToMessage(maybe_key, compute_client)
|
|
|
|
|
|
def MaybeLookupKeys(csek_keys_or_none, resource_collection):
|
|
return [MaybeLookupKey(csek_keys_or_none, r) for r in resource_collection]
|
|
|
|
|
|
def MaybeLookupKeyMessages(
|
|
csek_keys_or_none, resource_collection, compute_client):
|
|
return [MaybeToMessage(k, compute_client) for k in
|
|
MaybeLookupKeys(csek_keys_or_none, resource_collection)]
|
|
|
|
|
|
def MaybeLookupKeysByUri(csek_keys_or_none, parser, uris):
|
|
return MaybeLookupKeys(
|
|
csek_keys_or_none,
|
|
[(parser.Parse(u) if u else None) for u in uris])
|
|
|
|
|
|
def MaybeLookupKeyMessagesByUri(csek_keys_or_none, parser,
|
|
uris, compute_client):
|
|
return [MaybeToMessage(k, compute_client) for k in
|
|
MaybeLookupKeysByUri(csek_keys_or_none, parser, uris)]
|