255 lines
9.6 KiB
Python
255 lines
9.6 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2023 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.
|
|
"""Decrypt a ciphertext file using a raw key."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
from apitools.base.py import exceptions as apitools_exceptions
|
|
from googlecloudsdk.api_lib.cloudkms import base as cloudkms_base
|
|
from googlecloudsdk.calliope import base
|
|
from googlecloudsdk.calliope import exceptions
|
|
from googlecloudsdk.command_lib.kms import crc32c
|
|
from googlecloudsdk.command_lib.kms import e2e_integrity
|
|
from googlecloudsdk.command_lib.kms import flags
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core.console import console_io
|
|
from googlecloudsdk.core.util import files
|
|
|
|
CBC_CTR_IV_SIZE = 16
|
|
|
|
|
|
class RawDecrypt(base.Command):
|
|
r"""Decrypt a ciphertext file using a raw key.
|
|
|
|
`{command}` decrypts the given ciphertext file using the given CryptoKey
|
|
containing a raw key and writes the result to the named plaintext file.
|
|
The ciphertext file must not be larger than 64KiB.
|
|
|
|
The supported algorithms are: `AES-128-GCM`, `AES-256-GCM`, `AES-128-CBC`,
|
|
`AES-256-CBC`, `AES-128-CTR`, `and AES-256-CTR`.
|
|
|
|
`AES-GCM` provides authentication which means that it accepts additional
|
|
authenticated data (AAD). So, the flag `--additional-authenticated-data-file`
|
|
is only valid with `AES-128-GCM` and `AES-256-GCM` algorithms. If AAD is
|
|
provided during encryption, it must be provided during decryption too.
|
|
The file must not be larger than 64KiB.
|
|
|
|
If `--plaintext-file` or `--additional-authenticated-data-file` or
|
|
`--initialization-vector-file` is set to '-', that file is read from stdin.
|
|
Similarly, if `--ciphertext-file` is set to '-', the ciphertext is written
|
|
to stdout.
|
|
|
|
By default, the command performs integrity verification on data sent to and
|
|
received from Cloud KMS. Use `--skip-integrity-verification` to disable
|
|
integrity verification.
|
|
|
|
## EXAMPLES
|
|
The following command reads and decrypts the file `path/to/input/ciphertext`.
|
|
The file will be decrypted using the CryptoKey `KEYNAME` containing a raw key,
|
|
from the KeyRing `KEYRING` in the `global` location. It uses the additional
|
|
authenticated data file `path/to/input/aad` (only valid with the `AES-GCM`
|
|
algorithms) and the initialization vector file `path/to/input/iv`.
|
|
The resulting plaintext will be written to `path/to/output/plaintext`.
|
|
|
|
$ {command} \
|
|
--key=KEYNAME \
|
|
--keyring=KEYRING \
|
|
--location=global \
|
|
--ciphertext-file=path/to/input/ciphertext \
|
|
--additional-authenticated-data-file=path/to/input/aad \
|
|
--initialization-vector-file=path/to/input/iv \
|
|
--plaintext-file=path/to/output/plaintext
|
|
"""
|
|
|
|
@staticmethod
|
|
def Args(parser):
|
|
flags.AddKeyResourceFlags(parser, 'The (raw) key to use for decryption.')
|
|
flags.AddCryptoKeyVersionFlag(parser, 'to use for decryption', True)
|
|
flags.AddPlaintextFileFlag(parser, 'to store the decrypted data')
|
|
flags.AddCiphertextFileFlag(parser, 'to decrypt')
|
|
flags.AddIvFileFlag(parser, 'for decryption')
|
|
flags.AddAadFileFlag(parser)
|
|
flags.AddSkipIntegrityVerification(parser)
|
|
|
|
def _ReadFileOrStdin(self, path, max_bytes):
|
|
data = console_io.ReadFromFileOrStdin(path, binary=True)
|
|
if len(data) > max_bytes:
|
|
raise exceptions.BadFileException(
|
|
'The file [{0}] is larger than the maximum size of {1} bytes.'.format(
|
|
path, max_bytes
|
|
)
|
|
)
|
|
return data
|
|
|
|
def _PerformIntegrityVerification(self, args):
|
|
return not args.skip_integrity_verification
|
|
|
|
def _CreateRawDecryptRequest(self, args):
|
|
if args.ciphertext_file == '-' and args.initialization_vector_file == '-':
|
|
raise exceptions.InvalidArgumentException(
|
|
'--ciphertext-file and --initialization-vector-file',
|
|
"both parameters can't be read from stdin.",
|
|
)
|
|
|
|
if (
|
|
args.ciphertext_file == '-'
|
|
and args.additional_authenticated_data_file == '-'
|
|
):
|
|
raise exceptions.InvalidArgumentException(
|
|
'--ciphertext-file and --additional-authenticated-data-file',
|
|
"both parameters can't be read from stdin.",
|
|
)
|
|
|
|
if (
|
|
args.initialization_vector_file == '-'
|
|
and args.additional_authenticated_data_file == '-'
|
|
):
|
|
raise exceptions.InvalidArgumentException(
|
|
'--initialization-vector-file and'
|
|
' --additional-authenticated-data-file',
|
|
"both parameters can't be read from stdin.",
|
|
)
|
|
|
|
try:
|
|
# The Encrypt API has a limit of 64KB; the output ciphertext files will be
|
|
# slightly larger. Check proactively (but generously) to avoid attempting
|
|
# to buffer and send obviously oversized files to KMS.
|
|
ciphertext = self._ReadFileOrStdin(
|
|
args.ciphertext_file, max_bytes=2 * 65536
|
|
)
|
|
except files.Error as e:
|
|
raise exceptions.BadFileException(
|
|
'Failed to read ciphertext file [{0}]: {1}'.format(
|
|
args.ciphertext_file, e
|
|
)
|
|
)
|
|
|
|
try:
|
|
# The RawDecrypt API limits the IV to 16B.
|
|
iv = self._ReadFileOrStdin(
|
|
args.initialization_vector_file, max_bytes=CBC_CTR_IV_SIZE
|
|
)
|
|
except files.Error as e:
|
|
raise exceptions.BadFileException(
|
|
'Failed to read initialization vector file [{0}]: {1}'.format(
|
|
args.initialization_vector_file, e
|
|
)
|
|
)
|
|
|
|
if len(iv) != CBC_CTR_IV_SIZE:
|
|
raise exceptions.BadFileException(
|
|
'--initialization-vector-file',
|
|
'the IV size must be {0} bytes.'.format(CBC_CTR_IV_SIZE),
|
|
)
|
|
|
|
aad = b''
|
|
if args.additional_authenticated_data_file:
|
|
try:
|
|
# The RawDecrypt API limits the AAD to 64KiB.
|
|
aad = self._ReadFileOrStdin(
|
|
args.additional_authenticated_data_file, max_bytes=65536
|
|
)
|
|
except files.Error as e:
|
|
raise exceptions.BadFileException(
|
|
'Failed to read additional authenticated data file [{0}]: {1}'
|
|
.format(args.additional_authenticated_data_file, e)
|
|
)
|
|
|
|
crypto_key_ref = flags.ParseCryptoKeyVersionName(args)
|
|
messages = cloudkms_base.GetMessagesModule()
|
|
request = messages.CloudkmsProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersionsRawDecryptRequest( # pylint: disable=line-too-long
|
|
name=crypto_key_ref.RelativeName()
|
|
)
|
|
|
|
# Populate request integrity fields.
|
|
if self._PerformIntegrityVerification(args):
|
|
ciphertext_crc32c = crc32c.Crc32c(ciphertext)
|
|
iv_crc32c = crc32c.Crc32c(iv)
|
|
aad_crc32c = crc32c.Crc32c(aad)
|
|
request.rawDecryptRequest = messages.RawDecryptRequest(
|
|
ciphertext=ciphertext,
|
|
initializationVector=iv,
|
|
additionalAuthenticatedData=aad,
|
|
ciphertextCrc32c=ciphertext_crc32c,
|
|
initializationVectorCrc32c=iv_crc32c,
|
|
additionalAuthenticatedDataCrc32c=aad_crc32c,
|
|
)
|
|
else:
|
|
request.rawDecryptRequest = messages.RawDecryptRequest(
|
|
ciphertext=ciphertext,
|
|
initializationVector=iv,
|
|
additionalAuthenticatedData=aad,
|
|
)
|
|
|
|
return request
|
|
|
|
def _VerifyResponseIntegrityFields(self, resp):
|
|
"""Verifies integrity fields in response."""
|
|
|
|
# plaintext_crc32c was verified server-side.
|
|
if not resp.verifiedCiphertextCrc32c:
|
|
raise e2e_integrity.ClientSideIntegrityVerificationError(
|
|
e2e_integrity.GetRequestToServerCorruptedErrorMessage()
|
|
)
|
|
|
|
# additional_authenticated_data_crc32c was verified server-side.
|
|
if not resp.verifiedAdditionalAuthenticatedDataCrc32c:
|
|
raise e2e_integrity.ClientSideIntegrityVerificationError(
|
|
e2e_integrity.GetRequestToServerCorruptedErrorMessage()
|
|
)
|
|
|
|
# initialization_vector_crc32c was verified server-side.
|
|
if not resp.verifiedInitializationVectorCrc32c:
|
|
raise e2e_integrity.ClientSideIntegrityVerificationError(
|
|
e2e_integrity.GetRequestToServerCorruptedErrorMessage()
|
|
)
|
|
|
|
# Verify decrypted plaintext checksum.
|
|
if not crc32c.Crc32cMatches(resp.plaintext, resp.plaintextCrc32c):
|
|
raise e2e_integrity.ClientSideIntegrityVerificationError(
|
|
e2e_integrity.GetResponseFromServerCorruptedErrorMessage()
|
|
)
|
|
|
|
def Run(self, args):
|
|
response = None
|
|
request = self._CreateRawDecryptRequest(args)
|
|
client = cloudkms_base.GetClientInstance()
|
|
try:
|
|
response = client.projects_locations_keyRings_cryptoKeys_cryptoKeyVersions.RawDecrypt(
|
|
request
|
|
) # pylint: disable=line-too-long
|
|
# Intercept INVALID_ARGUMENT errors related to checksum verification to
|
|
# present a user-friendly message. All other errors are surfaced as-is.
|
|
except apitools_exceptions.HttpBadRequestError as error:
|
|
e2e_integrity.ProcessHttpBadRequestError(error)
|
|
|
|
if self._PerformIntegrityVerification(args):
|
|
self._VerifyResponseIntegrityFields(response)
|
|
|
|
try:
|
|
if response.plaintext is None:
|
|
with files.FileWriter(args.plaintext_file):
|
|
# to create an empty file
|
|
pass
|
|
log.Print('Decrypted file is empty')
|
|
else:
|
|
log.WriteToFileOrStdout(
|
|
args.plaintext_file, response.plaintext, binary=True, overwrite=True
|
|
)
|
|
except files.Error as e:
|
|
raise exceptions.BadFileException(e)
|