Skip to content

Commit 7be517a

Browse files
jhellerDmitriy Rabotyagov
authored andcommitted
Add support for SSE-C encryption
Changes implement 2 new flags --sse-customer-key and --sse-copy-source-customer-key that can be used by user to provide a key for server side encryption. Once these options are set extra headers are added to request accordingly to SSE-C specification [1] We also ensure that object_get respects provided extra_headers This PR squashes and rebases on current master changes implemented by @jheller [1] https://docs.aws.amazon.com/AmazonS3/latest/userguide/specifying-s3-c-encryption.html
1 parent d705dcd commit 7be517a

File tree

8 files changed

+175
-33
lines changed

8 files changed

+175
-33
lines changed

S3/Config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ class Config(object):
132132
extra_headers = SortedDict(ignore_case = True)
133133
force = False
134134
server_side_encryption = False
135+
sse_customer_key = ""
136+
sse_copy_source_customer_key = ""
135137
enable = None
136138
get_continue = False
137139
put_continue = False
@@ -293,6 +295,10 @@ def __init__(self, configfile = None, access_key=None, secret_key=None, access_t
293295
warning('Cannot have server_side_encryption (S3 SSE) and KMS_key set (S3 KMS). KMS encryption will be used. Please set server_side_encryption to False')
294296
if self.kms_key and self.signature_v2 == True:
295297
raise Exception('KMS encryption requires signature v4. Please set signature_v2 to False')
298+
if self.sse_customer_key and len(self.sse_customer_key) != 32:
299+
raise Exception('sse-customer-key must be 32 characters')
300+
if self.sse_copy_source_customer_key and len(self.sse_copy_source_customer_key) != 32:
301+
raise Exception('sse_copy_source_customer_key must be 32 characters')
296302

297303
def role_config(self):
298304
"""

S3/FileDict.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def get_md5(self, relative_file):
4343
if 'md5' in self[relative_file]:
4444
return self[relative_file]['md5']
4545
md5 = self.get_hardlink_md5(relative_file)
46-
if md5 is None and 'md5' in cfg.sync_checks:
46+
if md5 is None and 'md5' in cfg.preserve_attrs_list:
4747
logging.debug(u"doing file I/O to read md5 of %s" % relative_file)
4848
md5 = Utils.hash_file_md5(self[relative_file]['full_name'])
4949
self.record_md5(relative_file, md5)

S3/FileLists.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,23 @@ def _compare(src_list, dst_lst, src_remote, dst_remote, file):
552552
attribs_match = False
553553
debug(u"XFER: %s (md5 mismatch: src=%s dst=%s)" % (file, src_md5, dst_md5))
554554

555+
# Check mtime. This compares local mtime to the upload time of remote file
556+
compare_mtime = 'mtime' in cfg.sync_checks
557+
if attribs_match and compare_mtime:
558+
try:
559+
src_mtime = src_list[file]['mtime']
560+
dst_mtime = dst_list[file]['timestamp']
561+
except (IOError,OSError):
562+
# mtime sum verification failed - ignore that file altogether
563+
debug(u"IGNR: %s (disappeared)" % (file))
564+
warning(u"%s: file disappeared, ignoring." % (file))
565+
raise
566+
567+
if src_mtime > dst_mtime:
568+
## checksums are different.
569+
attribs_match = False
570+
debug(u"XFER: %s (mtime newer than last upload: src=%s dst=%s)" % (file, src_mtime, dst_mtime))
571+
555572
return attribs_match
556573

557574
# we don't support local->local sync, use 'rsync' or something like that instead ;-)

S3/S3.py

Lines changed: 89 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@
88

99
from __future__ import absolute_import, division
1010

11-
import sys
12-
import os
13-
import time
11+
import base64
1412
import errno
15-
import mimetypes
13+
import hashlib
1614
import io
15+
import mimetypes
16+
import os
1717
import pprint
18-
from xml.sax import saxutils
18+
import sys
19+
import time
20+
from logging import debug, error, info, warning
1921
from socket import timeout as SocketTimeoutException
20-
from logging import debug, info, warning, error
2122
from stat import ST_SIZE
23+
from xml.sax import saxutils
24+
2225
try:
2326
# python 3 support
2427
from urlparse import urlparse
@@ -38,26 +41,27 @@
3841
except ImportError:
3942
from md5 import md5
4043

41-
from .BaseUtils import (getListFromXml, getTextFromXml, getRootTagName,
42-
decode_from_s3, encode_to_s3, s3_quote)
43-
from .Utils import (convertHeaderTupleListToDict, hash_file_md5, unicodise,
44-
deunicodise, check_bucket_name,
45-
check_bucket_name_dns_support, getHostnameFromBucket,
46-
calculateChecksum)
47-
from .SortedDict import SortedDict
4844
from .AccessLog import AccessLog
4945
from .ACL import ACL, GranteeLogDelivery
46+
from .BaseUtils import (decode_from_s3, encode_to_s3, getListFromXml,
47+
getRootTagName, getTextFromXml, s3_quote)
5048
from .BidirMap import BidirMap
5149
from .Config import Config
50+
from .ConnMan import ConnMan
51+
from .Crypto import (checksum_sha256_buffer, checksum_sha256_file,
52+
format_param_str, sign_request_v2, sign_request_v4)
5253
from .Exceptions import *
5354
from .MultiPart import MultiPartUpload
5455
from .S3Uri import S3Uri
55-
from .ConnMan import ConnMan
56-
from .Crypto import (sign_request_v2, sign_request_v4, checksum_sha256_file,
57-
checksum_sha256_buffer, format_param_str)
56+
from .SortedDict import SortedDict
57+
from .Utils import (calculateChecksum, check_bucket_name,
58+
check_bucket_name_dns_support,
59+
convertHeaderTupleListToDict, deunicodise,
60+
getHostnameFromBucket, hash_file_md5, unicodise)
5861

5962
try:
6063
from ctypes import ArgumentError
64+
6165
import magic
6266
try:
6367
## https://github.com/ahupp/python-magic
@@ -701,6 +705,16 @@ def object_put(self, filename, uri, extra_headers = None, extra_label = ""):
701705
headers['x-amz-server-side-encryption'] = 'aws:kms'
702706
headers['x-amz-server-side-encryption-aws-kms-key-id'] = self.config.kms_key
703707

708+
if self.config.sse_customer_key:
709+
md5 = hashlib.md5()
710+
sse_customer_key = self.config.sse_customer_key.encode()
711+
md5.update(sse_customer_key)
712+
md5_encoded = base64.b64encode(md5.digest())
713+
encoded = base64.b64encode(sse_customer_key)
714+
headers["x-amz-server-side-encryption-customer-algorithm"] = "AES256"
715+
headers["x-amz-server-side-encryption-customer-key"] = encoded.decode()
716+
headers["x-amz-server-side-encryption-customer-key-md5"] = md5_encoded.decode()
717+
704718
## MIME-type handling
705719
headers["content-type"] = self.content_type(filename=filename)
706720

@@ -755,10 +769,32 @@ def object_put(self, filename, uri, extra_headers = None, extra_label = ""):
755769
response = self.send_file(request, src_stream, labels)
756770
return response
757771

758-
def object_get(self, uri, stream, dest_name, start_position = 0, extra_label = ""):
772+
def object_get(self, uri, stream, dest_name, extra_headers, start_position = 0, extra_label = ""):
759773
if uri.type != "s3":
760774
raise ValueError("Expected URI type 's3', got '%s'" % uri.type)
761-
request = self.create_request("OBJECT_GET", uri = uri)
775+
headers = SortedDict(ignore_case=True)
776+
if extra_headers:
777+
headers.update(extra_headers)
778+
## Set server side encryption
779+
if self.config.server_side_encryption:
780+
headers["x-amz-server-side-encryption"] = "AES256"
781+
782+
## Set kms headers
783+
if self.config.kms_key:
784+
headers['x-amz-server-side-encryption'] = 'aws:kms'
785+
headers['x-amz-server-side-encryption-aws-kms-key-id'] = self.config.kms_key
786+
787+
if self.config.sse_customer_key:
788+
md5 = hashlib.md5()
789+
sse_customer_key = self.config.sse_customer_key.encode()
790+
md5.update(sse_customer_key)
791+
md5_encoded = base64.b64encode(md5.digest())
792+
encoded = base64.b64encode(sse_customer_key)
793+
headers["x-amz-server-side-encryption-customer-algorithm"] = "AES256"
794+
headers["x-amz-server-side-encryption-customer-key"] = encoded.decode()
795+
headers["x-amz-server-side-encryption-customer-key-md5"] = md5_encoded.decode()
796+
797+
request = self.create_request("OBJECT_GET", uri = uri, headers=headers)
762798
labels = { 'source' : uri.uri(), 'destination' : dest_name, 'extra' : extra_label }
763799
response = self.recv_file(request, stream, labels, start_position)
764800
return response
@@ -954,6 +990,16 @@ def object_copy(self, src_uri, dst_uri, extra_headers=None,
954990
headers['x-amz-server-side-encryption-aws-kms-key-id'] = \
955991
self.config.kms_key
956992

993+
if self.config.sse_copy_source_customer_key:
994+
md5 = hashlib.md5()
995+
sse_copy_source_customer_key = self.config.sse_copy_source_customer_key.encode()
996+
md5.update(sse_copy_source_customer_key)
997+
md5_encoded = base64.b64encode(md5.digest())
998+
encoded = base64.b64encode(sse_copy_source_customer_key)
999+
headers["x-amz-copy-source-server-side-encryption-customer-algorithm"] = "AES256"
1000+
headers["x-amz-copy-source-server-side-encryption-customer-key"] = encoded.decode()
1001+
headers["x-amz-copy-source-server-side-encryption-customer-key-md5"] = md5_encoded.decode()
1002+
9571003
# Following meta data are not updated in simple COPY by aws.
9581004
if extra_headers:
9591005
headers.update(extra_headers)
@@ -1828,19 +1874,32 @@ def send_file(self, request, stream, labels, buffer = '', throttle = 0,
18281874
## Non-recoverable error
18291875
raise S3Error(response)
18301876

1831-
debug("MD5 sums: computed=%s, received=%s" % (md5_computed, response["headers"].get('etag', '').strip('"\'')))
1832-
## when using KMS encryption, MD5 etag value will not match
1833-
md5_from_s3 = response["headers"].get("etag", "").strip('"\'')
1834-
if ('-' not in md5_from_s3) and (md5_from_s3 != md5_hash.hexdigest()) and response["headers"].get("x-amz-server-side-encryption") != 'aws:kms':
1835-
warning("MD5 Sums don't match!")
1836-
if retries:
1837-
warning("Retrying upload of %s" % (filename))
1838-
return self.send_file(request, stream, labels, buffer, throttle,
1839-
retries - 1, offset, chunk_size, use_expect_continue)
1877+
if self.config.sse_customer_key:
1878+
if response["headers"]["x-amz-server-side-encryption-customer-key-md5"] != \
1879+
request.headers["x-amz-server-side-encryption-customer-key-md5"]:
1880+
warning("MD5 of customer key don't match!")
1881+
if retries:
1882+
warning("Retrying upload of %s" % (filename))
1883+
return self.send_file(request, stream, labels, buffer, throttle, retries - 1, offset, chunk_size)
1884+
else:
1885+
warning("Too many failures. Giving up on '%s'" % (filename))
1886+
raise S3UploadError
18401887
else:
1841-
warning("Too many failures. Giving up on '%s'" % (filename))
1842-
raise S3UploadError("Too many failures. Giving up on '%s'"
1843-
% filename)
1888+
debug("Match of x-amz-server-side-encryption-customer-key-md5")
1889+
else:
1890+
debug("MD5 sums: computed=%s, received=%s" % (md5_computed, response["headers"].get('etag', '').strip('"\'')))
1891+
## when using KMS encryption, MD5 etag value will not match
1892+
md5_from_s3 = response["headers"].get("etag", "").strip('"\'')
1893+
if ('-' not in md5_from_s3) and (md5_from_s3 != md5_hash.hexdigest()) and response["headers"].get("x-amz-server-side-encryption") != 'aws:kms':
1894+
warning("MD5 Sums don't match!")
1895+
if retries:
1896+
warning("Retrying upload of %s" % (filename))
1897+
return self.send_file(request, stream, labels, buffer, throttle,
1898+
retries - 1, offset, chunk_size, use_expect_continue)
1899+
else:
1900+
warning("Too many failures. Giving up on '%s'" % (filename))
1901+
raise S3UploadError("Too many failures. Giving up on '%s'"
1902+
% filename)
18441903

18451904
return response
18461905

run-tests-minio.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,24 @@ def pbucket(tail):
743743
test_s3cmd("Simple delete with rm", ['rm', '%s/xyz/test_rm/TypeRa.ttf' % pbucket(1)],
744744
must_find = [ "delete: '%s/xyz/test_rm/TypeRa.ttf'" % pbucket(1) ])
745745

746+
## ====== Check SSE-C encrypted object upload
747+
test_s3cmd("Put server-side encrypted object", ['put', 'testsuite/demo/some-file.xml', 's3://%s/xyz/demo/some-file.xml' % bucket(1), '--sse-customer-key=12345678901234567890123456789012'],
748+
retcode=0,
749+
must_find=["upload: 'testsuite/demo/some-file.xml' -> '%s/xyz/demo/some-file.xml'" % pbucket(1)])
750+
751+
## ====== Check SSE-C encrypted object wrong passphrase
752+
test_s3cmd("Get server-side encrypted object with wrong passphrase", ['get', u'%s/xyz/demo/some-file.xml' % pbucket(1), 'testsuite-out', '--sse-customer-key=11111111111111111111111111111111'],
753+
retcode = EX_SERVERERROR,
754+
must_find = [ "The calculated MD5 hash of the key did not match" ])
755+
756+
## ====== Check SSE-C encrypted object download
757+
test_s3cmd("Get server-side encrypted object", ['get', u'%s/xyz/demo/some-file.xml' % pbucket(1), 'testsuite-out', '--sse-customer-key=12345678901234567890123456789012'],
758+
retcode = 0,
759+
must_find = [ "-> 'testsuite-out/some-file.xml'" ])
760+
761+
## ====== Clean up local destination dir
762+
test_flushdir("Clean testsuite-out/", "testsuite-out")
763+
746764
## ====== Create expiration rule with days and prefix
747765
# Minio: disabled
748766
#test_s3cmd("Create expiration rule with days and prefix", ['expire', pbucket(1), '--expiry-days=365', '--expiry-prefix=log/'],

run-tests.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,24 @@ def pbucket(tail):
733733
test_s3cmd("Simple delete with rm", ['rm', '%s/xyz/test_rm/TypeRa.ttf' % pbucket(1)],
734734
must_find = [ "delete: '%s/xyz/test_rm/TypeRa.ttf'" % pbucket(1) ])
735735

736+
## ====== Check SSE-C encrypted object upload
737+
test_s3cmd("Put server-side encrypted object", ['put', 'testsuite/demo/some-file.xml', 's3://%s/xyz/demo/some-file.xml' % bucket(1), '--sse-customer-key=12345678901234567890123456789012'],
738+
retcode=0,
739+
must_find=["upload: 'testsuite/demo/some-file.xml' -> '%s/xyz/demo/some-file.xml'" % pbucket(1)])
740+
741+
## ====== Check SSE-C encrypted object wrong passphrase
742+
test_s3cmd("Get server-side encrypted object with wrong passphrase", ['get', u'%s/xyz/demo/some-file.xml' % pbucket(1), 'testsuite-out', '--sse-customer-key=11111111111111111111111111111111'],
743+
retcode = EX_SERVERERROR,
744+
must_find = [ "The calculated MD5 hash of the key did not match" ])
745+
746+
## ====== Check SSE-C encrypted object download
747+
test_s3cmd("Get server-side encrypted object", ['get', u'%s/xyz/demo/some-file.xml' % pbucket(1), 'testsuite-out', '--sse-customer-key=12345678901234567890123456789012'],
748+
retcode = 0,
749+
must_find = [ "-> 'testsuite-out/some-file.xml'" ])
750+
751+
## ====== Clean up local destination dir
752+
test_flushdir("Clean testsuite-out/", "testsuite-out")
753+
736754
## ====== Create expiration rule with days and prefix
737755
test_s3cmd("Create expiration rule with days and prefix", ['expire', pbucket(1), '--expiry-days=365', '--expiry-prefix=log/'],
738756
must_find = [ "Bucket '%s/': expiration configuration is set." % pbucket(1)])

s3cmd

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,8 @@ def cmd_object_get(args):
531531

532532
remote_count = len(remote_list)
533533

534+
extra_headers = copy(cfg.extra_headers)
535+
534536
info(u"Summary: %d remote files to download" % remote_count)
535537

536538
if remote_count > 0:
@@ -618,7 +620,7 @@ def cmd_object_get(args):
618620

619621
try:
620622
try:
621-
response = s3.object_get(uri, dst_stream, destination, start_position = start_position, extra_label = seq_label)
623+
response = s3.object_get(uri, dst_stream, destination, extra_headers, start_position = start_position, extra_label = seq_label)
622624
finally:
623625
dst_stream.close()
624626
except S3DownloadError as e:
@@ -1341,6 +1343,7 @@ def cmd_sync_remote2local(args):
13411343
def _download(remote_list, seq, total, total_size, dir_cache):
13421344
original_umask = os.umask(0)
13431345
os.umask(original_umask)
1346+
extra_headers = copy(cfg.extra_headers)
13441347
file_list = remote_list.keys()
13451348
file_list.sort()
13461349
ret = EX_OK
@@ -1375,7 +1378,7 @@ def cmd_sync_remote2local(args):
13751378
with io.open(chkptfd, mode='wb') as dst_stream:
13761379
dst_stream.stream_name = unicodise(chkptfname_b)
13771380
debug(u"created chkptfname=%s" % dst_stream.stream_name)
1378-
response = s3.object_get(uri, dst_stream, dst_file, extra_label = seq_label)
1381+
response = s3.object_get(uri, dst_stream, dst_file, extra_headers, extra_label = seq_label)
13791382

13801383
# download completed, rename the file to destination
13811384
if os.name == "nt":
@@ -1920,6 +1923,15 @@ def cmd_sync_local2remote(args):
19201923
error(u"or disable encryption with --no-encrypt parameter.")
19211924
sys.exit(EX_USAGE)
19221925

1926+
# Disable md5 checks if using SSE-C. Add mtime check
1927+
if cfg.sse_customer_key or cfg.sse_copy_source_customer_key:
1928+
try:
1929+
cfg.sync_checks.remove("md5")
1930+
except Exception:
1931+
pass
1932+
if cfg.sync_checks.count("mtime") == 0:
1933+
cfg.sync_checks.append("mtime")
1934+
19231935
for arg in args[:-1]:
19241936
if not os.path.exists(deunicodise(arg)):
19251937
raise ParameterError("Invalid source: '%s' is not an existing file or directory" % arg)
@@ -2365,6 +2377,8 @@ def run_configure(config_file, args):
23652377
("gpg_passphrase", "Encryption password", "Encryption password is used to protect your files from reading\nby unauthorized persons while in transfer to S3"),
23662378
("gpg_command", "Path to GPG program"),
23672379
("use_https", "Use HTTPS protocol", "When using secure HTTPS protocol all communication with Amazon S3\nservers is protected from 3rd party eavesdropping. This method is\nslower than plain HTTP, and can only be proxied with Python 2.7 or newer"),
2380+
("sse_customer_key", "Encryption key for server-side-encryption with customer key.\nMust be 32 characters"),
2381+
("sse_copy_source_customer_key", "Encryption key for server-side-encryption with customer key.\nMust be 32 characters"),
23682382
("proxy_host", "HTTP Proxy server name", "On some networks all internet access must go through a HTTP proxy.\nTry setting it here if you can't connect to S3 directly"),
23692383
("proxy_port", "HTTP Proxy server port"),
23702384
]
@@ -2804,6 +2818,8 @@ def main():
28042818

28052819
optparser.add_option( "--server-side-encryption", dest="server_side_encryption", action="store_true", help="Specifies that server-side encryption will be used when putting objects. [put, sync, cp, modify]")
28062820
optparser.add_option( "--server-side-encryption-kms-id", dest="kms_key", action="store", help="Specifies the key id used for server-side encryption with AWS KMS-Managed Keys (SSE-KMS) when putting objects. [put, sync, cp, modify]")
2821+
optparser.add_option( "--sse-customer-key", dest="sse_customer_key", action="store", metavar="12345678901234567890123456789012", help="Specifies a customer provided key for server-side encryption. Must be 32 character string.")
2822+
optparser.add_option( "--sse-copy-source-customer-key", dest="sse_copy_source_customer_key", action="store", metavar="12345678901234567890123456789012", help="Specifies the encryption key for copying or moving objects with a customer provided key for server-side encryption.")
28072823

28082824
optparser.add_option( "--encoding", dest="encoding", metavar="ENCODING", help="Override autodetected terminal and filesystem encoding (character set). Autodetected: %s" % autodetected_encoding)
28092825
optparser.add_option( "--add-encoding-exts", dest="add_encoding_exts", metavar="EXTENSIONs", help="Add encoding to these comma delimited extensions i.e. (css,js,html) when uploading to S3 )")

s3cmd.1

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,14 @@ Specifies the key id used for server\-side encryption
417417
with AWS KMS\-Managed Keys (SSE\-KMS) when putting
418418
objects. [put, sync, cp, modify]
419419
.TP
420+
\fB\-\-sse\-customer\-key\fR=12345678901234567890123456789012
421+
Specifies a customer key for server-side encryption, to be used
422+
when putting objects. Must be 32 characters.
423+
.TP
424+
\fB\-\-sse\-copy\-source\-customer\-key\fR=12345678901234567890123456789012
425+
Specifies the key for copying objects with server-side
426+
encryption customer key. Must be 32 characters.
427+
.TP
420428
\fB\-\-encoding\fR=ENCODING
421429
Override autodetected terminal and filesystem encoding
422430
(character set). Autodetected: UTF\-8

0 commit comments

Comments
 (0)