Skip to content

Commit 22bde40

Browse files
authored
Fix handling of SSE-C keys when copying unencrypted to encrypted objects or objects with different keys (#10175)
1 parent a2f5a78 commit 22bde40

6 files changed

Lines changed: 386 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"type": "bugfix",
3+
"category": "``s3``",
4+
"description": "Fix handling of SSE-C keys when copying unencrypted to encrypted objects or objects with different encryption keys"
5+
}

awscli/customizations/s3/subcommands.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,14 +1287,15 @@ def _map_sse_c_params(self, request_parameters, paths_type):
12871287
# SSE-C key and algorithm. Note the reverse FileGenerator does
12881288
# not need any of these because it is used only for sync operations
12891289
# which only use ListObjects which does not require HeadObject.
1290-
RequestParamsMapper.map_head_object_params(
1291-
request_parameters['HeadObject'], self.parameters)
12921290
if paths_type == 's3s3':
1291+
RequestParamsMapper.map_head_object_params_with_copy_source_sse(
1292+
request_parameters['HeadObject'],
1293+
self.parameters,
1294+
)
1295+
else:
12931296
RequestParamsMapper.map_head_object_params(
1294-
request_parameters['HeadObject'], {
1295-
'sse_c': self.parameters.get('sse_c_copy_source'),
1296-
'sse_c_key': self.parameters.get('sse_c_copy_source_key')
1297-
}
1297+
request_parameters['HeadObject'],
1298+
self.parameters,
12981299
)
12991300

13001301
def _should_handle_case_conflicts(self):

awscli/customizations/s3/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,19 @@ def map_head_object_params(cls, request_params, cli_params):
502502
cls._set_sse_c_request_params(request_params, cli_params)
503503
cls._set_request_payer_param(request_params, cli_params)
504504

505+
@classmethod
506+
def map_head_object_params_with_copy_source_sse(
507+
cls, request_params, cli_params
508+
):
509+
"""
510+
Map CLI params to HeadObject request params,
511+
using the SSE-C headers from the copy source
512+
"""
513+
cls._set_sse_c_request_params_with_copy_source_sse(
514+
request_params, cli_params
515+
)
516+
cls._set_request_payer_param(request_params, cli_params)
517+
505518
@classmethod
506519
def map_create_multipart_upload_params(cls, request_params, cli_params):
507520
"""Map CLI params to CreateMultipartUpload request params"""
@@ -623,6 +636,18 @@ def _set_sse_c_request_params(cls, request_params, cli_params):
623636
request_params['SSECustomerAlgorithm'] = cli_params['sse_c']
624637
request_params['SSECustomerKey'] = cli_params['sse_c_key']
625638

639+
@classmethod
640+
def _set_sse_c_request_params_with_copy_source_sse(
641+
cls, request_params, cli_params
642+
):
643+
if cli_params.get('sse_c_copy_source'):
644+
request_params['SSECustomerAlgorithm'] = cli_params[
645+
'sse_c_copy_source'
646+
]
647+
request_params['SSECustomerKey'] = cli_params[
648+
'sse_c_copy_source_key'
649+
]
650+
626651
@classmethod
627652
def _set_sse_c_copy_source_request_params(cls, request_params, cli_params):
628653
if cli_params.get('sse_c_copy_source'):

tests/functional/s3/__init__.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,19 @@ def head_object_response(self, **override_kwargs):
3939
response.update(override_kwargs)
4040
return response
4141

42-
def list_objects_response(self, keys):
42+
def list_objects_response(self, keys, **override_kwargs):
4343
contents = []
4444
for key in keys:
45-
contents.append(
46-
{
47-
'Key': key,
48-
'LastModified': '00:00:00Z',
49-
'Size': 100,
50-
'ETag': '"foo-1"',
51-
}
52-
)
53-
54-
return {
55-
'Contents': contents,
56-
'CommonPrefixes': []
57-
}
45+
content = {
46+
'Key': key,
47+
'LastModified': '00:00:00Z',
48+
'Size': 100,
49+
'ETag': '"foo-1"',
50+
}
51+
if override_kwargs:
52+
content.update(override_kwargs)
53+
contents.append(content)
54+
return {'Contents': contents, 'CommonPrefixes': []}
5855

5956
def get_object_response(self):
6057
return {
@@ -182,3 +179,10 @@ def complete_mpu_request(self, bucket, key, upload_id, num_parts,
182179
}
183180
params.update(override_kwargs)
184181
return 'CompleteMultipartUpload', params
182+
183+
def mp_copy_responses(self):
184+
return [
185+
self.create_mpu_response('upload_id'),
186+
self.upload_part_copy_response(),
187+
self.complete_mpu_response(),
188+
]

tests/functional/s3/test_cp_command.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from tests.functional.s3.test_sync_command import TestSyncCaseConflict
2222
from tests import requires_crt
2323

24+
MB = 1024**2
2425

2526
class BufferedBytesIO(BytesIO):
2627
@property
@@ -33,6 +34,10 @@ class BaseCPCommandTest(BaseS3TransferCommandTest):
3334

3435

3536
class TestCPCommand(BaseCPCommandTest):
37+
def setUp(self):
38+
super().setUp()
39+
self.multipart_threshold = 8 * MB
40+
3641
def test_operations_used_in_upload(self):
3742
full_path = self.files.create_file('foo.txt', 'mycontent')
3843
cmdline = '%s %s s3://bucket/key.txt' % (self.prefix, full_path)
@@ -558,6 +563,13 @@ def test_cp_with_sse_c_copy_source_fileb(self):
558563
self.assertEqual(len(self.operations_called), 2)
559564
self.assertEqual(self.operations_called[0][0].name, 'HeadObject')
560565
self.assertEqual(self.operations_called[1][0].name, 'CopyObject')
566+
expected_head_args = {
567+
'Bucket': 'bucket-one',
568+
'Key': 'key.txt',
569+
'SSECustomerAlgorithm': 'AES256',
570+
'SSECustomerKey': key_contents,
571+
}
572+
self.assertDictEqual(self.operations_called[0][1], expected_head_args)
561573

562574
expected_args = {
563575
'Key': 'key.txt', 'Bucket': 'bucket',
@@ -571,6 +583,175 @@ def test_cp_with_sse_c_copy_source_fileb(self):
571583
}
572584
self.assertDictEqual(self.operations_called[1][1], expected_args)
573585

586+
def test_s3s3_cp_with_destination_sse_c(self):
587+
"""S3->S3 copy with an unencrypted source and encrypted destination"""
588+
self.parsed_responses = [
589+
self.head_object_response(),
590+
self.copy_object_response(),
591+
]
592+
cmdline = (
593+
'%s s3://bucket-one/key.txt s3://bucket/key.txt '
594+
'--sse-c --sse-c-key destination-key' % self.prefix
595+
)
596+
self.run_cmd(cmdline, expected_rc=0)
597+
self.assertEqual(len(self.operations_called), 2)
598+
self.assertEqual(self.operations_called[0][0].name, 'HeadObject')
599+
expected_head_args = {
600+
'Bucket': 'bucket-one',
601+
'Key': 'key.txt',
602+
# don't expect to see SSE-c params for the source
603+
}
604+
self.assertDictEqual(self.operations_called[0][1], expected_head_args)
605+
606+
self.assertEqual(self.operations_called[1][0].name, 'CopyObject')
607+
expected_copy_args = {
608+
'Key': 'key.txt',
609+
'Bucket': 'bucket',
610+
'ContentType': 'text/plain',
611+
'CopySource': {'Bucket': 'bucket-one', 'Key': 'key.txt'},
612+
'SSECustomerAlgorithm': 'AES256',
613+
'SSECustomerKey': 'destination-key',
614+
}
615+
self.assertDictEqual(self.operations_called[1][1], expected_copy_args)
616+
617+
def test_s3s3_cp_with_different_sse_c_keys(self):
618+
"""S3->S3 copy with different SSE-C keys for source and destination"""
619+
self.parsed_responses = [
620+
self.head_object_response(),
621+
self.copy_object_response(),
622+
]
623+
cmdline = (
624+
'%s s3://bucket-one/key.txt s3://bucket/key.txt '
625+
'--sse-c-copy-source --sse-c-copy-source-key foo --sse-c --sse-c-key bar'
626+
% self.prefix
627+
)
628+
self.run_cmd(cmdline, expected_rc=0)
629+
self.assertEqual(len(self.operations_called), 2)
630+
self.assertEqual(self.operations_called[0][0].name, 'HeadObject')
631+
expected_head_args = {
632+
'Bucket': 'bucket-one',
633+
'Key': 'key.txt',
634+
'SSECustomerAlgorithm': 'AES256',
635+
'SSECustomerKey': 'foo',
636+
}
637+
self.assertDictEqual(self.operations_called[0][1], expected_head_args)
638+
639+
self.assertEqual(self.operations_called[1][0].name, 'CopyObject')
640+
expected_copy_args = {
641+
'Key': 'key.txt',
642+
'Bucket': 'bucket',
643+
'ContentType': 'text/plain',
644+
'CopySource': {'Bucket': 'bucket-one', 'Key': 'key.txt'},
645+
'SSECustomerAlgorithm': 'AES256',
646+
'SSECustomerKey': 'bar',
647+
'CopySourceSSECustomerAlgorithm': 'AES256',
648+
'CopySourceSSECustomerKey': 'foo',
649+
}
650+
self.assertDictEqual(self.operations_called[1][1], expected_copy_args)
651+
652+
def test_s3s3_cp_with_destination_sse_c_multipart(self):
653+
"""S3->S3 multipart copy with unencrypted source and encrypted destination"""
654+
self.parsed_responses = [
655+
self.head_object_response(ContentLength=self.multipart_threshold),
656+
self.create_mpu_response('upload_id'),
657+
self.upload_part_copy_response(),
658+
self.complete_mpu_response(),
659+
]
660+
cmdline = (
661+
'%s s3://bucket-one/key.txt s3://bucket/key.txt '
662+
'--sse-c --sse-c-key destination-key' % self.prefix
663+
)
664+
self.run_cmd(cmdline, expected_rc=0)
665+
self.assert_operations_called(
666+
[
667+
self.head_object_request(
668+
'bucket-one',
669+
'key.txt',
670+
# no SSE-C params — source is unencrypted
671+
),
672+
self.create_mpu_request(
673+
'bucket',
674+
'key.txt',
675+
ContentType='text/plain',
676+
SSECustomerAlgorithm='AES256',
677+
SSECustomerKey='destination-key',
678+
),
679+
self.upload_part_copy_request(
680+
'bucket-one',
681+
'key.txt',
682+
'bucket',
683+
'key.txt',
684+
'upload_id',
685+
PartNumber=mock.ANY,
686+
CopySourceRange=mock.ANY,
687+
CopySourceIfMatch='"foo-1"',
688+
SSECustomerAlgorithm='AES256',
689+
SSECustomerKey='destination-key',
690+
),
691+
self.complete_mpu_request(
692+
'bucket',
693+
'key.txt',
694+
'upload_id',
695+
num_parts=1,
696+
SSECustomerAlgorithm='AES256',
697+
SSECustomerKey='destination-key',
698+
),
699+
]
700+
)
701+
702+
def test_s3s3_cp_with_different_sse_c_keys_multipart(self):
703+
"""S3->S3 multipart copy with different SSE-C keys"""
704+
self.parsed_responses = [
705+
self.head_object_response(ContentLength=self.multipart_threshold),
706+
self.create_mpu_response('upload_id'),
707+
self.upload_part_copy_response(),
708+
self.complete_mpu_response(),
709+
]
710+
cmdline = (
711+
'%s s3://bucket-one/key.txt s3://bucket/key.txt '
712+
'--sse-c-copy-source --sse-c-copy-source-key source-key --sse-c --sse-c-key destination-key'
713+
% self.prefix
714+
)
715+
self.run_cmd(cmdline, expected_rc=0)
716+
self.assert_operations_called(
717+
[
718+
self.head_object_request(
719+
'bucket-one',
720+
'key.txt',
721+
SSECustomerAlgorithm='AES256',
722+
SSECustomerKey='source-key',
723+
),
724+
self.create_mpu_request(
725+
'bucket',
726+
'key.txt',
727+
ContentType='text/plain',
728+
SSECustomerAlgorithm='AES256',
729+
SSECustomerKey='destination-key',
730+
),
731+
self.upload_part_copy_request(
732+
'bucket-one',
733+
'key.txt',
734+
'bucket',
735+
'key.txt',
736+
'upload_id',
737+
PartNumber=mock.ANY,
738+
CopySourceRange=mock.ANY,
739+
CopySourceIfMatch='"foo-1"',
740+
SSECustomerAlgorithm='AES256',
741+
SSECustomerKey='destination-key',
742+
CopySourceSSECustomerAlgorithm='AES256',
743+
CopySourceSSECustomerKey='source-key',
744+
),
745+
self.complete_mpu_request(
746+
'bucket',
747+
'key.txt',
748+
'upload_id',
749+
num_parts=1,
750+
SSECustomerAlgorithm='AES256',
751+
SSECustomerKey='destination-key',
752+
),
753+
]
754+
)
574755

575756
# Note ideally the kms sse with a key id would be integration tests
576757
# However, you cannot delete kms keys so there would be no way to clean

0 commit comments

Comments
 (0)