mirror of
https://github.com/nextcloud/server.git
synced 2026-04-26 00:27:49 -04:00
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>
397 lines
12 KiB
PHP
397 lines
12 KiB
PHP
<?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');
|
|
}
|
|
}
|