nextcloud/tests/lib/Files/ObjectStore/S3SSEKMSTest.php
Stephen Cuppett cdaeed02b6 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>
2026-04-16 13:21:53 -04:00

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');
}
}