This commit is contained in:
Stephen Cuppett 2026-04-03 01:27:44 +00:00 committed by GitHub
commit aee53a8ca2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 489 additions and 17 deletions

View file

@ -115,7 +115,7 @@ class AmazonS3 extends Common {
$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;
@ -209,7 +209,7 @@ class AmazonS3 extends Common {
'Key' => $path . '/',
'Body' => '',
'ContentType' => FileInfo::MIMETYPE_FOLDER
] + $this->getSSECParameters());
] + $this->getServerSideEncryptionParameters());
$this->testTimeout();
} catch (S3Exception $e) {
$this->logger->error($e->getMessage(), [
@ -470,7 +470,7 @@ class AmazonS3 extends Common {
'Body' => '',
'ContentType' => $mimeType,
'MetadataDirective' => 'REPLACE',
] + $this->getSSECParameters());
] + $this->getServerSideEncryptionParameters());
$this->testTimeout();
} catch (S3Exception $e) {
$this->logger->error($e->getMessage(), [

View file

@ -34,7 +34,7 @@ class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaD
$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 @@ class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaD
'ContentLength' => $size,
'PartNumber' => $partId,
'UploadId' => $uploadId,
] + $this->getSSECParameters());
] + $this->getServerSideEncryptionParameters());
}
public function getMultipartUploads(string $urn, string $uploadId): array {
@ -65,7 +65,7 @@ class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaD
'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 @@ class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaD
'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 @@ class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaD
$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 @@ class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaD
$results = $this->getConnection()->getPaginator('ListObjectsV2', [
'Bucket' => $this->bucket,
'Prefix' => $prefix,
] + $this->getSSECParameters());
] + $this->getServerSideEncryptionParameters());
foreach ($results as $result) {
if (is_array($result['Contents'])) {

View file

@ -295,6 +295,80 @@ trait S3ConnectionTrait {
];
}
/**
* 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;
}

View file

@ -32,6 +32,7 @@ trait S3ObjectTrait {
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
@ -46,7 +47,7 @@ trait S3ObjectTrait {
'Bucket' => $this->bucket,
'Key' => $urn,
'Range' => 'bytes=' . $range,
] + $this->getSSECParameters());
] + $this->getServerSideEncryptionParameters());
$request = \Aws\serialize($command);
$headers = [];
foreach ($request->getHeaders() as $key => $values) {
@ -114,7 +115,7 @@ trait S3ObjectTrait {
'ContentType' => $mimetype,
'Metadata' => $this->buildS3Metadata($metaData),
'StorageClass' => $this->storageClass,
] + $this->getSSECParameters();
] + $this->getServerSideEncryptionParameters();
if ($size = $stream->getSize()) {
$args['ContentLength'] = $size;
@ -157,7 +158,7 @@ trait S3ObjectTrait {
'ContentType' => $mimetype,
'Metadata' => $this->buildS3Metadata($metaData),
'StorageClass' => $this->storageClass,
] + $this->getSSECParameters(),
] + $this->getServerSideEncryptionParameters(),
'before_upload' => function (Command $command) use (&$totalWritten): void {
$totalWritten += $command['ContentLength'];
},
@ -267,14 +268,14 @@ trait S3ObjectTrait {
}
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'));
@ -286,13 +287,13 @@ trait S3ObjectTrait {
'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));
}

View file

@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Files\ObjectStore;
use OC\Files\ObjectStore\S3;
use OCP\IConfig;
use OCP\Server;
/**
* Test suite for AWS SSE-KMS (Server-Side Encryption with Key Management Service).
*
* SSE-KMS provides:
* - AWS-managed server-side encryption
* - Centralized key management via AWS KMS
* - Audit trail of key usage via CloudTrail
* - No client-side encryption overhead
* - Automatic key rotation support
*
* Configuration options:
* - sse_kms_enabled: true - Enable SSE-KMS
* - sse_kms_key_id: (optional) Specific KMS key ARN, or use bucket default
*/
#[\PHPUnit\Framework\Attributes\Group('PRIMARY-s3')]
#[\PHPUnit\Framework\Attributes\Group('SSE-KMS')]
class S3SSEKMSTest extends ObjectStoreTestCase {
private S3 $instance;
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
$config = Server::get(IConfig::class)->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');
}
}