Skip to content

Commit ef987d4

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) <noreply@anthropic.com> Signed-off-by: Stephen Cuppett <steve@cuppett.com>
1 parent 989e220 commit ef987d4

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
@@ -116,7 +116,7 @@ private function headObject(string $key): array|false {
116116
$this->objectCache[$key] = $this->getConnection()->headObject([
117117
'Bucket' => $this->bucket,
118118
'Key' => $key
119-
] + $this->getSSECParameters())->toArray();
119+
] + $this->getServerSideEncryptionParameters())->toArray();
120120
} catch (S3Exception $e) {
121121
if ($e->getStatusCode() >= 500) {
122122
throw $e;
@@ -210,7 +210,7 @@ public function mkdir(string $path): bool {
210210
'Key' => $path . '/',
211211
'Body' => '',
212212
'ContentType' => FileInfo::MIMETYPE_FOLDER
213-
] + $this->getSSECParameters());
213+
] + $this->getServerSideEncryptionParameters());
214214
$this->testTimeout();
215215
} catch (S3Exception $e) {
216216
$this->logger->error($e->getMessage(), [
@@ -513,7 +513,7 @@ public function touch(string $path, ?int $mtime = null): bool {
513513
'Body' => '',
514514
'ContentType' => $mimeType,
515515
'MetadataDirective' => 'REPLACE',
516-
] + $this->getSSECParameters());
516+
] + $this->getServerSideEncryptionParameters());
517517
$this->testTimeout();
518518
} catch (S3Exception $e) {
519519
$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
@@ -31,6 +31,7 @@ abstract protected function getConnection();
3131

3232
abstract protected function getCertificateBundlePath(): ?string;
3333
abstract protected function getSSECParameters(bool $copy = false): array;
34+
abstract protected function getServerSideEncryptionParameters(bool $copy = false): array;
3435

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

118119
if ($size = $stream->getSize()) {
119120
$args['ContentLength'] = $size;
@@ -156,7 +157,7 @@ protected function writeMultiPart(string $urn, StreamInterface $stream, array $m
156157
'ContentType' => $mimetype,
157158
'Metadata' => $this->buildS3Metadata($metaData),
158159
'StorageClass' => $this->storageClass,
159-
] + $this->getSSECParameters(),
160+
] + $this->getServerSideEncryptionParameters(),
160161
'before_upload' => function (Command $command) use (&$totalWritten) {
161162
$totalWritten += $command['ContentLength'];
162163
},
@@ -266,14 +267,14 @@ public function deleteObject($urn) {
266267
}
267268

268269
public function objectExists($urn) {
269-
return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getSSECParameters());
270+
return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getServerSideEncryptionParameters());
270271
}
271272

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

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

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

0 commit comments

Comments
 (0)