From f734731090c90252ef7a5456734ec8e15e7f2237 Mon Sep 17 00:00:00 2001 From: Stephen Cuppett Date: Sat, 17 Jan 2026 08:12:21 -0500 Subject: [PATCH] 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) Signed-off-by: Stephen Cuppett --- .../lib/Lib/Storage/AmazonS3.php | 6 +- lib/private/Files/ObjectStore/S3.php | 14 +- .../Files/ObjectStore/S3ConnectionTrait.php | 74 ++++ .../Files/ObjectStore/S3ObjectTrait.php | 15 +- tests/lib/Files/ObjectStore/S3SSEKMSTest.php | 397 ++++++++++++++++++ 5 files changed, 489 insertions(+), 17 deletions(-) create mode 100644 tests/lib/Files/ObjectStore/S3SSEKMSTest.php diff --git a/apps/files_external/lib/Lib/Storage/AmazonS3.php b/apps/files_external/lib/Lib/Storage/AmazonS3.php index fe7e31a13565d..6d96c31fffda2 100644 --- a/apps/files_external/lib/Lib/Storage/AmazonS3.php +++ b/apps/files_external/lib/Lib/Storage/AmazonS3.php @@ -116,7 +116,7 @@ private function headObject(string $key): array|false { $this->objectCache[$key] = $this->getConnection()->headObject([ 'Bucket' => $this->bucket, 'Key' => $key - ] + $this->getSSECParameters())->toArray(); + ] + $this->getServerSideEncryptionParameters())->toArray(); } catch (S3Exception $e) { if ($e->getStatusCode() >= 500) { throw $e; @@ -210,7 +210,7 @@ public function mkdir(string $path): bool { 'Key' => $path . '/', 'Body' => '', 'ContentType' => FileInfo::MIMETYPE_FOLDER - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $this->testTimeout(); } catch (S3Exception $e) { $this->logger->error($e->getMessage(), [ @@ -513,7 +513,7 @@ public function touch(string $path, ?int $mtime = null): bool { 'Body' => '', 'ContentType' => $mimeType, 'MetadataDirective' => 'REPLACE', - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $this->testTimeout(); } catch (S3Exception $e) { $this->logger->error($e->getMessage(), [ diff --git a/lib/private/Files/ObjectStore/S3.php b/lib/private/Files/ObjectStore/S3.php index e92346e4ccc01..80eee35f395e4 100644 --- a/lib/private/Files/ObjectStore/S3.php +++ b/lib/private/Files/ObjectStore/S3.php @@ -34,7 +34,7 @@ public function initiateMultipartUpload(string $urn): string { $upload = $this->getConnection()->createMultipartUpload([ 'Bucket' => $this->bucket, 'Key' => $urn, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $uploadId = $upload->get('UploadId'); if ($uploadId === null) { throw new Exception('No upload id returned'); @@ -50,7 +50,7 @@ public function uploadMultipartPart(string $urn, string $uploadId, int $partId, 'ContentLength' => $size, 'PartNumber' => $partId, 'UploadId' => $uploadId, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); } public function getMultipartUploads(string $urn, string $uploadId): array { @@ -65,7 +65,7 @@ public function getMultipartUploads(string $urn, string $uploadId): array { 'UploadId' => $uploadId, 'MaxParts' => 1000, 'PartNumberMarker' => $partNumberMarker, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $parts = array_merge($parts, $result->get('Parts') ?? []); $isTruncated = $result->get('IsTruncated'); $partNumberMarker = $result->get('NextPartNumberMarker'); @@ -80,11 +80,11 @@ public function completeMultipartUpload(string $urn, string $uploadId, array $re 'Key' => $urn, 'UploadId' => $uploadId, 'MultipartUpload' => ['Parts' => $result], - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $stat = $this->getConnection()->headObject([ 'Bucket' => $this->bucket, 'Key' => $urn, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); return (int)$stat->get('ContentLength'); } @@ -113,7 +113,7 @@ public function getObjectMetaData(string $urn): array { $object = $this->getConnection()->headObject([ 'Bucket' => $this->bucket, 'Key' => $urn - ] + $this->getSSECParameters())->toArray(); + ] + $this->getServerSideEncryptionParameters())->toArray(); return [ 'mtime' => $object['LastModified'], 'etag' => trim($object['ETag'], '"'), @@ -125,7 +125,7 @@ public function listObjects(string $prefix = ''): \Iterator { $results = $this->getConnection()->getPaginator('ListObjectsV2', [ 'Bucket' => $this->bucket, 'Prefix' => $prefix, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); foreach ($results as $result) { if (is_array($result['Contents'])) { diff --git a/lib/private/Files/ObjectStore/S3ConnectionTrait.php b/lib/private/Files/ObjectStore/S3ConnectionTrait.php index 082cceaa9de9b..8dcd57fa63ce3 100644 --- a/lib/private/Files/ObjectStore/S3ConnectionTrait.php +++ b/lib/private/Files/ObjectStore/S3ConnectionTrait.php @@ -295,6 +295,80 @@ protected function getSSECParameters(bool $copy = false): array { ]; } + /** + * Get SSE-KMS key ID from configuration + * @return string|null KMS key ARN/ID or null for bucket default key + */ + protected function getSSEKMSKeyId(): ?string { + if (isset($this->params['sse_kms_key_id']) && !empty($this->params['sse_kms_key_id'])) { + return $this->params['sse_kms_key_id']; + } + return null; + } + + /** + * Check if SSE-KMS is enabled + * @return bool + */ + protected function isSSEKMSEnabled(): bool { + return !empty($this->params['sse_kms_enabled']) && $this->params['sse_kms_enabled'] === true; + } + + /** + * Get SSE-KMS parameters for S3 operations + * + * When SSE-KMS is enabled, AWS S3 encrypts objects server-side using + * AWS Key Management Service (KMS) keys. This provides: + * - Centralized key management via AWS KMS + * - Audit trail of key usage + * - No client-side encryption overhead + * - Automatic key rotation support + * + * @param bool $copy Whether this is for a copy operation (unused for KMS) + * @return array Parameters to merge into S3 API calls + */ + protected function getSSEKMSParameters(bool $copy = false): array { + if (!$this->isSSEKMSEnabled()) { + return []; + } + + $params = [ + 'ServerSideEncryption' => 'aws:kms', + ]; + + // Add specific KMS key if configured, otherwise use bucket default key + $keyId = $this->getSSEKMSKeyId(); + if ($keyId !== null) { + $params['SSEKMSKeyId'] = $keyId; + } + + // Note: For copy operations, S3 re-encrypts with the destination key + // No special source parameters needed (unlike SSE-C) + + return $params; + } + + /** + * Get unified server-side encryption parameters + * + * Supports both SSE-C (customer-provided keys) and SSE-KMS (AWS-managed keys). + * SSE-C takes precedence if both are configured (for backward compatibility + * during migration from SSE-C to SSE-KMS). + * + * @param bool $copy Whether this is for a copy operation + * @return array Encryption parameters to merge into S3 API calls + */ + protected function getServerSideEncryptionParameters(bool $copy = false): array { + // SSE-C takes precedence for backward compatibility during migration + $sseC = $this->getSSECParameters($copy); + if (!empty($sseC)) { + return $sseC; + } + + // Fall back to SSE-KMS if enabled + return $this->getSSEKMSParameters($copy); + } + public function isUsePresignedUrl(): bool { return $this->usePresignedUrl; } diff --git a/lib/private/Files/ObjectStore/S3ObjectTrait.php b/lib/private/Files/ObjectStore/S3ObjectTrait.php index b6b55c746b2c9..6b451ff599933 100644 --- a/lib/private/Files/ObjectStore/S3ObjectTrait.php +++ b/lib/private/Files/ObjectStore/S3ObjectTrait.php @@ -31,6 +31,7 @@ abstract protected function getConnection(); abstract protected function getCertificateBundlePath(): ?string; abstract protected function getSSECParameters(bool $copy = false): array; + abstract protected function getServerSideEncryptionParameters(bool $copy = false): array; /** * @param string $urn the unified resource name used to identify the object @@ -45,7 +46,7 @@ public function readObject($urn) { 'Bucket' => $this->bucket, 'Key' => $urn, 'Range' => 'bytes=' . $range, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $request = \Aws\serialize($command); $headers = []; foreach ($request->getHeaders() as $key => $values) { @@ -113,7 +114,7 @@ protected function writeSingle(string $urn, StreamInterface $stream, array $meta 'ContentType' => $mimetype, 'Metadata' => $this->buildS3Metadata($metaData), 'StorageClass' => $this->storageClass, - ] + $this->getSSECParameters(); + ] + $this->getServerSideEncryptionParameters(); if ($size = $stream->getSize()) { $args['ContentLength'] = $size; @@ -156,7 +157,7 @@ protected function writeMultiPart(string $urn, StreamInterface $stream, array $m 'ContentType' => $mimetype, 'Metadata' => $this->buildS3Metadata($metaData), 'StorageClass' => $this->storageClass, - ] + $this->getSSECParameters(), + ] + $this->getServerSideEncryptionParameters(), 'before_upload' => function (Command $command) use (&$totalWritten) { $totalWritten += $command['ContentLength']; }, @@ -266,14 +267,14 @@ public function deleteObject($urn) { } public function objectExists($urn) { - return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getSSECParameters()); + return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getServerSideEncryptionParameters()); } public function copyObject($from, $to, array $options = []) { $sourceMetadata = $this->getConnection()->headObject([ 'Bucket' => $this->getBucket(), 'Key' => $from, - ] + $this->getSSECParameters()); + ] + $this->getServerSideEncryptionParameters()); $size = (int)($sourceMetadata->get('Size') ?? $sourceMetadata->get('ContentLength')); @@ -285,13 +286,13 @@ public function copyObject($from, $to, array $options = []) { 'bucket' => $this->getBucket(), 'key' => $to, 'acl' => 'private', - 'params' => $this->getSSECParameters() + $this->getSSECParameters(true), + 'params' => $this->getServerSideEncryptionParameters() + $this->getServerSideEncryptionParameters(true), 'source_metadata' => $sourceMetadata ], $options)); $copy->copy(); } else { $this->getConnection()->copy($this->getBucket(), $from, $this->getBucket(), $to, 'private', array_merge([ - 'params' => $this->getSSECParameters() + $this->getSSECParameters(true), + 'params' => $this->getServerSideEncryptionParameters() + $this->getServerSideEncryptionParameters(true), 'mup_threshold' => PHP_INT_MAX, ], $options)); } diff --git a/tests/lib/Files/ObjectStore/S3SSEKMSTest.php b/tests/lib/Files/ObjectStore/S3SSEKMSTest.php new file mode 100644 index 0000000000000..e2320f14a3d85 --- /dev/null +++ b/tests/lib/Files/ObjectStore/S3SSEKMSTest.php @@ -0,0 +1,397 @@ +getSystemValue('objectstore'); + if (!is_array($config) || $config['class'] !== S3::class) { + self::markTestSkipped('S3 primary storage not configured'); + } + + $arguments = $config['arguments'] ?? []; + if (empty($arguments['sse_kms_enabled'])) { + self::markTestSkipped('SSE-KMS not enabled. Set sse_kms_enabled=true in objectstore config'); + } + } + + protected function getInstance() { + if (!isset($this->instance)) { + $config = Server::get(IConfig::class)->getSystemValue('objectstore'); + $this->instance = new S3($config['arguments']); + } + return $this->instance; + } + + /** + * Test basic write and read with SSE-KMS + */ + public function testWriteReadWithKMS(): void { + $this->cleanupAfter('kms-test-write-read'); + + $s3 = $this->getInstance(); + $data = 'Test data for SSE-KMS encryption'; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write with SSE-KMS + $s3->writeObject('kms-test-write-read', $stream); + + // Read back + $result = $s3->readObject('kms-test-write-read'); + $readData = stream_get_contents($result); + fclose($result); + + $this->assertEquals($data, $readData, 'Data should be readable after SSE-KMS encryption'); + } + + /** + * Test copy operation with SSE-KMS + */ + public function testCopyWithKMS(): void { + $this->cleanupAfter('kms-test-copy-source'); + $this->cleanupAfter('kms-test-copy-target'); + + $s3 = $this->getInstance(); + $data = 'Test data for SSE-KMS copy operation'; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write source file + $s3->writeObject('kms-test-copy-source', $stream); + + // Copy (should re-encrypt with same KMS key) + $s3->copyObject('kms-test-copy-source', 'kms-test-copy-target'); + + // Verify copy + $this->assertTrue($s3->objectExists('kms-test-copy-target'), 'Copied object should exist'); + + $result = $s3->readObject('kms-test-copy-target'); + $readData = stream_get_contents($result); + fclose($result); + + $this->assertEquals($data, $readData, 'Copied data should match original'); + } + + /** + * Test multipart upload with SSE-KMS + */ + public function testMultipartUploadWithKMS(): void { + $this->cleanupAfter('kms-test-multipart'); + + $s3 = $this->getInstance(); + + // Create 6MB data to trigger multipart + $data = str_repeat('A', 6 * 1024 * 1024); + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write with multipart (forces multipart upload) + $s3->writeObject('kms-test-multipart', $stream, 'application/octet-stream'); + + // Verify + $this->assertTrue($s3->objectExists('kms-test-multipart'), 'Multipart object should exist'); + + // Read back first 1000 bytes to verify + $result = $s3->readObject('kms-test-multipart'); + $readData = fread($result, 1000); + fclose($result); + + $this->assertEquals(substr($data, 0, 1000), $readData, 'Multipart data should be readable'); + } + + /** + * Data provider for various file sizes + */ + public static function dataFileSizes(): array { + return [ + '1KB' => [1024], + '1MB' => [1024 * 1024], + '10MB' => [10 * 1024 * 1024], + ]; + } + + /** + * Data provider for large file sizes to test multipart upload threshold behavior + */ + public static function dataLargeFileSizes(): array { + return [ + '50MB' => [50 * 1024 * 1024], + '100MB' => [100 * 1024 * 1024], + '150MB' => [150 * 1024 * 1024], + ]; + } + + /** + * Test various file sizes with SSE-KMS + * + * @dataProvider dataFileSizes + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataFileSizes')] + public function testFileSizesWithKMS(int $size): void { + $urn = 'kms-test-size-' . ($size / 1024) . 'kb'; + $this->cleanupAfter($urn); + + $s3 = $this->getInstance(); + $data = str_repeat('X', $size); + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write + $s3->writeObject($urn, $stream); + + // Verify object exists + $this->assertTrue($s3->objectExists($urn), "Object should exist for size $size"); + + // Read back and verify + $result = $s3->readObject($urn); + $readData = stream_get_contents($result); + fclose($result); + + $this->assertEquals($size, strlen($readData), "Size mismatch for $size byte file"); + $this->assertEquals($data, $readData, "Content mismatch for $size byte file"); + } + + /** + * Test that SSE-KMS metadata is set on objects + */ + public function testKMSMetadataPresent(): void { + $this->cleanupAfter('kms-test-metadata'); + + $s3 = $this->getInstance(); + $data = 'Test KMS metadata'; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write with SSE-KMS + $s3->writeObject('kms-test-metadata', $stream); + + // Check metadata via headObject + $result = $s3->getConnection()->headObject([ + 'Bucket' => $s3->getBucket(), + 'Key' => 'kms-test-metadata', + ]); + + // Verify SSE is KMS + $this->assertEquals('aws:kms', $result->get('ServerSideEncryption'), + 'Object should have SSE-KMS encryption'); + + // If specific key configured, verify it's used + $config = Server::get(IConfig::class)->getSystemValue('objectstore'); + if (!empty($config['arguments']['sse_kms_key_id'])) { + $this->assertNotNull($result->get('SSEKMSKeyId'), + 'KMS Key ID should be present when specific key configured'); + } + } + + /** + * Test zero-byte file with SSE-KMS + * + * Note: Zero-byte files are a known edge case with S3. + * While they can be written, reading them back may fail due to + * Range header issues with empty objects. + */ + public function testZeroByteFileWithKMS(): void { + $this->cleanupAfter('kms-test-zerobyte'); + + $s3 = $this->getInstance(); + $stream = fopen('php://temp', 'r+'); + // Write nothing (zero bytes) + rewind($stream); + + // Write zero-byte file + $s3->writeObject('kms-test-zerobyte', $stream); + + // Verify exists + $this->assertTrue($s3->objectExists('kms-test-zerobyte'), 'Zero-byte object should exist'); + + // Verify via headObject instead of read (avoids Range header issue) + $metadata = $s3->getConnection()->headObject([ + 'Bucket' => $s3->getBucket(), + 'Key' => 'kms-test-zerobyte', + ]); + + $this->assertEquals(0, $metadata->get('ContentLength'), + 'Zero-byte file should have ContentLength of 0'); + $this->assertEquals('aws:kms', $metadata->get('ServerSideEncryption'), + 'Zero-byte file should still have SSE-KMS encryption'); + } + + /** + * Test large file sizes with SSE-KMS to verify multipart threshold behavior + * + * @dataProvider dataLargeFileSizes + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataLargeFileSizes')] + #[\PHPUnit\Framework\Attributes\Group('SLOWDB')] + public function testLargeFileSizesWithKMS(int $size): void { + $urn = 'kms-test-large-size-' . ($size / 1024 / 1024) . 'mb'; + $this->cleanupAfter($urn); + + $s3 = $this->getInstance(); + $data = str_repeat('L', $size); + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write (should trigger multipart for files >= 100MB) + $s3->writeObject($urn, $stream); + + // Verify object exists + $this->assertTrue($s3->objectExists($urn), "Object should exist for size $size"); + + // Verify metadata via headObject + $metadata = $s3->getConnection()->headObject([ + 'Bucket' => $s3->getBucket(), + 'Key' => $urn, + ]); + + $this->assertEquals('aws:kms', $metadata->get('ServerSideEncryption'), + "Object should have SSE-KMS encryption for size $size"); + $this->assertEquals($size, $metadata->get('ContentLength'), + "Size should match for $size byte file"); + } + + /** + * Test multipart copy operation with large files + */ + #[\PHPUnit\Framework\Attributes\Group('SLOWDB')] + public function testMultipartCopyWithKMS(): void { + $this->cleanupAfter('kms-test-multipart-copy-source'); + $this->cleanupAfter('kms-test-multipart-copy-target'); + + $s3 = $this->getInstance(); + + // Create large file to trigger multipart copy (> copySizeLimit, default 5GB) + // Use 10MB for test efficiency, but in production this would be > 5GB + $size = 10 * 1024 * 1024; + $data = str_repeat('C', $size); + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write source file + $s3->writeObject('kms-test-multipart-copy-source', $stream); + + // Copy (should re-encrypt with same KMS key) + $s3->copyObject('kms-test-multipart-copy-source', 'kms-test-multipart-copy-target'); + + // Verify copy exists + $this->assertTrue($s3->objectExists('kms-test-multipart-copy-target'), 'Copied object should exist'); + + // Verify encryption on target + $metadata = $s3->getConnection()->headObject([ + 'Bucket' => $s3->getBucket(), + 'Key' => 'kms-test-multipart-copy-target', + ]); + + $this->assertEquals('aws:kms', $metadata->get('ServerSideEncryption'), + 'Copied object should have SSE-KMS encryption'); + } + + /** + * Test delete operation with KMS-encrypted objects + */ + public function testDeleteWithKMS(): void { + $this->cleanupAfter('kms-test-delete'); + + $s3 = $this->getInstance(); + $data = 'Test data for delete operation'; + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $data); + rewind($stream); + + // Write with SSE-KMS + $s3->writeObject('kms-test-delete', $stream); + + // Verify exists + $this->assertTrue($s3->objectExists('kms-test-delete'), 'Object should exist before delete'); + + // Delete + $s3->deleteObject('kms-test-delete'); + + // Verify deleted + $this->assertFalse($s3->objectExists('kms-test-delete'), 'Object should not exist after delete'); + } + + /** + * Test overwriting existing KMS-encrypted objects + */ + public function testOverwriteWithKMS(): void { + $this->cleanupAfter('kms-test-overwrite'); + + $s3 = $this->getInstance(); + + // Write initial data + $data1 = 'Initial data for overwrite test'; + $stream1 = fopen('php://temp', 'r+'); + fwrite($stream1, $data1); + rewind($stream1); + $s3->writeObject('kms-test-overwrite', $stream1); + + // Verify initial write + $result1 = $s3->readObject('kms-test-overwrite'); + $readData1 = stream_get_contents($result1); + fclose($result1); + $this->assertEquals($data1, $readData1, 'Initial data should match'); + + // Overwrite with new data + $data2 = 'Overwritten data with different content'; + $stream2 = fopen('php://temp', 'r+'); + fwrite($stream2, $data2); + rewind($stream2); + $s3->writeObject('kms-test-overwrite', $stream2); + + // Verify overwrite + $result2 = $s3->readObject('kms-test-overwrite'); + $readData2 = stream_get_contents($result2); + fclose($result2); + $this->assertEquals($data2, $readData2, 'Overwritten data should match'); + + // Verify still encrypted with KMS + $metadata = $s3->getConnection()->headObject([ + 'Bucket' => $s3->getBucket(), + 'Key' => 'kms-test-overwrite', + ]); + + $this->assertEquals('aws:kms', $metadata->get('ServerSideEncryption'), + 'Overwritten object should still have SSE-KMS encryption'); + } +}