Skip to content

Commit b3c0566

Browse files
cuppettclaude
andcommitted
feat(objectstore): Add AWS SSE-KMS encryption support for S3 storage
Add support for Server-Side Encryption with AWS Key Management Service (SSE-KMS) for S3 object storage. This allows Nextcloud to encrypt data at rest in S3 using AWS-managed keys. Key features: - New config options: sse_kms_enabled and sse_kms_key_id - Backward compatible with existing SSE-C (customer-provided keys) - SSE-C takes precedence when both SSE-C and SSE-KMS are configured Implementation details: - Added getServerSideEncryptionParameters() method to centralize encryption parameter logic for both SSE-C and SSE-KMS - Updated multipart uploads to use unified encryption parameters - Added comprehensive PHPUnit tests for SSE-KMS scenarios - Tested with AWS bucket and KMS keys in us-east-1 region Co-Authored-By: Claude Sonnet 4.5 (1M context) <[email protected]> Signed-off-by: Stephen Cuppett <[email protected]>
1 parent 6a29ea1 commit b3c0566

File tree

5 files changed

+489
-17
lines changed

5 files changed

+489
-17
lines changed

apps/files_external/lib/Lib/Storage/AmazonS3.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ private function headObject(string $key): array|false {
115115
$this->objectCache[$key] = $this->getConnection()->headObject([
116116
'Bucket' => $this->bucket,
117117
'Key' => $key
118-
] + $this->getSSECParameters())->toArray();
118+
] + $this->getServerSideEncryptionParameters())->toArray();
119119
} catch (S3Exception $e) {
120120
if ($e->getStatusCode() >= 500) {
121121
throw $e;
@@ -209,7 +209,7 @@ public function mkdir(string $path): bool {
209209
'Key' => $path . '/',
210210
'Body' => '',
211211
'ContentType' => FileInfo::MIMETYPE_FOLDER
212-
] + $this->getSSECParameters());
212+
] + $this->getServerSideEncryptionParameters());
213213
$this->testTimeout();
214214
} catch (S3Exception $e) {
215215
$this->logger->error($e->getMessage(), [
@@ -470,7 +470,7 @@ public function touch(string $path, ?int $mtime = null): bool {
470470
'Body' => '',
471471
'ContentType' => $mimeType,
472472
'MetadataDirective' => 'REPLACE',
473-
] + $this->getSSECParameters());
473+
] + $this->getServerSideEncryptionParameters());
474474
$this->testTimeout();
475475
} catch (S3Exception $e) {
476476
$this->logger->error($e->getMessage(), [

lib/private/Files/ObjectStore/S3.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public function initiateMultipartUpload(string $urn): string {
3434
$upload = $this->getConnection()->createMultipartUpload([
3535
'Bucket' => $this->bucket,
3636
'Key' => $urn,
37-
] + $this->getSSECParameters());
37+
] + $this->getServerSideEncryptionParameters());
3838
$uploadId = $upload->get('UploadId');
3939
if ($uploadId === null) {
4040
throw new Exception('No upload id returned');
@@ -50,7 +50,7 @@ public function uploadMultipartPart(string $urn, string $uploadId, int $partId,
5050
'ContentLength' => $size,
5151
'PartNumber' => $partId,
5252
'UploadId' => $uploadId,
53-
] + $this->getSSECParameters());
53+
] + $this->getServerSideEncryptionParameters());
5454
}
5555

5656
public function getMultipartUploads(string $urn, string $uploadId): array {
@@ -65,7 +65,7 @@ public function getMultipartUploads(string $urn, string $uploadId): array {
6565
'UploadId' => $uploadId,
6666
'MaxParts' => 1000,
6767
'PartNumberMarker' => $partNumberMarker,
68-
] + $this->getSSECParameters());
68+
] + $this->getServerSideEncryptionParameters());
6969
$parts = array_merge($parts, $result->get('Parts') ?? []);
7070
$isTruncated = $result->get('IsTruncated');
7171
$partNumberMarker = $result->get('NextPartNumberMarker');
@@ -80,11 +80,11 @@ public function completeMultipartUpload(string $urn, string $uploadId, array $re
8080
'Key' => $urn,
8181
'UploadId' => $uploadId,
8282
'MultipartUpload' => ['Parts' => $result],
83-
] + $this->getSSECParameters());
83+
] + $this->getServerSideEncryptionParameters());
8484
$stat = $this->getConnection()->headObject([
8585
'Bucket' => $this->bucket,
8686
'Key' => $urn,
87-
] + $this->getSSECParameters());
87+
] + $this->getServerSideEncryptionParameters());
8888
return (int)$stat->get('ContentLength');
8989
}
9090

@@ -113,7 +113,7 @@ public function getObjectMetaData(string $urn): array {
113113
$object = $this->getConnection()->headObject([
114114
'Bucket' => $this->bucket,
115115
'Key' => $urn
116-
] + $this->getSSECParameters())->toArray();
116+
] + $this->getServerSideEncryptionParameters())->toArray();
117117
return [
118118
'mtime' => $object['LastModified'],
119119
'etag' => trim($object['ETag'], '"'),
@@ -125,7 +125,7 @@ public function listObjects(string $prefix = ''): \Iterator {
125125
$results = $this->getConnection()->getPaginator('ListObjectsV2', [
126126
'Bucket' => $this->bucket,
127127
'Prefix' => $prefix,
128-
] + $this->getSSECParameters());
128+
] + $this->getServerSideEncryptionParameters());
129129

130130
foreach ($results as $result) {
131131
if (is_array($result['Contents'])) {

lib/private/Files/ObjectStore/S3ConnectionTrait.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,80 @@ protected function getSSECParameters(bool $copy = false): array {
295295
];
296296
}
297297

298+
/**
299+
* Get SSE-KMS key ID from configuration
300+
* @return string|null KMS key ARN/ID or null for bucket default key
301+
*/
302+
protected function getSSEKMSKeyId(): ?string {
303+
if (isset($this->params['sse_kms_key_id']) && !empty($this->params['sse_kms_key_id'])) {
304+
return $this->params['sse_kms_key_id'];
305+
}
306+
return null;
307+
}
308+
309+
/**
310+
* Check if SSE-KMS is enabled
311+
* @return bool
312+
*/
313+
protected function isSSEKMSEnabled(): bool {
314+
return !empty($this->params['sse_kms_enabled']) && $this->params['sse_kms_enabled'] === true;
315+
}
316+
317+
/**
318+
* Get SSE-KMS parameters for S3 operations
319+
*
320+
* When SSE-KMS is enabled, AWS S3 encrypts objects server-side using
321+
* AWS Key Management Service (KMS) keys. This provides:
322+
* - Centralized key management via AWS KMS
323+
* - Audit trail of key usage
324+
* - No client-side encryption overhead
325+
* - Automatic key rotation support
326+
*
327+
* @param bool $copy Whether this is for a copy operation (unused for KMS)
328+
* @return array Parameters to merge into S3 API calls
329+
*/
330+
protected function getSSEKMSParameters(bool $copy = false): array {
331+
if (!$this->isSSEKMSEnabled()) {
332+
return [];
333+
}
334+
335+
$params = [
336+
'ServerSideEncryption' => 'aws:kms',
337+
];
338+
339+
// Add specific KMS key if configured, otherwise use bucket default key
340+
$keyId = $this->getSSEKMSKeyId();
341+
if ($keyId !== null) {
342+
$params['SSEKMSKeyId'] = $keyId;
343+
}
344+
345+
// Note: For copy operations, S3 re-encrypts with the destination key
346+
// No special source parameters needed (unlike SSE-C)
347+
348+
return $params;
349+
}
350+
351+
/**
352+
* Get unified server-side encryption parameters
353+
*
354+
* Supports both SSE-C (customer-provided keys) and SSE-KMS (AWS-managed keys).
355+
* SSE-C takes precedence if both are configured (for backward compatibility
356+
* during migration from SSE-C to SSE-KMS).
357+
*
358+
* @param bool $copy Whether this is for a copy operation
359+
* @return array Encryption parameters to merge into S3 API calls
360+
*/
361+
protected function getServerSideEncryptionParameters(bool $copy = false): array {
362+
// SSE-C takes precedence for backward compatibility during migration
363+
$sseC = $this->getSSECParameters($copy);
364+
if (!empty($sseC)) {
365+
return $sseC;
366+
}
367+
368+
// Fall back to SSE-KMS if enabled
369+
return $this->getSSEKMSParameters($copy);
370+
}
371+
298372
public function isUsePresignedUrl(): bool {
299373
return $this->usePresignedUrl;
300374
}

lib/private/Files/ObjectStore/S3ObjectTrait.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ abstract protected function getConnection();
3232

3333
abstract protected function getCertificateBundlePath(): ?string;
3434
abstract protected function getSSECParameters(bool $copy = false): array;
35+
abstract protected function getServerSideEncryptionParameters(bool $copy = false): array;
3536

3637
/**
3738
* @param string $urn the unified resource name used to identify the object
@@ -46,7 +47,7 @@ public function readObject($urn) {
4647
'Bucket' => $this->bucket,
4748
'Key' => $urn,
4849
'Range' => 'bytes=' . $range,
49-
] + $this->getSSECParameters());
50+
] + $this->getServerSideEncryptionParameters());
5051
$request = \Aws\serialize($command);
5152
$headers = [];
5253
foreach ($request->getHeaders() as $key => $values) {
@@ -114,7 +115,7 @@ protected function writeSingle(string $urn, StreamInterface $stream, array $meta
114115
'ContentType' => $mimetype,
115116
'Metadata' => $this->buildS3Metadata($metaData),
116117
'StorageClass' => $this->storageClass,
117-
] + $this->getSSECParameters();
118+
] + $this->getServerSideEncryptionParameters();
118119

119120
if ($size = $stream->getSize()) {
120121
$args['ContentLength'] = $size;
@@ -157,7 +158,7 @@ protected function writeMultiPart(string $urn, StreamInterface $stream, array $m
157158
'ContentType' => $mimetype,
158159
'Metadata' => $this->buildS3Metadata($metaData),
159160
'StorageClass' => $this->storageClass,
160-
] + $this->getSSECParameters(),
161+
] + $this->getServerSideEncryptionParameters(),
161162
'before_upload' => function (Command $command) use (&$totalWritten): void {
162163
$totalWritten += $command['ContentLength'];
163164
},
@@ -267,14 +268,14 @@ public function deleteObject($urn) {
267268
}
268269

269270
public function objectExists($urn) {
270-
return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getSSECParameters());
271+
return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getServerSideEncryptionParameters());
271272
}
272273

273274
public function copyObject($from, $to, array $options = []) {
274275
$sourceMetadata = $this->getConnection()->headObject([
275276
'Bucket' => $this->getBucket(),
276277
'Key' => $from,
277-
] + $this->getSSECParameters());
278+
] + $this->getServerSideEncryptionParameters());
278279

279280
$size = (int)($sourceMetadata->get('Size') ?? $sourceMetadata->get('ContentLength'));
280281

@@ -286,13 +287,13 @@ public function copyObject($from, $to, array $options = []) {
286287
'bucket' => $this->getBucket(),
287288
'key' => $to,
288289
'acl' => 'private',
289-
'params' => $this->getSSECParameters() + $this->getSSECParameters(true),
290+
'params' => $this->getServerSideEncryptionParameters() + $this->getServerSideEncryptionParameters(true),
290291
'source_metadata' => $sourceMetadata
291292
], $options));
292293
$copy->copy();
293294
} else {
294295
$this->getConnection()->copy($this->getBucket(), $from, $this->getBucket(), $to, 'private', array_merge([
295-
'params' => $this->getSSECParameters() + $this->getSSECParameters(true),
296+
'params' => $this->getServerSideEncryptionParameters() + $this->getServerSideEncryptionParameters(true),
296297
'mup_threshold' => PHP_INT_MAX,
297298
], $options));
298299
}

0 commit comments

Comments
 (0)