diff --git a/.changes/nextrelease/s3-encryption-client.json b/.changes/nextrelease/s3-encryption-client.json
new file mode 100644
index 0000000000..421d3c0ac8
--- /dev/null
+++ b/.changes/nextrelease/s3-encryption-client.json
@@ -0,0 +1,7 @@
+[
+ {
+ "type": "feature",
+ "category": "S3",
+ "description": "A new `S3EncryptionClient` implementation and a new `KmsMaterialProvider` implementation. `S3EncryptionClientV3` now supports writing and reading objects with Key Commitment. `KmsMaterialProviderV3` now supports verifying supplied encryption context on `decryptCek` calls."
+ }
+]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 62227a8620..3ac40cd3cb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,6 +11,7 @@ Jump To:
* [Bug Reports](#Bug-Reports)
* [Feature Requests](#Feature-Requests)
* [Code Contributions](#Code-Contributions)
+* [Security issue notifications](#Security-Issue-Notifications)
## How to contribute
@@ -114,6 +115,9 @@ we ask the same of all community contributions as well:
9. If you are working on the SDK, make sure to check out the `Makefile` for some
of the common tasks that we have to do.
+## Security issue notifications
+If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
+
### Changelog Documents
A changelog document is a small JSON blob placed in the `.changes/nextrelease`
diff --git a/behat.yml b/behat.yml
index 3a19a2bc6f..4abead4647 100644
--- a/behat.yml
+++ b/behat.yml
@@ -39,3 +39,6 @@ default:
s3EncryptionV2:
paths: [ "%paths.base%/features/s3EncryptionV2" ]
contexts: [ Aws\Test\Integ\S3EncryptionContextV2 ]
+ s3EncryptionV3:
+ paths: [ "%paths.base%/features/s3EncryptionV3" ]
+ contexts: [ Aws\Test\Integ\S3EncryptionContextV3 ]
diff --git a/features/s3EncryptionV3/encryptionV3.feature b/features/s3EncryptionV3/encryptionV3.feature
new file mode 100644
index 0000000000..4e8573c91a
--- /dev/null
+++ b/features/s3EncryptionV3/encryptionV3.feature
@@ -0,0 +1,76 @@
+@s3EncryptionV3 @integ @requiresUniqueResources
+Feature: S3 Client Side Encryption V3
+
+ Scenario: Upload PHP V3 GCM encrypted fixtures with key commitment
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I encrypt each fixture with "kms" "AWS_SDK_PHP_TEST_ALIAS" "us-west-2" and "aes_gcm" using V3 with commitment policy "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ And upload "PHP" data with folder "version_3"
+
+ Scenario: Get all PHP V3 plaintext fixtures for kms keyed aes gcm with key commitment and commitment policy "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3" using V3 client with security profile "V3" with commitment policy "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Get all PHP V3 plaintext fixtures for kms keyed aes gcm with key commitment and commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3" using V3 client with security profile "V3" with commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Get all PHP V3 plaintext fixtures for kms keyed aes gcm with key commitment and commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3" using V3 client with security profile "V3" with commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Get all PHP V3 plaintext fixtures for kms keyed aes cbc
+ When I get all fixtures for "aes_cbc" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3" using V3 client with security profile "V3_AND_LEGACY" and commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Cross-language compatibility - V3 decrypts Go V2 objects with legacy profile and commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "Go" "version_2" using V3 client with security profile "V3_AND_LEGACY" and commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Cross-language compatibility - V3 decrypts Go V2 objects with legacy profile and commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "Go" "version_2" using V3 client with security profile "V3_AND_LEGACY" and commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Cross-language compatibility - V3 decrypts Java V2 objects with legacy profile and commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "Java" "version_2" using V3 client with security profile "V3_AND_LEGACY" and commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Cross-language compatibility - V3 decrypts Java V2 objects with legacy profile and commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "Java" "version_2" using V3 client with security profile "V3_AND_LEGACY" and commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Key commitment validation - V3 encrypts with commitment policy REQUIRE_ENCRYPT_REQUIRE_DECRYPT
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I encrypt each fixture with "kms" "AWS_SDK_PHP_TEST_ALIAS" "us-west-2" and "aes_gcm" using V3 with commitment policy "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ And I verify key commitment is present in metadata
+ And upload "PHP" data with folder "version_3_commitment"
+
+ Scenario: Key commitment validation - V3 decrypts objects with valid commitment policy "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3_commitment" using V3 client with security profile "V3" with commitment policy "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ And I verify key commitment validation passes
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Key commitment validation - V3 decrypts objects with valid commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3_commitment" using V3 client with security profile "V3" with commitment policy "REQUIRE_ENCRYPT_ALLOW_DECRYPT"
+ And I verify key commitment validation passes
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Key commitment validation - V3 decrypts objects with valid commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_3_commitment" using V3 client with security profile "V3" with commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ And I verify key commitment validation passes
+ And I compare the decrypted ciphertext to the plaintext
+
+ Scenario: Commitment policy FORBID_ENCRYPT_ALLOW_DECRYPT - V3 decrypts V2 objects
+ When I get all fixtures for "aes_gcm" from "aws-sdk-php-crypto-tests"
+ Then I decrypt each fixture against "PHP" "version_2" using V3 client with commitment policy "FORBID_ENCRYPT_ALLOW_DECRYPT" and security profile "V3_AND_LEGACY"
+ And I compare the decrypted ciphertext to the plaintext
diff --git a/src/Crypto/AbstractCryptoClientV2.php b/src/Crypto/AbstractCryptoClientV2.php
index 2c7e7c26f7..9c8bca7b3e 100644
--- a/src/Crypto/AbstractCryptoClientV2.php
+++ b/src/Crypto/AbstractCryptoClientV2.php
@@ -9,6 +9,10 @@
*/
abstract class AbstractCryptoClientV2
{
+ const KEY_COMMITMENT_POLICIES = [
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ ];
+
public static $supportedCiphers = ['gcm'];
public static $supportedKeyWraps = [
@@ -19,6 +23,18 @@ abstract class AbstractCryptoClientV2
public static $legacySecurityProfiles = ['V2_AND_LEGACY'];
+ /**
+ * Returns if the passed policy name is supported for encryption by the SDK.
+ *
+ * @param string $policy The name of a key commitment policy to verify is registered.
+ *
+ * @return bool If the key commitment policy passed is in our supported list.
+ */
+ public static function isSupportedKeyCommitmentPolicy(string $policy): bool
+ {
+ return in_array($policy, self::KEY_COMMITMENT_POLICIES, strict: true);
+ }
+
/**
* Returns if the passed cipher name is supported for encryption by the SDK.
*
diff --git a/src/Crypto/AbstractCryptoClientV3.php b/src/Crypto/AbstractCryptoClientV3.php
new file mode 100644
index 0000000000..977ea0bf99
--- /dev/null
+++ b/src/Crypto/AbstractCryptoClientV3.php
@@ -0,0 +1,151 @@
+value;
+ }
+
+ public function isLegacy(): bool
+ {
+ return match ($this) {
+ self::ALG_AES_256_CBC_IV16_NO_KDF => true,
+ default => false,
+ };
+ }
+
+ public function isKeyCommitting(): bool
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => true,
+ default => false,
+ };
+ }
+
+ public function getDataKeyAlgorithm(): string
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ self::ALG_AES_256_CBC_IV16_NO_KDF => "AES",
+ };
+ }
+
+ public function getDataKeyLengthBits(): string
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ self::ALG_AES_256_CBC_IV16_NO_KDF => "256",
+ };
+ }
+
+ public function getCipherName(): string
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF => "gcm",
+ self::ALG_AES_256_CBC_IV16_NO_KDF => "cbc",
+ };
+ }
+
+ public function getCipherBlockSizeBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ self::ALG_AES_256_CBC_IV16_NO_KDF => 128,
+ };
+ }
+
+ public function getCipherBlockSizeBytes(): int
+ {
+ return $this->getCipherBlockSizeBits() / 8;
+ }
+
+ public function getIvLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF => 96,
+ self::ALG_AES_256_CBC_IV16_NO_KDF => 128,
+ };
+ }
+
+ public function getIvLengthBytes(): int
+ {
+ return $this->getIvLengthBits() / 8;
+ }
+
+ public function getCipherTagLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF => 128,
+ self::ALG_AES_256_CBC_IV16_NO_KDF => 0,
+ };
+ }
+
+ public function getCipherTagLengthInBytes(): int
+ {
+ return $this->getCipherTagLengthBits() / 8;
+ }
+
+ public function getCipherMaxContentLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF => AlgorithmConstants::GCM_MAX_CONTENT_LENGTH_BITS,
+ self::ALG_AES_256_CBC_IV16_NO_KDF => AlgorithmConstants::CBC_MAX_CONTENT_LENGTH_BYTES,
+ };
+ }
+
+ public function getCipherMaxContentLengthBytes(): int
+ {
+ return $this->getCipherMaxContentLengthBits() / 8;
+ }
+
+ public function getHashingAlgorithm(): string
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => "sha512",
+ default => "",
+ };
+ }
+
+ public function getDerivationInputKeyLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => 256,
+ default => 0,
+ };
+ }
+
+ public function getDerivationInputKeyLengthBytes(): int
+ {
+ return $this->getDerivationInputKeyLengthBits() / 8;
+ }
+
+ public function getDerivationOutputKeyLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => 256,
+ default => 0,
+ };
+ }
+
+ public function getDerivationOutputKeyLengthBytes(): int
+ {
+ return $this->getDerivationOutputKeyLengthBits() / 8;
+ }
+
+ public function getCommitmentInputKeyLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => 256,
+ default => 0,
+ };
+ }
+
+ public function getCommitmentInputKeyLengthBytes(): int
+ {
+ return $this->getCommitmentInputKeyLengthBits() / 8;
+ }
+
+ public function getCommitmentOutputKeyLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => 224,
+ default => 0,
+ };
+ }
+
+ public function getCommitmentOutputKeyLengthBytes(): int
+ {
+ return $this->getCommitmentOutputKeyLengthBits() / 8;
+ }
+
+ public function getKeyCommitmentSaltLengthBits(): int
+ {
+ return match ($this) {
+ self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => 224,
+ default => 0,
+ };
+ }
+
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //# The S3EC MUST validate the configured Encryption Algorithm against the provided key commitment policy.
+ public static function validateCommitmentPolicyOnEncrypt(
+ array $cipherOptions,
+ string $keyCommitmentPolicy
+ ): self {
+ $cipherOptions['Cipher'] = strtolower($cipherOptions['Cipher']);
+ //= ../specification/s3-encryption/client.md#encryption-algorithm
+ //# The S3EC MUST validate that the configured encryption algorithm is not legacy.
+ if (!S3EncryptionClientV3::isSupportedCipher($cipherOptions['Cipher'])) {
+ //= ../specification/s3-encryption/client.md#encryption-algorithm
+ //# If the configured encryption algorithm is legacy, then the S3EC MUST throw an exception.
+ throw new \InvalidArgumentException('The cipher requested is not'
+ . ' supported by the SDK.');
+ }
+
+ if (empty($cipherOptions['KeySize'])) {
+ $cipherOptions['KeySize'] = 256;
+ }
+
+ if (!is_int($cipherOptions['KeySize'])) {
+ throw new \InvalidArgumentException('The cipher "KeySize" must be'
+ . ' an integer.');
+ }
+
+ if (!MaterialsProviderV3::isSupportedKeySize($cipherOptions['KeySize'])) {
+ throw new \InvalidArgumentException('The cipher "KeySize" requested'
+ . ' is not supported by AES (256).');
+ }
+
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //# When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST NOT encrypt using an algorithm suite which supports key commitment.
+ if ($keyCommitmentPolicy === 'FORBID_ENCRYPT_ALLOW_DECRYPT') {
+ return self::ALG_AES_256_GCM_IV12_TAG16_NO_KDF;
+ } else {
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //# When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST only encrypt using an algorithm suite which supports key commitment.
+
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //# When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT, the S3EC MUST only encrypt using an algorithm suite which supports key commitment.
+ return self::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+ }
+ }
+}
diff --git a/src/Crypto/DecryptionTraitV2.php b/src/Crypto/DecryptionTraitV2.php
index ed63e0befa..d250f1d8d5 100644
--- a/src/Crypto/DecryptionTraitV2.php
+++ b/src/Crypto/DecryptionTraitV2.php
@@ -60,48 +60,138 @@ public function decrypt(
MaterialsProviderInterfaceV2 $provider,
MetadataEnvelope $envelope,
array $options = []
- ) {
- $options['@CipherOptions'] = !empty($options['@CipherOptions'])
- ? $options['@CipherOptions']
- : [];
- $options['@CipherOptions']['Iv'] = base64_decode(
- $envelope[MetadataEnvelope::IV_HEADER]
- );
+ )
+ {
+ $commitmentPolicy = $this->getKeyCommitmentPolicy($options);
+ unset($options['@CommitmentPolicy']);
+ if (isset($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3])) {
+ if ($commitmentPolicy !== "FORBID_ENCRYPT_ALLOW_DECRYPT") {
+ throw new CryptoException(
+ "The requested item is encrypted"
+ . " with Key Commitment. The current policy {$commitmentPolicy}"
+ . " conflicts with the object metadata. Select an appropriate"
+ . " '@CommitmentPolicy' to decrypt this item."
+ );
+ }
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+ $options['@CipherOptions'] = $options['@CipherOptions'] ?? [];
+ $options['@CipherOptions']['Iv'] = str_repeat("\1", 12);
+ $options['@CipherOptions']['TagLength'] = $algorithmSuite->getCipherTagLengthInBytes();
+ $materialDescription = json_decode(
+ $envelope[MetadataEnvelope::ENCRYPTION_CONTEXT_V3],
+ true
+ );
- $options['@CipherOptions']['TagLength'] =
- $envelope[MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER] / 8;
- $cek = $provider->decryptCek(
- base64_decode(
- $envelope[MetadataEnvelope::CONTENT_KEY_V2_HEADER]
- ),
- json_decode(
- $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER],
- true
- ),
- $options
- );
- $options['@CipherOptions']['KeySize'] = strlen($cek) * 8;
- $options['@CipherOptions']['Cipher'] = $this->getCipherFromAesName(
- $envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]
- );
+ $cek = $provider->decryptCek(
+ base64_decode($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3]),
+ $materialDescription,
+ $options
+ );
- $this->validateOptionsAndEnvelope($options, $envelope);
+ $options['@CipherOptions']['KeySize'] = strlen($cek) * 8;
+ $options['@CipherOptions']['Cipher'] = $this->getCipherFromAesName(
+ $this->numericalContenCipherToAesName($envelope)
+ );
+ $this->validateOptionsAndEnvelope($options, $envelope);
- $decryptionStream = $this->getDecryptingStream(
- $cipherText,
- $cek,
- $options['@CipherOptions']
- );
- unset($cek);
+ $messageId = base64_decode($envelope[MetadataEnvelope::MESSAGE_ID_V3]);
+ $commitmentKey = base64_decode($envelope[MetadataEnvelope::KEY_COMMITMENT_V3]);
+
+ if (strlen($messageId) !== ($algorithmSuite->getKeyCommitmentSaltLengthBits()) / 8) {
+ throw new CryptoException("Invalid MessageId length found in object envelope.");
+ }
+
+ if (strlen($commitmentKey) !== $algorithmSuite->getCommitmentOutputKeyLengthBytes()) {
+ throw new CryptoException("Invalid Commitment Key length found in object envelope.");
+ }
+
+ $decryptionStream = $this->getCommitingDecryptingStream(
+ $cipherText,
+ $cek,
+ $options['@CipherOptions'],
+ $messageId,
+ $commitmentKey,
+ $algorithmSuite
+ );
+ unset($cek);
+
+ return $decryptionStream;
+ } else {
+ $options['@CipherOptions'] = $options['@CipherOptions'] ?? [];
+ $options['@CipherOptions']['Iv'] = base64_decode(
+ $envelope[MetadataEnvelope::IV_HEADER]
+ );
+
+ $options['@CipherOptions']['TagLength'] =
+ $envelope[MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER] / 8;
+
+ $cek = $provider->decryptCek(
+ base64_decode(
+ $envelope[MetadataEnvelope::CONTENT_KEY_V2_HEADER]
+ ),
+ json_decode(
+ $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER],
+ true
+ ),
+ $options
+ );
+ $options['@CipherOptions']['KeySize'] = strlen($cek) * 8;
+ $options['@CipherOptions']['Cipher'] = $this->getCipherFromAesName(
+ $envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]
+ );
+
+ $this->validateOptionsAndEnvelope($options, $envelope);
+
+ $decryptionStream = $this->getDecryptingStream(
+ $cipherText,
+ $cek,
+ $options['@CipherOptions']
+ );
+ unset($cek);
+
+ return $decryptionStream;
+ }
+ }
+
+ private function buildMaterialDescription(
+ MetadataEnvelope $envelope
+ ): array
+ {
+ switch ($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]) {
+ case 12:
+ return ['aws:x-amz-cek-alg' => '115'];
+ default:
+ throw new CryptoException(
+ "Unknown Encrypted Data Key "
+ . "wrapping algorithm found: "
+ . "{$envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]}"
+ );
+ }
+ }
+
+ private function numericalContenCipherToAesName(
+ MetadataEnvelope $envelope
+ ): string
+ {
+ switch ($envelope[MetadataEnvelope::CONTENT_CIPHER_V3]) {
+ case 115:
+ return 'AES/GCM/NoPadding';
+ default:
+ throw new CryptoException(
+ "Unknown Encrypted Data Key "
+ . "wrapping algorithm found: "
+ . "{$envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]}"
+ );
+ }
- return $decryptionStream;
}
private function getTagFromCiphertextStream(
StreamInterface $cipherText,
$tagLength
- ) {
+ )
+ {
$cipherTextSize = $cipherText->getSize();
if ($cipherTextSize == null || $cipherTextSize <= 0) {
throw new \RuntimeException('Cannot decrypt a stream of unknown'
@@ -117,7 +207,8 @@ private function getTagFromCiphertextStream(
private function getStrippedCiphertextStream(
StreamInterface $cipherText,
$tagLength
- ) {
+ )
+ {
$cipherTextSize = $cipherText->getSize();
if ($cipherTextSize == null || $cipherTextSize <= 0) {
throw new \RuntimeException('Cannot decrypt a stream of unknown'
@@ -130,7 +221,7 @@ private function getStrippedCiphertextStream(
);
}
- private function validateOptionsAndEnvelope($options, $envelope)
+ private function validateOptionsAndEnvelope($options, $envelope): void
{
$allowedCiphers = AbstractCryptoClientV2::$supportedCiphers;
$allowedKeywraps = AbstractCryptoClientV2::$supportedKeyWraps;
@@ -160,34 +251,38 @@ private function validateOptionsAndEnvelope($options, $envelope)
. " This profile allows decryption with: "
. implode(", ", $allowedCiphers));
}
- if (!in_array(
- $envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER],
- $allowedKeywraps
- )) {
- if (in_array(
- $envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER],
- AbstractCryptoClient::$supportedKeyWraps)
- ) {
- throw $v1SchemaException;
+
+ if (isset($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3])) {
+ if ($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3] !== '12') {
+ throw new CryptoException("The requested object is encrypted with"
+ . " the keywrap schema '{$envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]}',"
+ . " which is not supported for decryption with the current security"
+ . " profile.");
}
- throw new CryptoException("The requested object is encrypted with"
- . " the keywrap schema '{$envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER]}',"
- . " which is not supported for decryption with the current security"
- . " profile.");
- }
+ } else {
+ if (!in_array($envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER], $allowedKeywraps)) {
+ if (in_array($envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER], AbstractCryptoClient::$supportedKeyWraps)) {
+ throw $v1SchemaException;
+ }
- $matdesc = json_decode(
- $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER],
- true
- );
- if (isset($matdesc['aws:x-amz-cek-alg'])
- && $envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER] !==
- $matdesc['aws:x-amz-cek-alg']
- ) {
- throw new CryptoException("There is a mismatch in specified content"
- . " encryption algrithm between the materials description value"
- . " and the metadata envelope value: {$matdesc['aws:x-amz-cek-alg']}"
- . " vs. {$envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]}.");
+ throw new CryptoException("The requested object is encrypted with"
+ . " the keywrap schema '{$envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER]}',"
+ . " which is not supported for decryption with the current security"
+ . " profile.");
+ }
+ $matdesc = json_decode(
+ $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER],
+ true
+ );
+ if (isset($matdesc['aws:x-amz-cek-alg'])
+ && ($envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]
+ !== $matdesc['aws:x-amz-cek-alg'])
+ ) {
+ throw new CryptoException("There is a mismatch in specified content"
+ . " encryption algrithm between the materials description value"
+ . " and the metadata envelope value: {$matdesc['aws:x-amz-cek-alg']}"
+ . " vs. {$envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]}.");
+ }
}
}
@@ -210,14 +305,15 @@ protected function getDecryptingStream(
$cipherText,
$cek,
$cipherOptions
- ) {
+ )
+ {
$cipherTextStream = Psr7\Utils::streamFor($cipherText);
switch ($cipherOptions['Cipher']) {
case 'gcm':
$cipherOptions['Tag'] = $this->getTagFromCiphertextStream(
- $cipherTextStream,
- $cipherOptions['TagLength']
- );
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ );
return new AesGcmDecryptingStream(
$this->getStrippedCiphertextStream(
@@ -227,9 +323,7 @@ protected function getDecryptingStream(
$cek,
$cipherOptions['Iv'],
$cipherOptions['Tag'],
- $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
- ? $cipherOptions['Aad']
- : '',
+ $cipherOptions['Aad'] = $cipherOptions['Aad'] ?? '',
$cipherOptions['TagLength'] ?: null,
$cipherOptions['KeySize']
);
@@ -246,4 +340,85 @@ protected function getDecryptingStream(
);
}
}
+
+ /**
+ * Generates a stream that wraps the cipher text with the proper cipher and
+ * uses the content encryption key (CEK) to derive both a derived content encryption key
+ * and a commitment key to decrypt the data when read.
+ *
+ * @param string $cipherText Plain-text data to be encrypted using the
+ * materials, algorithm, and data provided.
+ * @param string $cek A content encryption key for use by the stream for
+ * encrypting the plaintext data.
+ * @param array $cipherOptions Options for use in determining the cipher to
+ * be used for encrypting data.
+ * @param string $messageId a string value used to calculate both a commitment
+ * key and derived content encryption key
+ * @param string $commitmentKey a string value to compare with the calculated commitment
+ * key value, if the values don't match an exception is raised.
+ *
+ * @return AesStreamInterface | CryptoException
+ *
+ * @internal
+ */
+ protected function getCommitingDecryptingStream(
+ string $cipherText,
+ string $cek,
+ array $cipherOptions,
+ string $messageId,
+ string $commitmentKey,
+ AlgorithmSuite $algorithmSuite
+ ): AesStreamInterface|CryptoException
+ {
+ $algorithmSuiteIdAsBytes = pack('n', $algorithmSuite->getId());
+ $derivedEncryptionKeyInfo = $algorithmSuiteIdAsBytes . "DERIVEKEY";
+ $commitmentKeyInfo = $algorithmSuiteIdAsBytes . "COMMITKEY";
+ $derivedEncryptionKey = hash_hkdf(
+ $algorithmSuite->getHashingAlgorithm(),
+ $cek,
+ $algorithmSuite->getDerivationOutputKeyLengthBytes(),
+ $derivedEncryptionKeyInfo,
+ $messageId
+ );
+ $calculatedCommitmentKey = hash_hkdf(
+ $algorithmSuite->getHashingAlgorithm(),
+ $cek,
+ $algorithmSuite->getCommitmentOutputKeyLengthBytes(),
+ $commitmentKeyInfo,
+ $messageId
+ );
+
+ if ($commitmentKey != $calculatedCommitmentKey) {
+ throw new CryptoException("Calculated commitment key does "
+ . "not match expected commitment key value ");
+ }
+
+ $cipherTextStream = Psr7\Utils::streamFor($cipherText);
+ switch ($cipherOptions['Cipher']) {
+ case 'gcm':
+ $cipherOptions['Tag'] = $this->getTagFromCiphertextStream(
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ );
+ $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
+ ? $cipherOptions['Aad'] + $algorithmSuiteIdAsBytes
+ : $algorithmSuiteIdAsBytes;
+
+ return new AesGcmDecryptingStream(
+ $this->getStrippedCiphertextStream(
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ ),
+ $derivedEncryptionKey,
+ $cipherOptions['Iv'],
+ $cipherOptions['Tag'],
+ $cipherOptions['Aad'],
+ $cipherOptions['TagLength'] ?: null,
+ $cipherOptions['KeySize']
+ );
+ default:
+ throw new CryptoException("Unsupported Cipher used for key commitment messages."
+ . " Found {$cipherOptions["Cipher"]}. Only 'gcm' is supported.");
+ }
+ }
}
diff --git a/src/Crypto/DecryptionTraitV3.php b/src/Crypto/DecryptionTraitV3.php
new file mode 100644
index 0000000000..3b809f8456
--- /dev/null
+++ b/src/Crypto/DecryptionTraitV3.php
@@ -0,0 +1,530 @@
+checkEnvelopeForExclusiveMapKeys(
+ $envelope,
+ MetadataEnvelope::getV2Fields(),
+ "Expected V3 only fields but found V2 fields in header metadata."
+ );
+ // PHP only supports one commiting algorithm suite
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+ $options['@CipherOptions'] = $options['@CipherOptions'] ?? [];
+ $options['@CipherOptions']['Iv'] = str_repeat("\1", 12);
+ $options['@CipherOptions']['TagLength'] = $algorithmSuite->getCipherTagLengthInBytes();
+
+ //= ../specification/s3-encryption/data-format/content-metadata.md#v3-only
+ //= type=implication
+ //# - The wrapping algorithm value "12" MUST be translated to kms+context upon retrieval, and vice versa on write.
+ $materialDescription = $this->buildMaterialDescription($envelope);
+
+ $cek = $provider->decryptCek(
+ base64_decode($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3]),
+ $materialDescription,
+ $options
+ );
+
+ $options['@CipherOptions']['KeySize'] = strlen($cek) * 8;
+ $options['@CipherOptions']['Cipher'] = $this->getCipherFromAesName(
+ $this->numericalContenCipherToAesName($envelope)
+ );
+ $this->validateOptionsAndEnvelope($options, $envelope, $commitmentPolicy);
+
+ $messageId = base64_decode($envelope[MetadataEnvelope::MESSAGE_ID_V3]);
+ $commitmentKey = base64_decode($envelope[MetadataEnvelope::KEY_COMMITMENT_V3]);
+
+ if (strlen($messageId) !== ($algorithmSuite->getKeyCommitmentSaltLengthBits()) / 8) {
+ throw new CryptoException("Invalid MessageId length found in object envelope.");
+ }
+
+ if (strlen($commitmentKey) !== $algorithmSuite->getCommitmentOutputKeyLengthBytes()) {
+ throw new CryptoException("Invalid Commitment Key length found in object envelope.");
+ }
+
+ $decryptionStream = $this->getCommitingDecryptingStream(
+ $cipherText,
+ $cek,
+ $options['@CipherOptions'],
+ $messageId,
+ $commitmentKey,
+ $algorithmSuite
+ );
+ unset($cek);
+
+ return $decryptionStream;
+ } else {
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //# When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
+ //# the S3EC MUST NOT allow decryption using algorithm suites which do not support key commitment.
+ if ($commitmentPolicy == "REQUIRE_ENCRYPT_REQUIRE_DECRYPT") {
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //# If the configured Encryption Algorithm is incompatible with
+ //# the key commitment policy, then it MUST throw an exception.
+ throw new CryptoException("Message is encrypted with a "
+ . "non commiting algorithm but commitment policy is set "
+ . "to {$commitmentPolicy}. Select a valid commitment "
+ . "policy to decrypt this object. ");
+ }
+ $this->checkEnvelopeForExclusiveMapKeys(
+ $envelope,
+ MetadataEnvelope::getV3Fields(),
+ "Expected V2 only fields but found V3 fields in header metadata."
+ );
+ $options['@CipherOptions'] = $options['@CipherOptions'] ?? [];
+ $options['@CipherOptions']['Iv'] = base64_decode(
+ $envelope[MetadataEnvelope::IV_HEADER]
+ );
+
+ $options['@CipherOptions']['TagLength'] =
+ $envelope[MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER] / 8;
+
+ $cek = $provider->decryptCek(
+ base64_decode(
+ $envelope[MetadataEnvelope::CONTENT_KEY_V2_HEADER]
+ ),
+ json_decode(
+ $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER],
+ true
+ ),
+ $options
+ );
+ $options['@CipherOptions']['KeySize'] = strlen($cek) * 8;
+ $options['@CipherOptions']['Cipher'] = $this->getCipherFromAesName(
+ $envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]
+ );
+
+ //= ../specification/s3-encryption/decryption.md#key-commitment
+ //= type=implication
+ //# The S3EC MUST validate the algorithm suite used for decryption
+ //# against the key commitment policy before attempting to decrypt
+ //# the content ciphertext.
+ $this->validateOptionsAndEnvelope($options, $envelope, $commitmentPolicy);
+
+ $decryptionStream = $this->getNonCommitingDecryptingStream(
+ $cipherText,
+ $cek,
+ $options['@CipherOptions']
+ );
+ unset($cek);
+
+ return $decryptionStream;
+ }
+ }
+
+ private function checkEnvelopeForExclusiveMapKeys(
+ MetadataEnvelope $envelope,
+ array $exclusiveKeys,
+ string $errorMessage
+ ): void
+ {
+ foreach ($exclusiveKeys as $exclusiveKey) {
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //# If there are multiple mapkeys which are meant to be exclusive, such as "x-amz-key", "x-amz-key-v2", and "x-amz-3" then the S3EC SHOULD throw an exception.
+ if (isset($envelope[$exclusiveKey])) {
+ throw new CryptoException($errorMessage);
+ }
+ }
+ }
+
+ private function numericalContenCipherToAesName(
+ MetadataEnvelope $envelope
+ ): string
+ {
+ switch ($envelope[MetadataEnvelope::CONTENT_CIPHER_V3]) {
+ case 115:
+ return 'AES/GCM/NoPadding';
+ default:
+ throw new CryptoException(
+ "Unknown Encrypted Data Key "
+ . "wrapping algorithm found: "
+ . "{$envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]}"
+ );
+ }
+
+ }
+
+ private function buildMaterialDescription(
+ MetadataEnvelope $envelope
+ ): array
+ {
+ switch ($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]) {
+ case 12:
+ return json_decode(
+ $envelope[MetadataEnvelope::ENCRYPTION_CONTEXT_V3],
+ true
+ );
+ default:
+ throw new CryptoException(
+ "Unknown Encrypted Data Key "
+ . "wrapping algorithm found: "
+ . "{$envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]}"
+ );
+ }
+ }
+
+ private function getTagFromCiphertextStream(
+ StreamInterface $cipherText,
+ $tagLength
+ ): string
+ {
+ $cipherTextSize = $cipherText->getSize();
+ if ($cipherTextSize == null || $cipherTextSize <= 0) {
+ throw new \RuntimeException('Cannot decrypt a stream of unknown size');
+ }
+
+ return (string) new LimitStream(
+ $cipherText,
+ $tagLength,
+ $cipherTextSize - $tagLength
+ );
+ }
+
+ private function getStrippedCiphertextStream(
+ StreamInterface $cipherText,
+ $tagLength
+ ): LimitStream
+ {
+ $cipherTextSize = $cipherText->getSize();
+ if ($cipherTextSize == null || $cipherTextSize <= 0) {
+ throw new \RuntimeException('Cannot decrypt a stream of unknown size');
+ }
+
+ return new LimitStream(
+ $cipherText,
+ $cipherTextSize - $tagLength,
+ 0
+ );
+ }
+
+ private function validateOptionsAndEnvelope(
+ array $options,
+ MetadataEnvelope $envelope,
+ string $commitmentPolicy
+ ): void
+ {
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //# When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT,
+ //# the S3EC MUST allow decryption using algorithm suites which do not support key commitment.
+
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //# When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT,
+ //# the S3EC MUST allow decryption using algorithm suites which do not support key commitment.
+
+ $allowedCiphers = AbstractCryptoClientV3::$supportedCiphers;
+ $allowedKeywraps = AbstractCryptoClientV3::$supportedKeyWraps;
+ //= ../specification/s3-encryption/client.md#enable-legacy-wrapping-algorithms
+ //# When enabled, the S3EC MUST be able to decrypt objects encrypted with all
+ //# supported wrapping algorithms (both legacy and fully supported).
+ if ($options['@SecurityProfile'] == 'V3_AND_LEGACY') {
+ $allowedCiphers = array_unique(array_merge(
+ $allowedCiphers,
+ AbstractCryptoClient::$supportedCiphers
+ ));
+ $allowedKeywraps = array_unique(array_merge(
+ $allowedKeywraps,
+ AbstractCryptoClient::$supportedKeyWraps
+ ));
+ }
+
+ $v1SchemaException = new CryptoException("The requested object is encrypted"
+ . " with V1 encryption schemas that have been disabled by"
+ . " client configuration @SecurityProfile=V3. Retry with"
+ . " V3_AND_LEGACY enabled or reencrypt the object.");
+
+ if (!in_array($options['@CipherOptions']['Cipher'], $allowedCiphers)) {
+ //= ../specification/s3-encryption/client.md#enable-legacy-wrapping-algorithms
+ //= type=implication
+ //# When disabled, the S3EC MUST NOT decrypt objects encrypted using legacy wrapping algorithms;
+ //# it MUST throw an exception when attempting to decrypt an object encrypted with a legacy wrapping algorithm.
+
+ //= ../specification/s3-encryption/decryption.md#legacy-decryption
+ //# The S3EC MUST NOT decrypt objects encrypted using legacy unauthenticated algorithm suites unless specifically configured to do so.
+ if (in_array($options['@CipherOptions']['Cipher'], AbstractCryptoClient::$supportedCiphers)) {
+ //= ../specification/s3-encryption/decryption.md#legacy-decryption
+ //# If the S3EC is not configured to enable legacy unauthenticated content decryption, the client MUST throw an exception when attempting to decrypt an object encrypted with a legacy unauthenticated algorithm suite.
+ throw $v1SchemaException;
+ }
+
+ throw new CryptoException("The requested object is encrypted with"
+ . " the cipher '{$options['@CipherOptions']['Cipher']}', which is not"
+ . " supported for decryption with the selected security profile."
+ . " This profile allows decryption with: "
+ . implode(", ", $allowedCiphers));
+ }
+
+ if (isset($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3])) {
+ if ($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3] !== '12') {
+ throw new CryptoException("The requested object is encrypted with"
+ . " the keywrap schema '{$envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3]}',"
+ . " which is not supported for decryption with the current security"
+ . " profile.");
+ }
+ } else {
+ if (!in_array($envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER], $allowedKeywraps)) {
+ if (in_array($envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER], AbstractCryptoClient::$supportedKeyWraps)) {
+ throw $v1SchemaException;
+ }
+
+ throw new CryptoException("The requested object is encrypted with"
+ . " the keywrap schema '{$envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER]}',"
+ . " which is not supported for decryption with the current security"
+ . " profile.");
+ }
+ $matdesc = json_decode(
+ $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER],
+ true
+ );
+ if (isset($matdesc['aws:x-amz-cek-alg'])
+ && ($envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]
+ !== $matdesc['aws:x-amz-cek-alg'])
+ ) {
+ throw new CryptoException("There is a mismatch in specified content"
+ . " encryption algrithm between the materials description value"
+ . " and the metadata envelope value: {$matdesc['aws:x-amz-cek-alg']}"
+ . " vs. {$envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER]}.");
+ }
+ }
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //= type=implication
+ //# - If the metadata contains "x-amz-3" and "x-amz-d" and "x-amz-i" then the object MUST be considered an S3EC-encrypted object using the V3 format.
+ if (!MetadataEnvelope::isV3Envelope($envelope)
+ && $commitmentPolicy == "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ ) {
+ //= ../specification/s3-encryption/decryption.md#key-commitment
+ //# If the commitment policy requires decryption using a committing algorithm suite
+ //# and the algorithm suite associated with the object does not support key commitment,
+ //# then the S3EC MUST throw an exception.
+ throw new CryptoException("There is a mismatch in specified"
+ . "commitment policy value {$commitmentPolicy} and"
+ . "Metadata Envelope found in object.");
+ }
+
+ }
+
+ /**
+ * Generates a stream that wraps the cipher text with the proper cipher and
+ * uses the content encryption key (CEK) to decrypt the data when read.
+ *
+ * @param string $cipherText Plain-text data to be encrypted using the
+ * materials, algorithm, and data provided.
+ * @param string $cek A content encryption key for use by the stream for
+ * encrypting the plaintext data.
+ * @param array $cipherOptions Options for use in determining the cipher to
+ * be used for encrypting data.
+ *
+ * @return AesStreamInterface
+ *
+ * @internal
+ */
+ protected function getNonCommitingDecryptingStream(
+ string $cipherText,
+ string $cek,
+ array $cipherOptions
+ ): AesStreamInterface
+ {
+ $cipherTextStream = Psr7\Utils::streamFor($cipherText);
+ switch ($cipherOptions['Cipher']) {
+ case 'gcm':
+ $cipherOptions['Tag'] = $this->getTagFromCiphertextStream(
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ );
+
+ return new AesGcmDecryptingStream(
+ $this->getStrippedCiphertextStream(
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ ),
+ $cek,
+ $cipherOptions['Iv'],
+ $cipherOptions['Tag'],
+ $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
+ ? $cipherOptions['Aad']
+ : '',
+ $cipherOptions['TagLength'] ?: null,
+ $cipherOptions['KeySize']
+ );
+ default:
+ $cipherMethod = $this->buildCipherMethod(
+ $cipherOptions['Cipher'],
+ $cipherOptions['Iv'],
+ $cipherOptions['KeySize']
+ );
+
+ return new AesDecryptingStream(
+ $cipherTextStream,
+ $cek,
+ $cipherMethod
+ );
+ }
+ }
+
+ /**
+ * Generates a stream that wraps the cipher text with the proper cipher and
+ * uses the content encryption key (CEK) to derive both a derived content encryption key
+ * and a commitment key to decrypt the data when read.
+ *
+ * @param string $cipherText Plain-text data to be encrypted using the
+ * materials, algorithm, and data provided.
+ * @param string $cek A content encryption key for use by the stream for
+ * encrypting the plaintext data.
+ * @param array $cipherOptions Options for use in determining the cipher to
+ * be used for encrypting data.
+ * @param string $messageId a string value used to calculate both a commitment
+ * key and derived content encryption key
+ * @param string $commitmentKey a string value to compare with the calculated commitment
+ * key value, if the values don't match an exception is raised.
+ *
+ * @return AesStreamInterface | CryptoException
+ *
+ * @internal
+ */
+ protected function getCommitingDecryptingStream(
+ string $cipherText,
+ string $cek,
+ array $cipherOptions,
+ string $messageId,
+ string $commitmentKey,
+ AlgorithmSuite $algorithmSuite
+ ): AesStreamInterface|CryptoException
+ {
+ $algorithmSuiteIdAsBytes = pack('n', $algorithmSuite->getId());
+ $derivedEncryptionKeyInfo = $algorithmSuiteIdAsBytes . "DERIVEKEY";
+ $commitmentKeyInfo = $algorithmSuiteIdAsBytes . "COMMITKEY";
+ $calculatedCommitmentKey = hash_hkdf(
+ $algorithmSuite->getHashingAlgorithm(),
+ $cek,
+ $algorithmSuite->getCommitmentOutputKeyLengthBytes(),
+ $commitmentKeyInfo,
+ $messageId
+ );
+ //= ../specification/s3-encryption/decryption.md#decrypting-with-commitment
+ //= type=implication
+ //# When using an algorithm suite which supports key commitment, the client MUST verify that the [derived key commitment](./key-derivation.md#hkdf-operation) contains the same bytes as the stored key commitment retrieved from the stored object's metadata.
+ if (
+ //= ../specification/s3-encryption/decryption.md#decrypting-with-commitment
+ //= type=implication
+ //# When using an algorithm suite which supports key commitment,
+ //# the verification of the derived key commitment value MUST be done in constant time.
+ !hash_equals($commitmentKey, $calculatedCommitmentKey)
+ ) {
+ //= ../specification/s3-encryption/decryption.md#decrypting-with-commitment
+ //= type=implication
+ //# When using an algorithm suite which supports key commitment, the client MUST
+ //# throw an exception when the derived key commitment value
+ //# and stored key commitment value do not match.
+ throw new CryptoException("Calculated commitment key does "
+ . "not match expected commitment key value ");
+ }
+ //= ../specification/s3-encryption/decryption.md#decrypting-with-commitment
+ //= type=implication
+ //# When using an algorithm suite which supports key commitment,
+ //# the client MUST verify the key commitment values match before
+ //# deriving the [derived encryption key](./key-derivation.md#hkdf-operation).
+ $derivedEncryptionKey = hash_hkdf(
+ $algorithmSuite->getHashingAlgorithm(),
+ $cek,
+ $algorithmSuite->getDerivationOutputKeyLengthBytes(),
+ $derivedEncryptionKeyInfo,
+ $messageId
+ );
+
+ $cipherTextStream = Psr7\Utils::streamFor($cipherText);
+ switch ($cipherOptions['Cipher']) {
+ case 'gcm':
+ $cipherOptions['Tag'] = $this->getTagFromCiphertextStream(
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ );
+ $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
+ ? $cipherOptions['Aad'] + $algorithmSuiteIdAsBytes
+ : $algorithmSuiteIdAsBytes;
+
+ return new AesGcmDecryptingStream(
+ $this->getStrippedCiphertextStream(
+ $cipherTextStream,
+ $cipherOptions['TagLength']
+ ),
+ $derivedEncryptionKey,
+ $cipherOptions['Iv'],
+ $cipherOptions['Tag'],
+ $cipherOptions['Aad'],
+ $cipherOptions['TagLength'] ?: null,
+ $cipherOptions['KeySize']
+ );
+ default:
+ throw new CryptoException("Unsupported Cipher used for key commitment messages."
+ . " Found {$cipherOptions["Cipher"]}. Only 'gcm' is supported.");
+ }
+ }
+}
diff --git a/src/Crypto/EncryptionTraitV2.php b/src/Crypto/EncryptionTraitV2.php
index 2bce1833bd..784b91664c 100644
--- a/src/Crypto/EncryptionTraitV2.php
+++ b/src/Crypto/EncryptionTraitV2.php
@@ -85,9 +85,7 @@ public function encrypt(
. ' an integer.');
}
- if (!MaterialsProviderV2::isSupportedKeySize(
- $cipherOptions['KeySize']
- )) {
+ if (!MaterialsProviderV2::isSupportedKeySize($cipherOptions['KeySize'])) {
throw new \InvalidArgumentException('The cipher "KeySize" requested'
. ' is not supported by AES (128 or 256).');
}
@@ -130,12 +128,12 @@ public function encrypt(
$provider->getWrapAlgorithmName();
$envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER] = $aesName;
$envelope[MetadataEnvelope::UNENCRYPTED_CONTENT_LENGTH_HEADER] =
- strlen($plaintext);
+ (string) strlen($plaintext);
$envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER] =
json_encode($materialsDescription);
if (!empty($cipherOptions['Tag'])) {
$envelope[MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER] =
- strlen($cipherOptions['Tag']) * 8;
+ (string) (strlen($cipherOptions['Tag']) * 8);
}
return $encryptingStream;
@@ -170,9 +168,7 @@ protected function getEncryptingStream(
$plaintext,
$cek,
$cipherOptions['Iv'],
- $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
- ? $cipherOptions['Aad']
- : '',
+ $cipherOptions['Aad'] = $cipherOptions['Aad'] ?? '',
$cipherOptions['TagLength'],
$cipherOptions['KeySize']
);
diff --git a/src/Crypto/EncryptionTraitV3.php b/src/Crypto/EncryptionTraitV3.php
new file mode 100644
index 0000000000..62e3ced851
--- /dev/null
+++ b/src/Crypto/EncryptionTraitV3.php
@@ -0,0 +1,474 @@
+ true,
+ 'KeySize' => true,
+ 'Aad' => true,
+ ];
+
+ private static array $encryptClasses = [
+ 'gcm' => AesGcmEncryptingStream::class
+ ];
+
+ /**
+ * Dependency to generate a CipherMethod from a set of inputs for loading
+ * in to an AesEncryptingStream.
+ *
+ * @param string $cipherName Name of the cipher to generate for encrypting.
+ * @param string $iv Base Initialization Vector for the cipher.
+ * @param int $keySize Size of the encryption key, in bits, that will be
+ * used.
+ *
+ * @return Cipher\CipherMethod
+ *
+ * @internal
+ */
+ abstract protected function buildCipherMethod(
+ $cipherName,
+ $iv,
+ $keySize
+ );
+
+ /**
+ * Builds an AesStreamInterface and populates encryption metadata into the
+ * supplied envelope.
+ *
+ * @param Stream $plaintext Plain-text data to be encrypted using the
+ * materials, algorithm, and data provided.
+ * @param AlgorithmSuite $algorithmSuite Algorithm Suite for use in encryption
+ * @param array $options Options for use in encryption, including cipher
+ * options, and encryption context.
+ * @param MaterialsProviderV3 $provider A provider to supply and encrypt
+ * materials used in encryption.
+ * @param MetadataEnvelope $envelope A storage envelope for encryption
+ * metadata to be added to.
+ *
+ * @return AppendStream
+ *
+ * @throws \InvalidArgumentException Thrown when a value in $options['@CipherOptions']
+ * is not valid.
+ *s
+ * @internal
+ */
+ public function encrypt(
+ Stream $plaintext,
+ AlgorithmSuite $algorithmSuite,
+ array $options,
+ MaterialsProviderV3 $provider,
+ MetadataEnvelope $envelope
+ ): AppendStream
+ {
+ $options = array_change_key_case($options);
+ $cipherOptions = array_intersect_key(
+ $options['@cipheroptions'],
+ self::$allowedOptions
+ );
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //= type=implication
+ //# The client MUST validate that the length of the plaintext bytes does not exceed
+ //# the algorithm suite's cipher's maximum content length in bytes.
+ if (strlen($plaintext) > $algorithmSuite->getCipherMaxContentLengthBytes()) {
+ throw new \InvalidArgumentException("The contentLength of the object you are attempting"
+ . " to encrypt exceeds the maximum length allowed for GCM encryption.");
+ }
+ $cipherOptions['Cipher'] = strtolower($cipherOptions['Cipher']);
+
+ if (!self::isSupportedCipher($cipherOptions['Cipher'])) {
+ throw new \InvalidArgumentException('The cipher requested is not'
+ . ' supported by the SDK.');
+ }
+
+ if (empty($cipherOptions['KeySize'])) {
+ $cipherOptions['KeySize'] = 256;
+ }
+
+ if (!is_int($cipherOptions['KeySize'])) {
+ throw new \InvalidArgumentException('The cipher "KeySize" must be'
+ . ' an integer.');
+ }
+
+ if (!MaterialsProviderV3::isSupportedKeySize($cipherOptions['KeySize'])) {
+ throw new \InvalidArgumentException('The cipher "KeySize" requested'
+ . ' is not supported by AES (256).');
+ }
+
+ $encryptClass = self::$encryptClasses[$algorithmSuite->getCipherName()];
+ $aesName = $encryptClass::getStaticAesName();
+ $materialsDescription = $algorithmSuite->isKeyCommitting()
+ ? ['aws:x-amz-cek-alg' => '115']
+ : ['aws:x-amz-cek-alg' => $aesName];
+
+ $keys = $provider->generateCek(
+ $algorithmSuite->getDataKeyLengthBits(),
+ $materialsDescription,
+ $options
+ );
+
+ // Some providers modify materials description based on options
+ if (isset($keys['UpdatedContext'])) {
+ $materialsDescription = $keys['UpdatedContext'];
+ }
+
+ if ($algorithmSuite->isKeyCommitting()) {
+ return $this->encryptCommitingStream(
+ $plaintext,
+ $algorithmSuite,
+ $cipherOptions,
+ $keys,
+ $materialsDescription,
+ $provider,
+ $envelope
+ );
+ } else {
+ return $this->encryptNonCommitingStream(
+ $plaintext,
+ $cipherOptions,
+ $keys,
+ $materialsDescription,
+ $aesName,
+ $provider,
+ $envelope
+ );
+ }
+ }
+
+ private function encryptNonCommitingStream(
+ Stream $plaintext,
+ array &$cipherOptions,
+ array $keys,
+ array $materialsDescription,
+ string $aesName,
+ MaterialsProviderV3 $provider,
+ MetadataEnvelope $envelope
+ ): AppendStream
+ {
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //# The generated IV or Message ID MUST be set or returned from the
+ //# encryption process such that it can be included in the content metadata.
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //# The client MUST generate an IV or Message ID using the length of the IV or Message ID defined in the algorithm suite.
+ $cipherOptions['Iv'] = $provider->generateIv(
+ $this->getCipherOpenSslName(
+ $cipherOptions['Cipher'],
+ $cipherOptions['KeySize']
+ )
+ );
+ // Some providers modify materials description based on options
+ if (isset($keys['UpdatedContext'])) {
+ $materialsDescription = $keys['UpdatedContext'];
+ }
+
+ $encryptingStream = $this->getNonCommittingEncryptingStream(
+ $plaintext,
+ $keys['Plaintext'],
+ $cipherOptions
+ );
+
+ // Populate envelope data
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //# - If the metadata contains "x-amz-iv" and "x-amz-metadata-x-amz-key-v2" then the object MUST be considered as an S3EC-encrypted object using the V2 format.
+ $envelope[MetadataEnvelope::CONTENT_KEY_V2_HEADER] = $keys['Ciphertext'];
+ unset($keys);
+
+ $envelope[MetadataEnvelope::IV_HEADER] =
+ base64_encode($cipherOptions['Iv']);
+ $envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER] =
+ $provider->getWrapAlgorithmName();
+ $envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER] = $aesName;
+ $envelope[MetadataEnvelope::UNENCRYPTED_CONTENT_LENGTH_HEADER] =
+ (string) strlen($plaintext);
+ $envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER] =
+ json_encode($materialsDescription);
+ if (!empty($cipherOptions['Tag'])) {
+ $envelope[MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER] =
+ (string) (strlen($cipherOptions['Tag']) * 8);
+ }
+ if (!MetadataEnvelope::isV2Envelope($envelope)) {
+ throw new CryptoException("Error while writing metadata envelope."
+ . " Not all required fields were set.");
+ }
+
+ return $encryptingStream;
+ }
+
+ private function encryptCommitingStream(
+ Stream $plaintext,
+ AlgorithmSuite $algorithmSuite,
+ array &$options,
+ array $keys,
+ array $materialsDescription,
+ MaterialsProviderV3 $provider,
+ MetadataEnvelope $envelope
+ ): AppendStream
+ {
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //# The generated IV or Message ID MUST be set or returned from the
+ //# encryption process such that it can be included in the content metadata.
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# When encrypting or decrypting with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ //# the IV used in the AES-GCM content encryption/decryption
+ //# MUST consist entirely of bytes with the value 0x01.
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# The IV's total length MUST match the IV length defined by the algorithm suite.
+ $options['Iv'] = str_repeat("\1", $algorithmSuite->getIvLengthBytes());
+ $messageId = $provider->generateIv(
+ $this->getCipherOpenSslName(
+ $algorithmSuite->getCipherName(),
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //# The client MUST generate an IV or Message ID using the length of
+ //# the IV or Message ID defined in the algorithm suite.
+ $algorithmSuite->getKeyCommitmentSaltLengthBits()
+ )
+ );
+ // Some providers modify materials description based on options
+ if (isset($keys['UpdatedContext'])) {
+ $materialsDescription["aws:x-amz-cek-alg"] = (string) $algorithmSuite->getId();
+ }
+ $commitingEncryptingArray = $this->getCommitingEncryptionStream(
+ $plaintext,
+ $keys['Plaintext'],
+ $options,
+ $messageId,
+ $algorithmSuite
+ );
+ //= ../specification/s3-encryption/encryption.md#alg-aes-256-gcm-hkdf-sha512-commit-key
+ //= type=implication
+ //# The derived key commitment value MUST be set or returned from the encryption process
+ //# such that it can be included in the content metadata.
+ $commitmentKey = $commitingEncryptingArray[0];
+ $encryptionStream = $commitingEncryptingArray[1];
+
+ // Populate envelope data
+ $envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3] = $keys['Ciphertext'];
+ unset($keys);
+
+ $envelope[MetadataEnvelope::CONTENT_CIPHER_V3] = $algorithmSuite->getId();
+ // we are able to always set the encryption context in the envelope because PHP
+ // only supports a KMS Material Provider.
+ $envelope[MetadataEnvelope::ENCRYPTION_CONTEXT_V3] =
+ json_encode($materialsDescription);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#v3-only
+ //# The Encryption Context value MUST be used for wrapping algorithm `kms+context` or `12`.
+ $envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3] = '12';
+ $envelope[MetadataEnvelope::KEY_COMMITMENT_V3] = $commitmentKey;
+ $envelope[MetadataEnvelope::MESSAGE_ID_V3] = base64_encode($messageId);
+ if (!MetadataEnvelope::isV3Envelope($envelope)) {
+ throw new CryptoException("Error while writing metadata envelope."
+ . " Not all required fields were set.");
+ }
+
+ return $encryptionStream;
+ }
+
+ /**
+ * Generates a stream that wraps the plaintext with the proper cipher and
+ * uses the content encryption key (CEK) to encrypt the data when read.
+ *
+ * @param Stream $plaintext Plain-text data to be encrypted using the
+ * materials, algorithm, and data provided.
+ * @param string $cek A content encryption key for use by the stream for
+ * encrypting the plaintext data.
+ * @param array $cipherOptions Options for use in determining the cipher to
+ * be used for encrypting data.
+ *
+ * @return AppendStream returns an AppendStream
+ *
+ * @internal
+ */
+ protected function getNonCommittingEncryptingStream(
+ Stream $plaintext,
+ string $cek,
+ array &$cipherOptions
+ ): AppendStream
+ {
+ // Only 'gcm' is supported for encryption currently
+ switch ($cipherOptions['Cipher']) {
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //= type=implication
+ //# The S3EC MUST use the encryption algorithm configured during [client](./client.md) initialization.
+ case 'gcm':
+ $cipherOptions['TagLength'] = 16;
+ $encryptClass = self::$encryptClasses['gcm'];
+ //= ../specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf
+ //= type=implication
+ //# The client MUST initialize the cipher, or call an AES-GCM encryption API,
+ //# with the plaintext data key, the generated IV, and the tag length defined
+ //# in the Algorithm Suite when encrypting with ALG_AES_256_GCM_IV12_TAG16_NO_KDF.
+ $cipherTextStream = new $encryptClass(
+ $plaintext,
+ $cek,
+ $cipherOptions['Iv'],
+ $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
+ ? $cipherOptions['Aad']
+ : '',
+ $cipherOptions['TagLength'],
+ $cipherOptions['KeySize']
+ );
+
+ if (!empty($cipherOptions['Aad'])) {
+ trigger_error("'Aad' has been supplied for content encryption"
+ . " with " . $cipherTextStream->getAesName() . ". The"
+ . " PHP SDK encryption client can decrypt an object"
+ . " encrypted in this way, but other AWS SDKs may not be"
+ . " able to.", E_USER_WARNING);
+ }
+
+ $appendStream = new AppendStream([
+ $cipherTextStream->createStream()
+ ]);
+ //= ../specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf
+ //= type=implication
+ //# The client MUST append the GCM auth tag to the ciphertext if the underlying
+ //# crypto provider does not do so automatically.
+ $cipherOptions['Tag'] = $cipherTextStream->getTag();
+ $appendStream->addStream(Psr7\Utils::streamFor($cipherOptions['Tag']));
+
+ return $appendStream;
+ default:
+ throw new CryptoException("Unsupported Cipher used for key commitment messages."
+ . " Found {$cipherOptions["Cipher"]}. Only 'gcm' is supported.");
+ }
+ }
+
+ /**
+ * Generates a stream that wraps the plaintext with the proper cipher and
+ * uses the content encryption key (CEK) to encrypt the data when read.
+ *
+ * @param Stream $plaintext Plain-text data to be encrypted using the
+ * materials, algorithm, and data provided.
+ * @param string $cek A content encryption key for use by the stream for
+ * encrypting the plaintext data.
+ * @param array $cipherOptions Options for use in determining the cipher to
+ * be used for encrypting data.
+ * @param string $messageId salt value used for key extraction step in the key
+ * derivation process.
+ * @param AlgorithmSuite $algorithmSuite options used for key commitment
+ *
+ * @return array returns an array with two elements as follows: [commitmentKey, AppendStream]
+ *
+ * @internal
+ */
+ protected function getCommitingEncryptionStream(
+ Stream $plaintext,
+ string $dek,
+ array &$cipherOptions,
+ string $messageId,
+ AlgorithmSuite $algorithmSuite
+ ): array
+ {
+ $algorithmSuiteIdAsBytes = pack('n', $algorithmSuite->getId());
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The input info MUST be a concatenation of the algorithm suite ID as bytes followed by the string DERIVEKEY as UTF8 encoded bytes.
+ $derivedEncryptionKeyInfo = $algorithmSuiteIdAsBytes . "DERIVEKEY";
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The input info MUST be a concatenation of the algorithm suite ID as bytes followed by the string COMMITKEY as UTF8 encoded bytes.
+ $commitmentKeyInfo = $algorithmSuiteIdAsBytes . "COMMITKEY";
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The length of the input keying material MUST equal the key derivation
+ //# input length specified by the algorithm suite commit key derivation setting.
+ if (strlen($dek) !== $algorithmSuite->getDerivationInputKeyLengthBytes()) {
+ throw new CryptoException("Input Key Material length exceeds "
+ . "key derivation input length specified by the algorithm suite.");
+ }
+ //= ../specification/s3-encryption/encryption.md#alg-aes-256-gcm-hkdf-sha512-commit-key
+ //= type=implication
+ //# The client MUST use HKDF to derive the key commitment value and
+ //# the derived encrypting key as described in [Key Derivation](key-derivation.md).
+ $cek = hash_hkdf(
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The hash function MUST be specified by the algorithm suite commitment settings.
+ $algorithmSuite->getHashingAlgorithm(),
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The input keying material MUST be the plaintext data key (PDK) generated by the key provider.
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The DEK input pseudorandom key MUST be the output from the extract step.
+ $dek,
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The length of the output keying material MUST equal the encryption key length specified by the algorithm suite encryption settings.
+ $algorithmSuite->getDerivationOutputKeyLengthBytes(),
+ $derivedEncryptionKeyInfo,
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The salt MUST be the Message ID with the length defined in the algorithm suite.
+ $messageId
+ );
+ $commitmentKey = hash_hkdf(
+ $algorithmSuite->getHashingAlgorithm(),
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The CK input pseudorandom key MUST be the output from the extract step.
+ $dek,
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# - The length of the output keying material MUST equal the commit key length specified by the supported algorithm suites.
+ $algorithmSuite->getCommitmentOutputKeyLengthBytes(),
+ $commitmentKeyInfo,
+ $messageId
+ );
+
+ switch ($cipherOptions['Cipher']) {
+ // Only 'gcm' is supported for encryption currently
+ case 'gcm':
+ $cipherOptions['TagLength'] = $algorithmSuite->getCipherTagLengthInBytes();
+ $encryptClass = self::$encryptClasses[$algorithmSuite->getCipherName()];
+
+ if (!empty($cipherOptions['Aad'])) {
+ trigger_error("'Aad' has been supplied for content encryption"
+ . " with " . $encryptClass->getAesName() . ". The"
+ . " PHP SDK encryption client can decrypt an object"
+ . " encrypted in this way, but other AWS SDKs may not be"
+ . " able to.", E_USER_NOTICE);
+ }
+ $cipherOptions['Aad'] = isset($cipherOptions['Aad'])
+ ? $cipherOptions['Aad'] + $algorithmSuiteIdAsBytes
+ : $algorithmSuiteIdAsBytes;
+ //= ../specification/s3-encryption/key-derivation.md#hkdf-operation
+ //= type=implication
+ //# The client MUST initialize the cipher, or call an AES-GCM encryption API,
+ //# with the derived encryption key, an IV containing only bytes with the value 0x01,
+ //# and the tag length defined in the Algorithm Suite when encrypting or
+ //# decrypting with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.
+ $cipherTextStream = new $encryptClass(
+ $plaintext,
+ $cek,
+ $cipherOptions['Iv'],
+ $cipherOptions['Aad'],
+ $cipherOptions['TagLength'],
+ $cipherOptions['KeySize']
+ );
+
+ $appendStream = new AppendStream([
+ $cipherTextStream->createStream()
+ ]);
+ //= ../specification/s3-encryption/encryption.md#alg-aes-256-gcm-hkdf-sha512-commit-key
+ //= type=implication
+ //# The client MUST append the GCM auth tag to the ciphertext if the underlying
+ //# crypto provider does not do so automatically.
+ $cipherOptions['Tag'] = $cipherTextStream->getTag();
+ $appendStream->addStream(Psr7\Utils::streamFor($cipherOptions['Tag']));
+
+ return [base64_encode($commitmentKey), $appendStream];
+ default:
+ throw new CryptoException("Unsupported Cipher used for content encryption");
+ }
+ }
+}
diff --git a/src/Crypto/KmsMaterialsProviderV3.php b/src/Crypto/KmsMaterialsProviderV3.php
new file mode 100644
index 0000000000..666585a365
--- /dev/null
+++ b/src/Crypto/KmsMaterialsProviderV3.php
@@ -0,0 +1,159 @@
+kmsClient = $kmsClient;
+ $this->kmsKeyId = $kmsKeyId;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getWrapAlgorithmName(): string
+ {
+ return self::WRAP_ALGORITHM_NAME;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function decryptCek(
+ string $encryptedCek,
+ array $materialDescription,
+ array $options
+ ): string
+ {
+ $options = array_change_key_case($options);
+ $encryptionContext = null;
+
+ // no encryption context was provided we will use the one in the metadata
+ if (!isset($options['@kmsencryptioncontext'])) {
+ $encryptionContext = $materialDescription;
+ } else {
+ // encryption context was passed in, we have to make sure it is an array
+ // and that the reserved keywords were not used
+ if (!is_array($options['@kmsencryptioncontext'])) {
+ throw new CryptoException("'When using @KmsMaterialsProviderV3, it"
+ . " must be an associative array (or empty array).");
+ }
+
+ if (isset($options['@kmsencryptioncontext']['aws:x-amz-cek-alg'])) {
+ throw new CryptoException("Conflict in reserved @KmsEncryptionContext"
+ . " key aws:x-amz-cek-alg. This value is reserved for the S3"
+ . " Encryption Client and cannot be set by the user.");
+ }
+
+ if (isset($options['@kmsencryptioncontext']['kms_cmk_id'])) {
+ throw new CryptoException("Conflict in reserved @KmsEncryptionContext"
+ . " key kms_cmk_id. This value is reserved for the S3"
+ . " Encryption Client and cannot be set by the user.");
+ }
+ //= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ //= type=implication
+ //# When decrypting using Kms+Context mode, the KmsKeyring MUST validate the provided (request) encryption context with the stored (materials) encryption context.
+ // We are validating the encryption context to match S3EC V2 behavior
+ // Refer to KMSMaterialsHandler in the V2 client for details
+ $materialsDescriptionContextCopy = $materialDescription;
+ unset($materialsDescriptionContextCopy["aws:x-amz-cek-alg"]);
+ unset($materialsDescriptionContextCopy["kms_cmk_id"]);
+
+ $requestEncryptionContext = $options['@kmsencryptioncontext'];
+ //= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ //= type=implication
+ //# The stored encryption context with the two reserved keys removed MUST match the provided encryption context.
+ if ($materialsDescriptionContextCopy !== $requestEncryptionContext) {
+ //= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ //= type=implication
+ //# If the stored encryption context with the two reserved keys removed does not match the provided encryption context, the KmsKeyring MUST throw an exception.
+ throw new CryptoException("Provided encryption context does not match information retrieved from S3");
+ }
+ $encryptionContext = $materialDescription;
+ }
+ $params = [
+ 'CiphertextBlob' => $encryptedCek,
+ 'EncryptionContext' => $encryptionContext
+ ];
+
+ if (empty($options['@kmsallowdecryptwithanycmk'])) {
+ if (empty($this->kmsKeyId)) {
+ throw new CryptoException('KMS CMK ID was not specified and the'
+ . ' operation is not opted-in to attempting to use any valid'
+ . ' CMK it discovers. Please specify a CMK ID, or explicitly'
+ . ' enable attempts to use any valid KMS CMK with the'
+ . ' @KmsAllowDecryptWithAnyCmk option.');
+ }
+ $params['KeyId'] = $this->kmsKeyId;
+ }
+
+ $result = $this->kmsClient->decrypt($params);
+
+ return $result['Plaintext'];
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function generateCek($keySize, $context, $options): array
+ {
+ if (empty($this->kmsKeyId)) {
+ throw new CryptoException('A KMS key id is required for encryption'
+ . ' with KMS keywrap. Use a KmsMaterialsProviderV2 that has been'
+ . ' instantiated with a KMS key id.');
+ }
+ $options = array_change_key_case($options);
+ if (!isset($options['@kmsencryptioncontext'])
+ || !is_array($options['@kmsencryptioncontext'])
+ ) {
+ throw new CryptoException("'@KmsEncryptionContext' is a"
+ . " required argument when using KmsMaterialsProviderV3, and"
+ . " must be an associative array (or empty array).");
+ }
+
+ if (isset($options['@kmsencryptioncontext']['aws:x-amz-cek-alg'])) {
+ throw new CryptoException("Conflict in reserved @KmsEncryptionContext"
+ . " key aws:x-amz-cek-alg. This value is reserved for the S3"
+ . " Encryption Client and cannot be set by the user.");
+ }
+
+ if (isset($options['@kmsencryptioncontext']['kms_cmk_id'])) {
+ throw new CryptoException("Conflict in reserved @KmsEncryptionContext"
+ . " key kms_cmk_id. This value is reserved for the S3"
+ . " Encryption Client and cannot be set by the user.");
+ }
+ $context = array_merge($options['@kmsencryptioncontext'], $context);
+ $result = $this->kmsClient->generateDataKey([
+ 'KeyId' => $this->kmsKeyId,
+ 'KeySpec' => "AES_{$keySize}",
+ 'EncryptionContext' => $context
+ ]);
+
+ return [
+ 'Plaintext' => $result['Plaintext'],
+ 'Ciphertext' => base64_encode($result['CiphertextBlob']),
+ 'UpdatedContext' => $context
+ ];
+ }
+}
diff --git a/src/Crypto/MaterialsProviderInterfaceV3.php b/src/Crypto/MaterialsProviderInterfaceV3.php
new file mode 100644
index 0000000000..13b255db6c
--- /dev/null
+++ b/src/Crypto/MaterialsProviderInterfaceV3.php
@@ -0,0 +1,61 @@
+ true
+ ];
+
+ /**
+ * Returns if the requested size is supported by AES.
+ *
+ * @param int $keySize Size of the requested key in bits.
+ *
+ * @return bool
+ */
+ public static function isSupportedKeySize(int $keySize): bool
+ {
+ return isset(self::$supportedKeySizes[$keySize]);
+ }
+
+ /**
+ * Returns the wrap algorithm name for this Provider.
+ *
+ * @return string
+ */
+ abstract public function getWrapAlgorithmName(): string;
+
+ /**
+ * Takes an encrypted content encryption key (CEK) and material description
+ * for use decrypting the key according to the Provider's specifications.
+ *
+ * @param string $encryptedCek Encrypted key to be decrypted by the Provider
+ * for use decrypting other data.
+ * @param array $materialDescription Material Description for use in
+ * decrypting the CEK.
+ * @param array $options Options for use in decrypting the CEK.
+ *
+ * @return string
+ */
+ abstract public function decryptCek(
+ string $encryptedCek,
+ array $materialDescription,
+ array $options
+ ): string;
+
+ /**
+ * @param string $keySize Length of a cipher key in bits for generating a
+ * random content encryption key (CEK).
+ * @param array $context Context map needed for key encryption
+ * @param array $options Additional options to be used in CEK generation
+ *
+ * @return array
+ */
+ abstract public function generateCek(
+ string $keySize,
+ array $context,
+ array $options
+ ): array;
+
+ /**
+ * @param string $openSslName Cipher OpenSSL name to use for generating
+ * an initialization vector.
+ *
+ * @return string
+ */
+ public function generateIv(string $openSslName): string
+ {
+ $iv = null;
+ $cstrong = null;
+
+ if ($openSslName === "aes-96-gcm") {
+ $iv = openssl_random_pseudo_bytes(12, $cstrong);
+ } else if ($openSslName === "aes-224-gcm") {
+ $iv = openssl_random_pseudo_bytes(28, $cstrong);
+ } else {
+ $iv = openssl_random_pseudo_bytes(
+ openssl_cipher_iv_length($openSslName),
+ $cstrong
+ );
+ }
+ if (!$cstrong) {
+ throw new CryptoException("No strong cryptographic source available to generate a random IV.");
+ }
+
+ return $iv;
+ }
+}
diff --git a/src/Crypto/MetadataEnvelope.php b/src/Crypto/MetadataEnvelope.php
index 5a7c692054..f24a8da490 100644
--- a/src/Crypto/MetadataEnvelope.php
+++ b/src/Crypto/MetadataEnvelope.php
@@ -16,13 +16,63 @@ class MetadataEnvelope implements ArrayAccess, IteratorAggregate, JsonSerializab
{
use HasDataTrait;
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# The "x-amz-" prefix denotes that the metadata is owned by an Amazon product
+ //# and MUST be prepended to all S3EC metadata mapkeys.
+
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-key-v2" MUST be present for V2 format objects.
const CONTENT_KEY_V2_HEADER = 'x-amz-key-v2';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-3") SHOULD be represented by a constant named "ENCRYPTED_DATA_KEY_V3" or similar in the implementation code.
+ const ENCRYPTED_DATA_KEY_V3 = 'x-amz-3';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-iv" MUST be present for V1 format objects.
const IV_HEADER = 'x-amz-iv';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-matdesc" MUST be present for V1 format objects.
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-matdesc" MUST be present for V2 format objects.
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-iv" MUST be present for V2 format objects.
const MATERIALS_DESCRIPTION_HEADER = 'x-amz-matdesc';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-m") SHOULD be represented by a constant named "MAT_DESC_V3" or similar in the implementation code.
+ const MAT_DESC_V3 = 'x-amz-m';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-wrap-alg" MUST be present for V2 format objects.
const KEY_WRAP_ALGORITHM_HEADER = 'x-amz-wrap-alg';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-w") SHOULD be represented by a constant named "ENCRYPTED_DATA_KEY_ALGORITHM_V3" or similar in the implementation code.
+ const ENCRYPTED_DATA_KEY_ALGORITHM_V3 = 'x-amz-w';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-cek-alg" MUST be present for V2 format objects.
const CONTENT_CRYPTO_SCHEME_HEADER = 'x-amz-cek-alg';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-c") SHOULD be represented by a constant named "CONTENT_CIPHER_V3" or similar in the implementation code.
+ const CONTENT_CIPHER_V3 = 'x-amz-c';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-tag-len" MUST be present for V2 format objects.
const CRYPTO_TAG_LENGTH_HEADER = 'x-amz-tag-len';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-unencrypted-content-length" SHOULD be present for V1 format objects.
const UNENCRYPTED_CONTENT_LENGTH_HEADER = 'x-amz-unencrypted-content-length';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-t") SHOULD be represented by a constant named "ENCRYPTION_CONTEXT_V3" or similar in the implementation code.
+ const ENCRYPTION_CONTEXT_V3 = 'x-amz-t';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-d") SHOULD be represented by a constant named "KEY_COMMITMENT_V3" or similar in the implementation code.
+ const KEY_COMMITMENT_V3 = 'x-amz-d';
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - This mapkey ("x-amz-i") SHOULD be represented by a constant named "MESSAGE_ID_V3" or similar in the implementation code.
+ const MESSAGE_ID_V3 = 'x-amz-i';
private static $constants = [];
@@ -30,6 +80,8 @@ public static function getConstantValues()
{
if (empty(self::$constants)) {
$reflection = new \ReflectionClass(static::class);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# The "x-amz-meta-" prefix is automatically added by the S3 server and MUST NOT be included in implementation code.
foreach (array_values($reflection->getConstants()) as $constant) {
self::$constants[$constant] = true;
}
@@ -45,6 +97,8 @@ public static function getConstantValues()
public function offsetSet($name, $value)
{
$constants = self::getConstantValues();
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //# In general, if there is any deviation from the above format, with the exception of additional unrelated mapkeys, then the S3EC SHOULD throw an exception.
if (is_null($name) || !in_array($name, $constants)) {
throw new InvalidArgumentException('MetadataEnvelope fields must'
. ' must match a predefined offset; use the header constants.');
@@ -58,4 +112,96 @@ public function jsonSerialize()
{
return $this->data;
}
+
+ public static function isV2Envelope(MetadataEnvelope $envelope): bool
+ {
+ if (!isset($envelope[MetadataEnvelope::CONTENT_KEY_V2_HEADER])
+ || !isset($envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER])
+ || !isset($envelope[MetadataEnvelope::IV_HEADER])
+ || !isset($envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER])
+ || !isset($envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER])
+ || !isset($envelope[MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER])
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ public static function isV1Envelope(MetadataEnvelope $envelope): bool
+ {
+ if (!isset($envelope[MetadataEnvelope::CONTENT_KEY_V2_HEADER])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-matdesc" MUST be present for V1 format objects.
+ || !isset($envelope[MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-iv" MUST be present for V1 format objects.
+ || !isset($envelope[MetadataEnvelope::IV_HEADER])
+ || !isset($envelope[MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER])
+ || !isset($envelope[MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# - The mapkey "x-amz-unencrypted-content-length" SHOULD be present for V1 format objects.
+ || !isset($envelope[MetadataEnvelope::UNENCRYPTED_CONTENT_LENGTH_HEADER])
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ public static function isV3Envelope(MetadataEnvelope $envelope): bool
+ {
+
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - The mapkey "x-amz-3" MUST be present for V3 format objects.
+ if (!isset($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - The mapkey "x-amz-c" MUST be present for V3 format objects.
+ || !isset($envelope[MetadataEnvelope::CONTENT_CIPHER_V3])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - The mapkey "x-amz-d" MUST be present for V3 format objects.
+ || !isset($envelope[MetadataEnvelope::KEY_COMMITMENT_V3])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - The mapkey "x-amz-i" MUST be present for V3 format objects.
+ || !isset($envelope[MetadataEnvelope::MESSAGE_ID_V3])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - The mapkey "x-amz-w" MUST be present for V3 format objects.
+ || !isset($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3])
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=implication
+ //# - The mapkey "x-amz-t" SHOULD be present for V3 format objects that use KMS Encryption Context.
+ || !isset($envelope[MetadataEnvelope::ENCRYPTION_CONTEXT_V3])
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public static function getV2Fields(): array
+ {
+ return [
+ MetadataEnvelope::CONTENT_KEY_V2_HEADER,
+ MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER,
+ MetadataEnvelope::IV_HEADER,
+ MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER,
+ MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER,
+ MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER
+ ];
+ }
+
+ public static function getV3Fields(): array
+ {
+ return [
+ MetadataEnvelope::ENCRYPTED_DATA_KEY_V3,
+ MetadataEnvelope::CONTENT_CIPHER_V3,
+ MetadataEnvelope::KEY_COMMITMENT_V3,
+ MetadataEnvelope::MESSAGE_ID_V3,
+ MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3,
+ MetadataEnvelope::ENCRYPTION_CONTEXT_V3
+ ];
+ }
}
diff --git a/src/MetricsBuilder.php b/src/MetricsBuilder.php
index af9b79fa1c..6f1d4a75a9 100644
--- a/src/MetricsBuilder.php
+++ b/src/MetricsBuilder.php
@@ -22,6 +22,7 @@ final class MetricsBuilder
const S3_TRANSFER = "G";
const S3_CRYPTO_V1N = "H";
const S3_CRYPTO_V2 = "I";
+ const S3_CRYPTO_V3 = "AE";
const S3_EXPRESS_BUCKET = "J";
const GZIP_REQUEST_COMPRESSION = "L";
const ENDPOINT_OVERRIDE = "N";
diff --git a/src/S3/Crypto/CryptoParamsTrait.php b/src/S3/Crypto/CryptoParamsTrait.php
index 57253a4d32..1bd796a42f 100644
--- a/src/S3/Crypto/CryptoParamsTrait.php
+++ b/src/S3/Crypto/CryptoParamsTrait.php
@@ -19,6 +19,8 @@ protected function getMaterialsProvider(array $args)
protected function getInstructionFileSuffix(array $args)
{
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //# Instruction File writes MUST be optionally configured during client creation or on each PutObject request.
return !empty($args['@InstructionFileSuffix'])
? $args['@InstructionFileSuffix']
: $this->instructionFileSuffix;
@@ -28,7 +30,9 @@ protected function determineGetObjectStrategy(
$result,
$instructionFileSuffix
) {
- if (isset($result['Metadata'][MetadataEnvelope::CONTENT_KEY_V2_HEADER])) {
+ if (isset($result['Metadata'][MetadataEnvelope::CONTENT_KEY_V2_HEADER]) ||
+ isset($result['Metadata'][MetadataEnvelope::ENCRYPTED_DATA_KEY_V3])
+ ) {
return new HeadersMetadataStrategy();
}
diff --git a/src/S3/Crypto/CryptoParamsTraitV2.php b/src/S3/Crypto/CryptoParamsTraitV2.php
index 05498176cf..9302bc3e26 100644
--- a/src/S3/Crypto/CryptoParamsTraitV2.php
+++ b/src/S3/Crypto/CryptoParamsTraitV2.php
@@ -16,4 +16,19 @@ protected function getMaterialsProvider(array $args)
throw new \InvalidArgumentException('An instance of MaterialsProviderInterfaceV2'
. ' must be passed in the "MaterialsProvider" field.');
}
+
+ protected function getKeyCommitmentPolicy(array $args): string
+ {
+ if (empty($args['@CommitmentPolicy'])) {
+ throw new \InvalidArgumentException('A commitment policy must be'
+ . ' specified in the CommitmentPolicy field.');
+ }
+
+ if (!S3EncryptionClientV2::isSupportedKeyCommitmentPolicy($args['@CommitmentPolicy'])) {
+ throw new \InvalidArgumentException('The CommitmentPolicy requested is not'
+ . ' supported by the SDK.');
+ }
+
+ return $args['@CommitmentPolicy'];
+ }
}
diff --git a/src/S3/Crypto/CryptoParamsTraitV3.php b/src/S3/Crypto/CryptoParamsTraitV3.php
new file mode 100644
index 0000000000..1ff4483a24
--- /dev/null
+++ b/src/S3/Crypto/CryptoParamsTraitV3.php
@@ -0,0 +1,34 @@
+client->putObject([
'Bucket' => $args['Bucket'],
'Key' => $args['Key'] . $this->suffix,
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //= type=implication
+ //# The content metadata stored in the Instruction File MUST be serialized to a JSON string.
'Body' => json_encode($envelope)
]);
@@ -80,11 +130,55 @@ public function load(array $args)
$constantValues = MetadataEnvelope::getConstantValues();
foreach ($constantValues as $constant) {
- if (!empty($metadataHeaders[$constant])) {
- $envelope[$constant] = $metadataHeaders[$constant];
+ if (isset($metadataHeaders[$constant])) {
+ // check for a duplicate
+ if (empty($envelope[$constant])) {
+ $envelope[$constant] = $metadataHeaders[$constant];
+ } else {
+ throw new CryptoException("Duplicate keys are not allowed"
+ . " in Instruction Files. "
+ );
+ }
}
}
+ // check if we are reading a V3 object
+ // if it is a V3 object some data is stored in the object metadata and some
+ // as in the instruction file
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //# In the V3 format, the mapkeys "x-amz-c", "x-amz-d", and "x-amz-i"
+ //# MUST be stored exclusively in the Object Metadata
+ if (!empty($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3])) {
+ // before loading the rest of the v3 metadata, we must check that:
+ // 1. the following values are not already present in the envelope, if they are
+ // the instruction file is not correct.
+ if (!empty($envelope[MetadataEnvelope::CONTENT_CIPHER_V3])
+ || !empty($envelope[MetadataEnvelope::KEY_COMMITMENT_V3])
+ || !empty($envelope[MetadataEnvelope::MESSAGE_ID_V3])
+ ) {
+ throw new CryptoException("One or more reserved keys found in"
+ . " Instruction file when they should not be present.");
+ }
+ // this data is stored in the original object's metadata
+ // V3 added x-amz-c, x-amz-d, x-amz-i, x-amz-3, x-amz-w, x-amz-m, x-amz-t
+ // x-amz-c, x-amz-d, x-amz-i are strictly stored on the object metadata
+ // the rest are stored in the instruction file
+ $envelope[MetadataEnvelope::CONTENT_CIPHER_V3] = $args['Metadata'][MetadataEnvelope::CONTENT_CIPHER_V3];
+ $envelope[MetadataEnvelope::KEY_COMMITMENT_V3] = $args['Metadata'][MetadataEnvelope::KEY_COMMITMENT_V3];
+ $envelope[MetadataEnvelope::MESSAGE_ID_V3] = $args['Metadata'][MetadataEnvelope::MESSAGE_ID_V3];
+
+ if (!MetadataEnvelope::isV3Envelope($envelope)) {
+ throw new CryptoException("Expected a V3 envelope but was unable to"
+ . " constuct one.");
+ }
+ return $envelope;
+ }
+ // if we are not reading a v3 object then it must be a v2 object
+ if (!MetadataEnvelope::isV2Envelope($envelope)
+ && !MetadataEnvelope::isV1Envelope($envelope)) {
+ throw new CryptoException("Malformed metadata envelope.");
+ }
+
return $envelope;
}
}
diff --git a/src/S3/Crypto/README.md b/src/S3/Crypto/README.md
new file mode 100644
index 0000000000..a518276255
--- /dev/null
+++ b/src/S3/Crypto/README.md
@@ -0,0 +1,81 @@
+# Amazon S3 Encryption Client for PHP V3
+
+This library provides an S3 client that supports client-side encryption.
+`S3EncryptionClientV3` is the v3 of the Amazon S3 Encryption Client for the PHP programming language.
+
+The V3 encryption client requires a minimum version of **PHP >= 8.1**.
+The V3 encryption client requires the extension `openssl`.
+
+Jump To:
+
+* [Migration](#migration)
+
+## Quick Examples
+
+### Create an Amazon S3 Encryption Client
+
+```php
+ 'latest',
+ 'region' => 'us-west-2'
+]);
+
+// Instantiate an Amazon S3 Encryption Client V3.
+$client = new S3EncryptionClientV3($s3Client);
+
+### Upload a file to Amazon S3 using client side encryption
+
+$kmsKeyId = 'kms-key-id';
+$materialsProvider = new KmsMaterialsProviderV3(
+ new KmsClient([
+ 'profile' => 'default',
+ 'region' => 'us-east-1',
+ 'version' => 'latest',
+ ]),
+ $kmsKeyId
+);
+
+$bucket = 'the-bucket-name';
+$key = 'the-file-name';
+$cipherOptions = [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256,
+ // Additional configuration options
+];
+
+$result = $client->putObject([
+ '@MaterialsProvider' => $materialsProvider,
+ '@CipherOptions' => $cipherOptions,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@KmsEncryptionContext' => ['context-key' => 'context-value'],
+ 'Bucket' => $bucket,
+ 'Key' => $key,
+ 'Body' => fopen('file-to-encrypt.txt', 'r'),
+]);
+
+```
+
+## Migration
+
+This version of the library supports reading encrypted objects from previous versions with extra configuration.
+It also supports writing objects with non-legacy algorithms.
+The list of legacy modes and operations will be provided below.
+
+* [2.x to 3.x Migration Guide](https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/s3-encryption-migration-v2-v3.html)
+* [1.x to 2.x Migration Guide](https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/s3-encryption-migration-v1-v2.html)
+
+## Security
+
+See [CONTRIBUTING](../../../CONTRIBUTING.md#security-issue-notifications) for more information.
+
+## License
+
+This project is licensed under the Apache-2.0 License.
diff --git a/src/S3/Crypto/S3EncryptionClientV2.php b/src/S3/Crypto/S3EncryptionClientV2.php
index fe917800f7..5b63bb24b4 100644
--- a/src/S3/Crypto/S3EncryptionClientV2.php
+++ b/src/S3/Crypto/S3EncryptionClientV2.php
@@ -20,11 +20,11 @@
* Provides a wrapper for an S3Client that supplies functionality to encrypt
* data on putObject[Async] calls and decrypt data on getObject[Async] calls.
*
- * AWS strongly recommends the upgrade to the S3EncryptionClientV2 (over the
- * S3EncryptionClient), as it offers updated data security best practices to our
- * customers who upgrade. S3EncryptionClientV2 contains breaking changes, so this
+ * AWS strongly recommends the upgrade to the S3EncryptionClientV3 (over the
+ * S3EncryptionClientV2), as it offers updated data security best practices to our
+ * customers who upgrade. S3EncryptionClientV3 contains breaking changes, so this
* will require planning by engineering teams to migrate. New workflows should
- * just start with S3EncryptionClientV2.
+ * just start with S3EncryptionClientV3.
*
* Note that for PHP versions of < 7.1, this class uses an AES-GCM polyfill
* for encryption since there is no native PHP support. The performance for large
@@ -75,6 +75,7 @@
* 'Cipher' => 'gcm',
* 'KeySize' => 256,
* ],
+ * '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
* 'Bucket' => 'your-bucket',
* 'Key' => 'your-key',
* ]);
@@ -293,7 +294,10 @@ public function putObject(array $args)
* - 'V2_AND_LEGACY' indicates that objects encrypted with both
* S3EncryptionClientV2 and older legacy encryption clients are able
* to be decrypted.
- *
+ * - @CommitmentPolicy: (string) Must be set to 'FORBID_ENCRYPT_ALLOW_DECRYPT'.
+ * - 'FORBID_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to read messages encrypted with key commitment or without key commitment.
+ *
* The optional configuration arguments are as follows:
*
* - SaveAs: (string) The path to a file on disk to save the decrypted
@@ -326,6 +330,8 @@ public function getObjectAsync(array $args)
$provider = $this->getMaterialsProvider($args);
unset($args['@MaterialsProvider']);
+ $keyCommitmentPolicy = $this->getKeyCommitmentPolicy($args);
+
$instructionFileSuffix = $this->getInstructionFileSuffix($args);
unset($args['@InstructionFileSuffix']);
@@ -346,9 +352,9 @@ public function getObjectAsync(array $args)
$this->legacyWarningCount++;
trigger_error(
"This S3 Encryption Client operation is configured to"
- . " read encrypted data with legacy encryption modes. If you"
- . " don't have objects encrypted with these legacy modes,"
- . " you should disable support for them to enhance security. ",
+ . " read encrypted data with legacy encryption modes. If you"
+ . " don't have objects encrypted with these legacy modes,"
+ . " you should disable support for them to enhance security. ",
E_USER_WARNING
);
}
@@ -418,6 +424,10 @@ function ($result) use ($saveAs) {
* - 'V2_AND_LEGACY' indicates that objects encrypted with both
* S3EncryptionClientV2 and older legacy encryption clients are able
* to be decrypted.
+ * - @CommitmentPolicy: (string) Must be set to 'FORBID_ENCRYPT_ALLOW_DECRYPT'.
+ * - 'FORBID_ENCRYPT_ALLOW_DECRYPT' indicates that the client is
+ * configured to read messages encrypted with key commitment
+ * or without key commitment.
*
* The optional configuration arguments are as follows:
*
diff --git a/src/S3/Crypto/S3EncryptionClientV3.php b/src/S3/Crypto/S3EncryptionClientV3.php
new file mode 100644
index 0000000000..4fcd942876
--- /dev/null
+++ b/src/S3/Crypto/S3EncryptionClientV3.php
@@ -0,0 +1,581 @@
+
+ * use Aws\Crypto\KmsMaterialsProviderV3;
+ * use Aws\S3\Crypto\S3EncryptionClientV3;
+ * use Aws\S3\S3Client;
+ *
+ * $encryptionClient = new S3EncryptionClientV3(
+ * new S3Client([
+ * 'region' => 'us-west-2',
+ * 'version' => 'latest'
+ * ])
+ * );
+ * $materialsProvider = new KmsMaterialsProviderV3(
+ * new KmsClient([
+ * 'profile' => 'default',
+ * 'region' => 'us-east-1',
+ * 'version' => 'latest',
+ * ],
+ * 'your-kms-key-id'
+ * );
+ *
+ * $encryptionClient->putObject([
+ * '@MaterialsProvider' => $materialsProvider,
+ * '@CipherOptions' => [
+ * 'Cipher' => 'gcm',
+ * 'KeySize' => 256,
+ * ],
+ * '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ * '@KmsEncryptionContext' => ['foo' => 'bar'],
+ * 'Bucket' => 'your-bucket',
+ * 'Key' => 'your-key',
+ * 'Body' => 'your-encrypted-data',
+ * ]);
+ *
+ *
+ * Example read call (using objects from previous example):
+ *
+ *
+ * $encryptionClient->getObject([
+ * '@MaterialsProvider' => $materialsProvider,
+ * '@CipherOptions' => [
+ * 'Cipher' => 'gcm',
+ * 'KeySize' => 256,
+ * ],
+ * '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ * 'Bucket' => 'your-bucket',
+ * 'Key' => 'your-key',
+ * ]);
+ *
+ */
+class S3EncryptionClientV3 extends AbstractCryptoClientV3
+{
+ use CipherBuilderTrait;
+ use CryptoParamsTraitV3;
+ use DecryptionTraitV3;
+ use EncryptionTraitV3;
+ use UserAgentTrait;
+
+ const CRYPTO_VERSION = '3.0';
+
+ private S3Client $client;
+ private ?string $instructionFileSuffix;
+ private int $legacyWarningCount;
+
+ /**
+ * @param S3Client $client The S3Client to be used for true uploading and
+ * retrieving objects from S3 when using the
+ * encryption client.
+ * @param string|null $instructionFileSuffix Suffix for a client wide
+ * default when using instruction
+ * files for metadata storage.
+ */
+ public function __construct(
+ //= ../specification/s3-encryption/client.md#wrapped-s3-client-s
+ //= type=implication
+ //# The S3EC MUST support the option to provide an SDK S3 client instance during its initialization.
+ S3Client $client,
+ //= ../specification/s3-encryption/client.md#aws-sdk-compatibility
+ //= type=implication
+ //# The S3EC MUST provide a different set of configuration options than the conventional S3 client.
+
+ //= ../specification/s3-encryption/client.md#instruction-file-configuration
+ //= type=implication
+ //# In this case, the Instruction File Configuration SHOULD be optional,
+ //# such that its default configuration is used when none is provided.
+ ?string $instructionFileSuffix = null
+ ) {
+ //= ../specification/s3-encryption/client.md#aws-sdk-compatibility
+ //= type=implication
+ //# The S3EC MUST adhere to the same interface for API operations as the conventional AWS SDK S3 client.
+ $this->appendUserAgent($client, 'feat/s3-encrypt-php/' . self::CRYPTO_VERSION);
+ //= ../specification/s3-encryption/client.md#wrapped-s3-client-s
+ //# The S3EC MUST NOT support use of S3EC as the provided S3 client during its initialization;
+ //# it MUST throw an exception in this case.
+ if ($client instanceof S3EncryptionClientV3) {
+ throw new CryptoException("Client configuration error."
+ . " An S3 Encryption Client is not a valid S3 client for an S3 Encryption Client.");
+ }
+ $this->client = $client;
+ //= ../specification/s3-encryption/client.md#instruction-file-configuration
+ //= type=implication
+ //# The S3EC MAY support the option to provide Instruction File Configuration during its initialization.
+
+ //= ../specification/s3-encryption/client.md#instruction-file-configuration
+ //= type=implication
+ //# If the S3EC in a given language supports Instruction Files,
+ //# then it MUST accept Instruction File Configuration during its initialization.
+ $this->instructionFileSuffix = $instructionFileSuffix;
+ $this->legacyWarningCount = 0;
+ MetricsBuilder::appendMetricsCaptureMiddleware(
+ $this->client->getHandlerList(),
+ MetricsBuilder::S3_CRYPTO_V3
+ );
+
+ if (!extension_loaded('openssl')) {
+ throw new CryptoException("Unable to load `openssl` extension.");
+ }
+ }
+
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //# Instruction File writes MUST NOT be enabled by default.
+ private static function getDefaultStrategy(): HeadersMetadataStrategy
+ {
+ return new HeadersMetadataStrategy();
+ }
+
+ /**
+ * Encrypts the data in the 'Body' field of $args and promises to upload it
+ * to the specified location on S3.
+ *
+ * @param array $args Arguments for encrypting an object and uploading it
+ * to S3 via PutObject.
+ *
+ * The required configuration arguments are as follows:
+ *
+ * - @MaterialsProvider: (MaterialsProviderV3) Provides Cek, Iv, and Cek
+ * encrypting/decrypting for encryption metadata.
+ * - @CommitmentPolicy: (string) Must be set to 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ * 'REQUIRE_ENCRYPT_ALLOW_DECRYPT', or 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT'.
+ * - 'FORBID_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages without key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment.
+ * - @CipherOptions: (array) Cipher options for encrypting data. Only the
+ * Cipher option is required. Accepts the following:
+ * - Cipher: (string) gcm
+ * See also: AbstractCryptoClientV3::$supportedCiphers
+ * - KeySize: (int) 256
+ * See also: MaterialsProvider::$supportedKeySizes
+ * - Aad: (string) Additional authentication data. This option is
+ * passed directly to OpenSSL when using gcm. Note if you pass in
+ * Aad, the PHP SDK will be able to decrypt the resulting object,
+ * but other AWS SDKs may not be able to do so.
+ * - @KmsEncryptionContext: (array) Only required if using
+ * KmsMaterialsProviderV3. An associative array of key-value
+ * pairs to be added to the encryption context for KMS key encryption. An
+ * empty array may be passed if no additional context is desired.
+ *
+ * The optional configuration arguments are as follows:
+ *
+ * - @MetadataStrategy: (MetadataStrategy|string|null) Strategy for storing
+ * MetadataEnvelope information. Defaults to using a
+ * HeadersMetadataStrategy. Can either be a class implementing
+ * MetadataStrategy, a class name of a predefined strategy, or empty/null
+ * to default.
+ * - @InstructionFileSuffix: (string|null) Suffix used when writing to an
+ * instruction file if using an InstructionFileMetadataHandler.
+ *
+ * @return PromiseInterface
+ *
+ * @throws \InvalidArgumentException Thrown when arguments above are not
+ * passed or are passed incorrectly.
+ */
+ public function putObjectAsync(array $args): PromiseInterface
+ {
+ //= ../specification/s3-encryption/client.md#cryptographic-materials
+ //= type=implication
+ //# The S3EC MUST accept either one CMM or one Keyring instance upon initialization.
+ $provider = $this->getMaterialsProvider($args);
+ unset($args['@MaterialsProvider']);
+
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=implication
+ //# The S3EC MUST support configuration of the [Key Commitment policy](./key-commitment.md) during its initialization.
+ $keyCommitmentPolicy = $this->getKeyCommitmentPolicy($args);
+ unset($args['@CommitmentPolicy']);
+
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //# Instruction File writes MUST be optionally configured during client creation or on each PutObject request.
+ $instructionFileSuffix = $this->getInstructionFileSuffix($args);
+ unset($args['@InstructionFileSuffix']);
+
+ $strategy = $this->getMetadataStrategy($args, $instructionFileSuffix);
+ unset($args['@MetadataStrategy']);
+
+ //= ../specification/s3-encryption/client.md#encryption-algorithm
+ //= type=implication
+ //# The S3EC MUST support configuration of the encryption algorithm (or algorithm suite) during its initialization.
+ $options = array_change_key_case($args);
+ $cipherOptions = array_intersect_key(
+ $options['@cipheroptions'],
+ self::$allowedOptions
+ );
+ if (empty($cipherOptions['Cipher'])) {
+ throw new \InvalidArgumentException('An encryption cipher must be'
+ . ' specified in @CipherOptions["Cipher"].');
+ }
+
+ $algorithmSuite = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ $keyCommitmentPolicy
+ );
+
+ $envelope = new MetadataEnvelope();
+
+ return Promise\Create::promiseFor(
+ //= ../specification/s3-encryption/client.md#required-api-operations
+ //= type=implication
+ //# - PutObject MUST encrypt its input data before it is uploaded to S3.
+ $this->encrypt(
+ Psr7\Utils::streamFor($args['Body']),
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ )
+ )->then(
+ function ($encryptedBodyStream) use ($args) {
+ $hash = new PhpHash('sha256');
+ $hashingEncryptedBodyStream = new HashingStream(
+ $encryptedBodyStream,
+ $hash,
+ self::getContentShaDecorator($args)
+ );
+
+ return [$hashingEncryptedBodyStream, $args];
+ }
+ )->then(
+ function ($putObjectContents) use ($strategy, $envelope) {
+ list($bodyStream, $args) = $putObjectContents;
+ if ($strategy === null) {
+ $strategy = self::getDefaultStrategy();
+ }
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+ //# By default, the S3EC MUST store content metadata in the S3 Object Metadata.
+ $updatedArgs = $strategy->save($envelope, $args);
+ $updatedArgs['Body'] = $bodyStream;
+
+ return $updatedArgs;
+ }
+ )->then(
+ function ($args) {
+ unset($args['@CipherOptions']);
+
+ return $this->client->putObjectAsync($args);
+ }
+ );
+ }
+
+ private static function getContentShaDecorator(&$args): \Closure
+ {
+ return function ($hash) use (&$args) {
+ $args['ContentSHA256'] = bin2hex($hash);
+ };
+ }
+
+ /**
+ * Encrypts the data in the 'Body' field of $args and uploads it to the
+ * specified location on S3.
+ *
+ * @param array $args Arguments for encrypting an object and uploading it
+ * to S3 via PutObject.
+ *
+ * The required configuration arguments are as follows:
+ *
+ * - @MaterialsProvider: (MaterialsProvider) Provides Cek, Iv, and Cek
+ * encrypting/decrypting for encryption metadata.
+ * - @CommitmentPolicy: (string) Must be set to 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ * 'REQUIRE_ENCRYPT_ALLOW_DECRYPT', or 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT'.
+ * - 'FORBID_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages without key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment.
+ * - @CipherOptions: (array) Cipher options for encrypting data. A Cipher
+ * is required. Accepts the following options:
+ * - Cipher: (string) gcm
+ * See also: AbstractCryptoClientV3::$supportedCiphers
+ * - KeySize: (int) 128|256
+ * See also: MaterialsProvider::$supportedKeySizes
+ * - Aad: (string) Additional authentication data. This option is
+ * passed directly to OpenSSL when using gcm. Note if you pass in
+ * Aad, the PHP SDK will be able to decrypt the resulting object,
+ * but other AWS SDKs may not be able to do so.
+ * - @KmsEncryptionContext: (array) Only required if using
+ * KmsMaterialsProviderV3. An associative array of key-value
+ * pairs to be added to the encryption context for KMS key encryption. An
+ * empty array may be passed if no additional context is desired.
+ *
+ * The optional configuration arguments are as follows:
+ *
+ * - @MetadataStrategy: (MetadataStrategy|string|null) Strategy for storing
+ * MetadataEnvelope information. Defaults to using a
+ * HeadersMetadataStrategy. Can either be a class implementing
+ * MetadataStrategy, a class name of a predefined strategy, or empty/null
+ * to default.
+ * - @InstructionFileSuffix: (string|null) Suffix used when writing to an
+ * instruction file if an using an InstructionFileMetadataHandler was
+ * determined.
+ *
+ * @return \Aws\Result PutObject call result with the details of uploading
+ * the encrypted file.
+ *
+ * @throws \InvalidArgumentException Thrown when arguments above are not
+ * passed or are passed incorrectly.
+ */
+ public function putObject(array $args): Result
+ {
+ //= ../specification/s3-encryption/client.md#required-api-operations
+ //= type=implication
+ //# - PutObject MUST be implemented by the S3EC.
+ return $this->putObjectAsync($args)->wait();
+ }
+
+ /**
+ * Promises to retrieve an object from S3 and decrypt the data in the
+ * 'Body' field.
+ *
+ * @param array $args Arguments for retrieving an object from S3 via
+ * GetObject and decrypting it.
+ *
+ * The required configuration argument is as follows:
+ *
+ * - @MaterialsProvider: (MaterialsProviderInterface) Provides Cek, Iv, and Cek
+ * encrypting/decrypting for decryption metadata. May have data loaded
+ * from the MetadataEnvelope upon decryption.
+ * - @CommitmentPolicy: (string) Must be set to 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ * 'REQUIRE_ENCRYPT_ALLOW_DECRYPT', or 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT'.
+ * - 'FORBID_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages without key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment.
+ * - @SecurityProfile: (string) Must be set to 'V3' or 'V3_AND_LEGACY'.
+ * - 'V3' indicates that only objects encrypted with S3EncryptionClientV3
+ * content encryption and key wrap schemas are able to be decrypted.
+ * - 'V3_AND_LEGACY' indicates that objects encrypted with both
+ * S3EncryptionClientV3 and older legacy encryption clients are able
+ * to be decrypted.
+ *
+ * The optional configuration arguments are as follows:
+ *
+ * - SaveAs: (string) The path to a file on disk to save the decrypted
+ * object data. This will be handled by file_put_contents instead of the
+ * Guzzle sink.
+ *
+ * - @MetadataStrategy: (MetadataStrategy|string|null) Strategy for reading
+ * MetadataEnvelope information. Defaults to determining based on object
+ * response headers. Can either be a class implementing MetadataStrategy,
+ * a class name of a predefined strategy, or empty/null to default.
+ * - @InstructionFileSuffix: (string) Suffix used when looking for an
+ * instruction file if an InstructionFileMetadataHandler is being used.
+ * - @CipherOptions: (array) Cipher options for decrypting data. A Cipher
+ * is required. Accepts the following options:
+ * - Aad: (string) Additional authentication data. This option is
+ * passed directly to OpenSSL when using gcm. It is ignored when
+ * using cbc.
+ * - @KmsAllowDecryptWithAnyCmk: (bool) This allows decryption with
+ * KMS materials for any KMS key ID, instead of needing the KMS key ID to
+ * be specified and provided to the decrypt operation. Ignored for non-KMS
+ * materials providers. Defaults to false.
+ *
+ * @return PromiseInterface
+ *
+ * @throws \InvalidArgumentException Thrown when required arguments are not
+ * passed or are passed incorrectly.
+ */
+ public function getObjectAsync(array $args): PromiseInterface
+ {
+ //= ../specification/s3-encryption/client.md#cryptographic-materials
+ //= type=implication
+ //# The S3EC MUST accept either one CMM or one Keyring instance upon initialization.
+ $provider = $this->getMaterialsProvider($args);
+ unset($args['@MaterialsProvider']);
+
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=implication
+ //# The S3EC MUST support configuration of the [Key Commitment policy](./key-commitment.md) during its initialization.
+ $keyCommitmentPolicy = $this->getKeyCommitmentPolicy($args);
+ unset($args['@CommitmentPolicy']);
+
+ $instructionFileSuffix = $this->getInstructionFileSuffix($args);
+ unset($args['@InstructionFileSuffix']);
+
+ $strategy = $this->getMetadataStrategy($args, $instructionFileSuffix);
+ unset($args['@MetadataStrategy']);
+
+ //= ../specification/s3-encryption/client.md#enable-legacy-wrapping-algorithms
+ //= type=implication
+ //# The S3EC MUST support the option to enable or disable legacy wrapping algorithms.
+ if (!isset($args['@SecurityProfile'])
+ || !in_array($args['@SecurityProfile'], self::SUPPORTED_SECURITY_PROFILES)
+ ) {
+ throw new CryptoException("@SecurityProfile is required and must be"
+ . " set to 'V3' or 'V3_AND_LEGACY'");
+ }
+
+ // Only throw this legacy warning once per client
+ if (in_array($args['@SecurityProfile'], self::LEGACY_SECURITY_PROFILES)
+ && $this->legacyWarningCount < 1
+ ) {
+ $this->legacyWarningCount++;
+ trigger_error(
+ "This S3 Encryption Client operation is configured to"
+ . " read encrypted data with legacy encryption modes. If you"
+ . " don't have objects encrypted with these legacy modes,"
+ . " you should disable support for them to enhance security. ",
+ E_USER_WARNING
+ );
+ }
+
+ $saveAs = null;
+ if (!empty($args['SaveAs'])) {
+ $saveAs = $args['SaveAs'];
+ }
+
+ $promise = $this->client->getObjectAsync($args)
+ ->then(
+ function ($result) use (
+ $provider,
+ $instructionFileSuffix,
+ $strategy,
+ $keyCommitmentPolicy,
+ $args
+ ) {
+ if ($strategy === null) {
+ $strategy = $this->determineGetObjectStrategy(
+ $result,
+ $instructionFileSuffix
+ );
+ }
+
+ $envelope = $strategy->load($args + [
+ 'Metadata' => $result['Metadata']
+ ]);
+ //= ../specification/s3-encryption/client.md#required-api-operations
+ //= type=implication
+ //# - GetObject MUST decrypt data received from the S3 server and return it as plaintext.
+ $result['Body'] = $this->decrypt(
+ $result['Body'],
+ $provider,
+ $envelope,
+ $keyCommitmentPolicy,
+ $args
+ );
+
+ return $result;
+ }
+ )->then(
+ function ($result) use ($saveAs) {
+ if (!empty($saveAs)) {
+ file_put_contents(
+ $saveAs,
+ (string)$result['Body'],
+ LOCK_EX
+ );
+ }
+
+ return $result;
+ }
+ );
+
+ return $promise;
+ }
+
+ /**
+ * Retrieves an object from S3 and decrypts the data in the 'Body' field.
+ *
+ * @param array $args Arguments for retrieving an object from S3 via
+ * GetObject and decrypting it.
+ *
+ * The required configuration argument is as follows:
+ *
+ * - @MaterialsProvider: (MaterialsProviderInterface) Provides Cek, Iv, and Cek
+ * encrypting/decrypting for decryption metadata. May have data loaded
+ * from the MetadataEnvelope upon decryption.
+ * - @CommitmentPolicy: (string) Must be set to 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ * 'REQUIRE_ENCRYPT_ALLOW_DECRYPT', or 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT'.
+ * - 'FORBID_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages without key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_ALLOW_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment or without key commitment.
+ * - 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT' indicates that the client is configured
+ * to write messages with key commitment and read messages encrypted
+ * with key commitment.
+ * - @SecurityProfile: (string) Must be set to 'V3' or 'V3_AND_LEGACY'.
+ * - 'V3' indicates that only objects encrypted with S3EncryptionClientV3
+ * content encryption and key wrap schemas are able to be decrypted.
+ * - 'V3_AND_LEGACY' indicates that objects encrypted with both
+ * S3EncryptionClientV3 and older legacy encryption clients are able
+ * to be decrypted.
+ *
+ * The optional configuration arguments are as follows:
+ *
+ * - SaveAs: (string) The path to a file on disk to save the decrypted
+ * object data. This will be handled by file_put_contents instead of the
+ * Guzzle sink.
+ * - @InstructionFileSuffix: (string|null) Suffix used when looking for an
+ * instruction file if an InstructionFileMetadataHandler was detected.
+ * - @CipherOptions: (array) Cipher options for encrypting data. A Cipher
+ * is required. Accepts the following options:
+ * - Aad: (string) Additional authentication data. This option is
+ * passed directly to OpenSSL when using gcm. It is ignored when
+ * using cbc.
+ * - @KmsAllowDecryptWithAnyCmk: (bool) This allows decryption with
+ * KMS materials for any KMS key ID, instead of needing the KMS key ID to
+ * be specified and provided to the decrypt operation. Ignored for non-KMS
+ * materials providers. Defaults to false.
+ *
+ * @return \Aws\Result GetObject call result with the 'Body' field
+ * wrapped in a decryption stream with its metadata
+ * information.
+ *
+ * @throws \InvalidArgumentException Thrown when arguments above are not
+ * passed or are passed incorrectly.
+ */
+ public function getObject(array $args): Result
+ {
+ //= ../specification/s3-encryption/client.md#required-api-operations
+ //= type=implication
+ //# - GetObject MUST be implemented by the S3EC.
+ return $this->getObjectAsync($args)->wait();
+ }
+}
diff --git a/src/S3/Crypto/S3EncryptionMultipartUploaderV3.php b/src/S3/Crypto/S3EncryptionMultipartUploaderV3.php
new file mode 100644
index 0000000000..bd4f20f7a6
--- /dev/null
+++ b/src/S3/Crypto/S3EncryptionMultipartUploaderV3.php
@@ -0,0 +1,217 @@
+appendUserAgent($client, 'feat/s3-encrypt/' . self::CRYPTO_VERSION);
+ $this->client = $client;
+ $config['params'] = [];
+ if (!empty($config['bucket'])) {
+ $config['params']['Bucket'] = $config['bucket'];
+ }
+ if (!empty($config['key'])) {
+ $config['params']['Key'] = $config['key'];
+ }
+
+ $this->provider = $this->getMaterialsProvider($config);
+ unset($config['@MaterialsProvider']);
+
+ $this->keyCommitmentPolicy = $this->getKeyCommitmentPolicy($config);
+ unset($config['@CommitmentPolicy']);
+
+ $this->instructionFileSuffix = $this->getInstructionFileSuffix($config);
+ unset($config['@InstructionFileSuffix']);
+ $this->strategy = $this->getMetadataStrategy(
+ $config,
+ $this->instructionFileSuffix
+ );
+ if ($this->strategy === null) {
+ $this->strategy = self::getDefaultStrategy();
+ }
+ unset($config['@MetadataStrategy']);
+
+ $options = array_change_key_case($config);
+ $cipherOptions = array_intersect_key(
+ $options['@cipheroptions'],
+ self::$allowedOptions
+ );
+ if (empty($cipherOptions['Cipher'])) {
+ throw new \InvalidArgumentException('An encryption cipher must be'
+ . ' specified in @CipherOptions["Cipher"].');
+ }
+
+ $this->algorithmSuite = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ $this->keyCommitmentPolicy
+ );
+
+ //= ../specification/s3-encryption/client.md#optional-api-operations
+ //= type=implication
+ //# - If implemented, CreateMultipartUpload MUST initiate a multipart upload.
+ $config['prepare_data_source'] = $this->getEncryptingDataPreparer();
+
+ parent::__construct($client, $source, $config);
+
+ if (!extension_loaded('openssl')) {
+ throw new CryptoException("Unable to load `openssl` extension.");
+ }
+ }
+
+ private static function getDefaultStrategy()
+ {
+ return new HeadersMetadataStrategy();
+ }
+
+ private function getEncryptingDataPreparer()
+ {
+ return function() {
+ // Defer encryption work until promise is executed
+ $envelope = new MetadataEnvelope();
+
+ list($this->source, $params) = Promise\Create::promiseFor($this->encrypt(
+ $this->source,
+ $this->algorithmSuite,
+ $this->config ?: [],
+ $this->provider,
+ $envelope
+ ))->then(
+ function ($bodyStream) use ($envelope) {
+ $params = $this->strategy->save(
+ $envelope,
+ $this->config['params']
+ );
+ return [$bodyStream, $params];
+ }
+ )->wait();
+
+ $this->source->rewind();
+ $this->config['params'] = $params;
+ };
+ }
+}
diff --git a/src/S3/Crypto/S3_EC_SUPPORT_POLICY.md b/src/S3/Crypto/S3_EC_SUPPORT_POLICY.md
new file mode 100644
index 0000000000..598701672f
--- /dev/null
+++ b/src/S3/Crypto/S3_EC_SUPPORT_POLICY.md
@@ -0,0 +1,19 @@
+# Overview
+
+This page describes the support policy for the Amazon S3 Encryption Client for PHP. We regularly provide the Amazon S3 Encryption Client for PHP with updates that may contain support for new or updated APIs, new features, enhancements, bug fixes, security patches, or documentation updates. Updates may also address changes with dependencies, language runtimes, and operating systems.
+
+We recommend users to stay up-to-date with Amazon S3 Encryption Client for PHP releases to keep up with the latest features, security updates, and underlying dependencies. Continued use of an unsupported SDK version is not recommended and is done at the user's discretion.
+
+# Major Version Lifecycle
+
+The Amazon S3 Encryption Client for Go follows the same major version lifecycle as the AWS SDK. For details on this lifecycle, see [AWS SDKs and Tools Maintenance Policy](https://docs.aws.amazon.com/sdkref/latest/guide/maint-policy.html#version-life-cycle).
+
+# Version Support Matrix
+
+This table describes the current support status of each major version of the Amazon S3 Encryption Client for PHP. It also shows the next status each major version will transition to, and the date at which that transition will happen.
+
+| Major version | Current status | Next status | Next status date |
+|--------------|----------------|-------------|------------------|
+| 3.x | General Availability | - | - |
+| 2.x | General Availability | Maintenance | 2026-06-15 |
+| 1.x | End of Support | - | - |
diff --git a/tests/Crypto/AlgorithmSuiteTest.php b/tests/Crypto/AlgorithmSuiteTest.php
new file mode 100644
index 0000000000..11a5e490d4
--- /dev/null
+++ b/tests/Crypto/AlgorithmSuiteTest.php
@@ -0,0 +1,499 @@
+assertSame(0x0073, AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->value);
+ $this->assertSame(0x0072, AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->value);
+ $this->assertSame(0x0070, AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->value);
+ }
+
+ /**
+ * Test getId() method returns correct values
+ */
+ public function testGetId(): void
+ {
+ $this->assertSame(0x0073, AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getId());
+ $this->assertSame(0x0072, AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getId());
+ $this->assertSame(0x0070, AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getId());
+ }
+
+ /**
+ * Test isLegacy() method - only CBC algorithm should be considered legacy
+ */
+ public function testIsLegacy(): void
+ {
+ $this->assertFalse(AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->isLegacy());
+ $this->assertFalse(AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->isLegacy());
+ $this->assertTrue(AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->isLegacy());
+ }
+
+ /**
+ * Test isKeyCommitting() method - only HKDF SHA512 algorithm should be key committing
+ */
+ public function testIsKeyCommitting(): void
+ {
+ $this->assertTrue(AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->isKeyCommitting());
+ $this->assertFalse(AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->isKeyCommitting());
+ $this->assertFalse(AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->isKeyCommitting());
+ }
+
+ /**
+ * Test getDataKeyAlgorithm() method - all should return AES
+ */
+ public function testGetDataKeyAlgorithm(): void
+ {
+ $this->assertSame("AES", AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getDataKeyAlgorithm());
+ $this->assertSame("AES", AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getDataKeyAlgorithm());
+ $this->assertSame("AES", AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getDataKeyAlgorithm());
+ }
+
+ /**
+ * Test getDataKeyLengthBits() method - all should return 256
+ */
+ public function testGetDataKeyLengthBits(): void
+ {
+ $this->assertSame("256", AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getDataKeyLengthBits());
+ $this->assertSame("256", AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getDataKeyLengthBits());
+ $this->assertSame("256", AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getDataKeyLengthBits());
+ }
+
+ /**
+ * Data provider for cipher name tests
+ */
+ public static function cipherNameProvider(): array
+ {
+ return [
+ 'HKDF SHA512 Commit Key uses GCM' => [
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ 'gcm'
+ ],
+ 'GCM IV12 TAG16 uses GCM' => [
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ 'gcm'
+ ],
+ 'CBC IV16 uses CBC' => [
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF,
+ 'cbc'
+ ],
+ ];
+ }
+
+ /**
+ * Test getCipherName() method
+ * @dataProvider cipherNameProvider
+ */
+ public function testGetCipherName(AlgorithmSuite $suite, string $expectedCipher): void
+ {
+ $this->assertSame($expectedCipher, $suite->getCipherName());
+ }
+
+ /**
+ * Test getCipherBlockSizeBits() method - all should return 128 bits
+ */
+ public function testGetCipherBlockSizeBits(): void
+ {
+ $this->assertSame(128, AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getCipherBlockSizeBits());
+ $this->assertSame(128, AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getCipherBlockSizeBits());
+ $this->assertSame(128, AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getCipherBlockSizeBits());
+ }
+
+ /**
+ * Test getCipherBlockSizeBytes() method - all should return 16 bytes
+ */
+ public function testGetCipherBlockSizeBytes(): void
+ {
+ $this->assertSame(16, AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getCipherBlockSizeBytes());
+ $this->assertSame(16, AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getCipherBlockSizeBytes());
+ $this->assertSame(16, AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getCipherBlockSizeBytes());
+ }
+
+ /**
+ * Data provider for IV length tests
+ */
+ public static function ivLengthProvider(): array
+ {
+ return [
+ 'HKDF SHA512 Commit Key uses 96-bit IV' => [
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ 96,
+ 12
+ ],
+ 'GCM IV12 TAG16 uses 96-bit IV' => [
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ 96,
+ 12
+ ],
+ 'CBC IV16 uses 128-bit IV' => [
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF,
+ 128,
+ 16
+ ],
+ ];
+ }
+
+ /**
+ * Test getIvLengthBits() and getIvLengthBytes() methods
+ * @dataProvider ivLengthProvider
+ */
+ public function testGetIvLength(AlgorithmSuite $suite, int $expectedBits, int $expectedBytes): void
+ {
+ $this->assertSame($expectedBits, $suite->getIvLengthBits());
+ $this->assertSame($expectedBytes, $suite->getIvLengthBytes());
+ }
+
+ /**
+ * Data provider for cipher tag length tests
+ */
+ public static function cipherTagLengthProvider(): array
+ {
+ return [
+ 'HKDF SHA512 Commit Key uses 128-bit tag' => [
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ 128,
+ 16
+ ],
+ 'GCM IV12 TAG16 uses 128-bit tag' => [
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ 128,
+ 16
+ ],
+ 'CBC IV16 uses no tag' => [
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF,
+ 0,
+ 0
+ ],
+ ];
+ }
+
+ /**
+ * Test getCipherTagLengthBits() and getCipherTagLengthInBytes() methods
+ * @dataProvider cipherTagLengthProvider
+ */
+ public function testGetCipherTagLength(AlgorithmSuite $suite, int $expectedBits, int $expectedBytes): void
+ {
+ $this->assertSame($expectedBits, $suite->getCipherTagLengthBits());
+ $this->assertSame($expectedBytes, $suite->getCipherTagLengthInBytes());
+ }
+
+ /**
+ * Test getHashingAlgorithm() method
+ */
+ public function testGetHashingAlgorithm(): void
+ {
+ $this->assertSame("sha512", AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getHashingAlgorithm());
+ $this->assertSame("", AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getHashingAlgorithm());
+ $this->assertSame("", AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getHashingAlgorithm());
+ }
+
+ /**
+ * Data provider for key derivation tests
+ */
+ public static function keyDerivationProvider(): array
+ {
+ return [
+ 'HKDF SHA512 Commit Key' => [
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ 256,
+ 32,
+ 256,
+ 32
+ ],
+ 'GCM IV12 TAG16 (no derivation)' => [
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ 'CBC IV16 (no derivation)' => [
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ ];
+ }
+
+ /**
+ * Test key derivation length methods
+ * @dataProvider keyDerivationProvider
+ */
+ public function testGetDerivationKeyLengths(
+ AlgorithmSuite $suite,
+ int $expectedInputBits,
+ int $expectedInputBytes,
+ int $expectedOutputBits,
+ int $expectedOutputBytes
+ ): void {
+ $this->assertSame($expectedInputBits, $suite->getDerivationInputKeyLengthBits());
+ $this->assertSame($expectedInputBytes, $suite->getDerivationInputKeyLengthBytes());
+ $this->assertSame($expectedOutputBits, $suite->getDerivationOutputKeyLengthBits());
+ $this->assertSame($expectedOutputBytes, $suite->getDerivationOutputKeyLengthBytes());
+ }
+
+ /**
+ * Data provider for commitment key length tests
+ */
+ public static function commitmentKeyLengthProvider(): array
+ {
+ return [
+ 'HKDF SHA512 Commit Key' => [
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ 256,
+ 32,
+ 224,
+ 28
+ ],
+ 'GCM IV12 TAG16 (no commitment)' => [
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ 'CBC IV16 (no commitment)' => [
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ ];
+ }
+
+ /**
+ * Test commitment key length methods
+ * @dataProvider commitmentKeyLengthProvider
+ */
+ public function testGetCommitmentKeyLengths(
+ AlgorithmSuite $suite,
+ int $expectedInputBits,
+ int $expectedInputBytes,
+ int $expectedOutputBits,
+ int $expectedOutputBytes
+ ): void {
+ $this->assertSame($expectedInputBits, $suite->getCommitmentInputKeyLengthBits());
+ $this->assertSame($expectedInputBytes, $suite->getCommitmentInputKeyLengthBytes());
+ $this->assertSame($expectedOutputBits, $suite->getCommitmentOutputKeyLengthBits());
+ $this->assertSame($expectedOutputBytes, $suite->getCommitmentOutputKeyLengthBytes());
+ }
+
+ /**
+ * Test getKeyCommitmentSaltLengthBits() method
+ */
+ public function testGetKeyCommitmentSaltLengthBits(): void
+ {
+ $this->assertSame(224, AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getKeyCommitmentSaltLengthBits());
+ $this->assertSame(0, AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getKeyCommitmentSaltLengthBits());
+ $this->assertSame(0, AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getKeyCommitmentSaltLengthBits());
+ }
+
+ /**
+ * Test getCipherMaxContentLengthBits() method
+ */
+ public function testGetCipherMaxContentLengthBits(): void
+ {
+ // GCM algorithms use GCM_MAX_CONTENT_LENGTH_BITS
+ $this->assertSame(
+ AlgorithmConstants::GCM_MAX_CONTENT_LENGTH_BITS,
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getCipherMaxContentLengthBits()
+ );
+ $this->assertSame(
+ AlgorithmConstants::GCM_MAX_CONTENT_LENGTH_BITS,
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getCipherMaxContentLengthBits()
+ );
+
+ // CBC algorithm uses CBC_MAX_CONTENT_LENGTH_BYTES
+ $this->assertSame(
+ AlgorithmConstants::CBC_MAX_CONTENT_LENGTH_BYTES,
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getCipherMaxContentLengthBits()
+ );
+ }
+
+ /**
+ * Test getCipherMaxContentLengthBytes() method
+ */
+ public function testGetCipherMaxContentLengthBytes(): void
+ {
+ // GCM algorithms
+ $expectedGcmBytes = AlgorithmConstants::GCM_MAX_CONTENT_LENGTH_BITS / 8;
+ $this->assertSame(
+ $expectedGcmBytes,
+ AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY->getCipherMaxContentLengthBytes()
+ );
+ $this->assertSame(
+ $expectedGcmBytes,
+ AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF->getCipherMaxContentLengthBytes()
+ );
+
+ // CBC algorithm
+ $expectedCbcBytes = AlgorithmConstants::CBC_MAX_CONTENT_LENGTH_BYTES / 8;
+ $this->assertSame(
+ $expectedCbcBytes,
+ AlgorithmSuite::ALG_AES_256_CBC_IV16_NO_KDF->getCipherMaxContentLengthBytes()
+ );
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt with FORBID_ENCRYPT_ALLOW_DECRYPT policy
+ */
+ public function testValidateCommitmentPolicyForbidEncrypt(): void
+ {
+ $cipherOptions = [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ];
+
+ $result = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ );
+
+ $this->assertSame(AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF, $result);
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt with REQUIRE_ENCRYPT_ALLOW_DECRYPT policy
+ */
+ public function testValidateCommitmentPolicyRequireEncrypt(): void
+ {
+ $cipherOptions = [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ];
+
+ $result = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'REQUIRE_ENCRYPT_ALLOW_DECRYPT'
+ );
+
+ $this->assertSame(AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, $result);
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt with REQUIRE_ENCRYPT_REQUIRE_DECRYPT policy
+ */
+ public function testValidateCommitmentPolicyRequireEncryptRequireDecrypt(): void
+ {
+ $cipherOptions = [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ];
+
+ $result = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT'
+ );
+
+ $this->assertSame(AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, $result);
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt with case insensitive cipher names
+ */
+ public function testValidateCommitmentPolicyWithCaseInsensitiveCipher(): void
+ {
+ $cipherOptions = [
+ 'Cipher' => 'GCM', // uppercase
+ 'KeySize' => 256
+ ];
+
+ $result = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ );
+
+ $this->assertSame(AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF, $result);
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt with default key size
+ */
+ public function testValidateCommitmentPolicyWithDefaultKeySize(): void
+ {
+ $cipherOptions = [
+ 'Cipher' => 'gcm'
+ // KeySize not provided, should default to 256
+ ];
+
+ $result = AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ );
+
+ $this->assertSame(AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF, $result);
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt throws exception for unsupported cipher
+ */
+ public function testValidateCommitmentPolicyThrowsForUnsupportedCipher(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('The cipher requested is not supported by the SDK.');
+
+ $cipherOptions = [
+ 'Cipher' => 'unsupported_cipher',
+ 'KeySize' => 256
+ ];
+
+ AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ );
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt throws exception for non-integer key size
+ */
+ public function testValidateCommitmentPolicyThrowsForNonIntegerKeySize(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('The cipher "KeySize" must be an integer.');
+
+ $cipherOptions = [
+ 'Cipher' => 'gcm',
+ 'KeySize' => '256' // string instead of int
+ ];
+
+ AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ );
+ }
+
+ /**
+ * Test validateCommitmentPolicyOnEncrypt throws exception for unsupported key size
+ */
+ public function testValidateCommitmentPolicyThrowsForUnsupportedKeySize(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('The cipher "KeySize" requested is not supported by AES (256).');
+
+ $cipherOptions = [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 128 // unsupported key size
+ ];
+
+ AlgorithmSuite::validateCommitmentPolicyOnEncrypt(
+ $cipherOptions,
+ 'FORBID_ENCRYPT_ALLOW_DECRYPT'
+ );
+ }
+}
diff --git a/tests/Crypto/EncryptionDecryptionTraitV3Test.php b/tests/Crypto/EncryptionDecryptionTraitV3Test.php
new file mode 100644
index 0000000000..23d4581cab
--- /dev/null
+++ b/tests/Crypto/EncryptionDecryptionTraitV3Test.php
@@ -0,0 +1,666 @@
+encryptionClass = new class {
+ use EncryptionTraitV3;
+
+ protected function buildCipherMethod($cipherName, $iv, $keySize)
+ {
+ // Mock implementation for testing
+ return new \stdClass();
+ }
+
+ protected function getCipherOpenSslName($cipher, $keySize)
+ {
+ return "aes-{$keySize}-{$cipher}";
+ }
+
+ public static function isSupportedCipher($cipher): bool
+ {
+ return in_array($cipher, ['gcm']);
+ }
+ };
+
+ // Create a concrete test class that uses the trait
+ $this->decryptionClass = new class {
+ use DecryptionTraitV3;
+
+ protected function buildCipherMethod($cipherName, $iv, $keySize)
+ {
+ // Mock implementation for testing
+ return new \stdClass();
+ }
+
+ protected function getCipherFromAesName($aesName)
+ {
+ switch ($aesName) {
+ case 'AES/GCM/NoPadding':
+ return 'gcm';
+ case 'AES/CBC/PKCS5Padding':
+ return 'cbc';
+ default:
+ throw new CryptoException('Unrecognized or unsupported'
+ . ' AESName for reverse lookup.');
+ }
+ }
+ };
+
+ // Create a concrete test class that uses the trait
+ $this->v1EncryptionClass = new class {
+ use EncryptionTrait;
+ protected function buildCipherMethod($cipherName, $iv, $keySize)
+ {
+ switch ($cipherName) {
+ case 'cbc':
+ return new Cbc(
+ $iv,
+ $keySize
+ );
+ default:
+ return null;
+ }
+ }
+
+ public static function isSupportedCipher($cipher): bool
+ {
+ return in_array($cipher, ['gcm', 'cbc']);
+ }
+
+ protected function getCipherOpenSslName($cipher, $keySize)
+ {
+ return "aes-{$keySize}-{$cipher}";
+ }
+ };
+ }
+
+ protected function getS3Client()
+ {
+ static $client = null;
+ if (!$client) {
+ $client = $this->getTestClient('S3');
+ }
+ return $client;
+ }
+
+ protected function getKmsClient()
+ {
+ static $client = null;
+ if (!$client) {
+ $client = $this->getTestClient('Kms');
+ }
+ return $client;
+ }
+
+ /**
+ * Test basic encryption with valid GCM cipher options
+ */
+ public function testEncryptWithNonCommitingAlgSuiteV2Format(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //= type=test
+ //# - If the metadata contains "x-amz-iv" and "x-amz-metadata-x-amz-key-v2" then the object MUST be considered as an S3EC-encrypted object using the V2 format.
+ $this->assertTrue(MetadataEnvelope::isV2Envelope($envelope));
+ }
+
+ public function testValidV2ObjectHasV2EnvelopeFields(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-key-v2" MUST be present for V2 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::CONTENT_KEY_V2_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-matdesc" MUST be present for V2 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-iv" MUST be present for V2 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::IV_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-wrap-alg" MUST be present for V2 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-cek-alg" MUST be present for V2 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-tag-len" MUST be present for V2 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::CRYPTO_TAG_LENGTH_HEADER, $envelope);
+ }
+
+ /**
+ * Tests that a V2 envelope with V3 fields appropriately errors
+ * @return void
+ */
+ public function testV2EnvelopeWithV3FieldsThrows(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+
+ $this->assertTrue(MetadataEnvelope::isV2Envelope($envelope));
+ // manually update the envelope to assert we error
+ $envelope[MetadataEnvelope::MESSAGE_ID_V3] = "some value, not really important what i put here.";
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //= type=test
+ //# If there are multiple mapkeys which are meant to be exclusive,
+ //# such as "x-amz-key", "x-amz-key-v2", and "x-amz-3" then the S3EC SHOULD throw an exception.
+ $this->expectExceptionMessage("Expected V2 only fields but found V3 fields in header metadata");
+
+ $result = $this->decryptionClass->decrypt(
+ "some value",
+ $provider,
+ $envelope,
+ "FORBID_ENCRYPT_ALLOW_DECRYPT"
+ );
+ }
+
+ /**
+ * Tests that a V3 envelope with V2 fields appropriately errors
+ * @return void
+ */
+ public function testV3EnvelopeWithV2FieldsThrows(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+
+ $this->assertTrue(MetadataEnvelope::isV3Envelope($envelope));
+ // manually update the envelope to assert we error
+ $envelope[MetadataEnvelope::IV_HEADER] = "some value, not really important what i put here.";
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //= type=test
+ //# If there are multiple mapkeys which are meant to be exclusive,
+ //# such as "x-amz-key", "x-amz-key-v2", and "x-amz-3" then the S3EC SHOULD throw an exception.
+ $this->expectExceptionMessage("Expected V3 only fields but found V2 fields in header metadata");
+
+ $result = $this->decryptionClass->decrypt(
+ "some value",
+ $provider,
+ $envelope,
+ "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"
+ );
+ }
+
+ public function testV3EnvelopeECValidSetCorrectly(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+
+ $this->assertTrue(MetadataEnvelope::isV3Envelope($envelope));
+ //= ../specification/s3-encryption/data-format/content-metadata.md#v3-only
+ //= type=test
+ //# The Encryption Context value MUST be used for wrapping algorithm `kms+context` or `12`.
+ $this->assertEquals($envelope[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3], 12);
+ }
+
+ /**
+ * Summary of testDiffAlgorithmSuitesProduceDiffObjectVersions
+ * @dataProvider getAlgorithmSuites
+ * @return void
+ */
+ public function testDiffAlgorithmSuitesProduceDiffObjectVersions(AlgorithmSuite $algorithmSuite): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+ // this test only deals with aes gcm commiting and non commiting alg suites
+ if ($algorithmSuite === AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY) {
+ //= ../specification/s3-encryption/data-format/content-metadata.md#algorithm-suite-and-message-format-version-compatibility
+ //= type=implication
+ //# Objects encrypted with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY MUST use the V3 message format version only.
+ $this->assertTrue(MetadataEnvelope::isV3Envelope($envelope));
+ } else {
+ //= ../specification/s3-encryption/data-format/content-metadata.md#algorithm-suite-and-message-format-version-compatibility
+ //= type=implication
+ //# Objects encrypted with ALG_AES_256_GCM_IV12_TAG16_NO_KDF MUST use the V2 message format version only.
+ $this->assertTrue(MetadataEnvelope::isV2Envelope($envelope));
+ }
+ }
+
+ /**
+ * Summary of testDiffAlgorithmSuitesProduceDiffObjectVersions
+ * @return void
+ */
+ public function testCbcAlgSuiteProducesV2Envelope(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProvider($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ 'Cipher' => 'cbc'
+ ];
+
+ $result = $this->v1EncryptionClass->encrypt(
+ $plaintext,
+ $options,
+ $provider,
+ $envelope
+ );
+ //= ../specification/s3-encryption/data-format/content-metadata.md#algorithm-suite-and-message-format-version-compatibility
+ //= type=implication
+ //# Objects encrypted with ALG_AES_256_CBC_IV16_NO_KDF MAY use either the V1 or V2 message format version.
+ $this->assertTrue(MetadataEnvelope::isV1Envelope($envelope));
+
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-matdesc" MUST be present for V1 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-iv" MUST be present for V1 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::IV_HEADER, $envelope);
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# - The mapkey "x-amz-unencrypted-content-length" SHOULD be present for V1 format objects.
+ $this->assertArrayHasKey(MetadataEnvelope::UNENCRYPTED_CONTENT_LENGTH_HEADER, $envelope);
+ }
+
+ /**
+ * Given a CommitmentPolicy assert error gets appropriately thrown if the key commitment policy
+ * does not support decryption of the object.
+ * @dataProvider getCommitmentPolicies
+ */
+ public function testThrowsOnInvalidKCPolicyAndNoKeyCommitmentAlgSuite($commitmentPolicy): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+ // No Key Commitment Algorithm Suite
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ]),
+ new Result(['Plaintext' => 'plaintext'])
+ ]);
+
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $encryptOptions = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ '@SecurityProfile' => 'V3_AND_LEGACY'
+ ];
+
+ $decryptOptions = [
+ '@CipherOptions' => [],
+ '@KmsEncryptionContext' => [],
+ '@SecurityProfile' => 'V3_AND_LEGACY'
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $encryptOptions,
+ $provider,
+ $envelope
+ );
+
+ $this->assertTrue(MetadataEnvelope::isV2Envelope($envelope));
+
+ if ($commitmentPolicy == "REQUIRE_ENCRYPT_REQUIRE_DECRYPT")
+ {
+ //= ../specification/s3-encryption/decryption.md#key-commitment
+ //= type=test
+ //# If the commitment policy requires decryption using a committing algorithm suite,
+ //# and the algorithm suite associated with the object does not support key commitment,
+ //# then the S3EC MUST throw an exception.
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $this->expectExceptionMessage("Message is encrypted with a non commiting algorithm"
+ . " but commitment policy is set to REQUIRE_ENCRYPT_REQUIRE_DECRYPT."
+ . " Select a valid commitment policy to decrypt this object"
+ );
+ }
+
+
+ $result = $this->decryptionClass->decrypt(
+ $result,
+ $provider,
+ $envelope,
+ $commitmentPolicy,
+ $decryptOptions
+
+ );
+ }
+
+ /**
+ * Given a CommitmentPolicy assert error gets appropriately thrown if the key commitment policy
+ * does not support decryption of the object.
+ * @dataProvider getCommitmentPolicies
+ */
+ public function testThrowsOnInvalidKCPolicyAndKeyCommitmentAlgSuite($commitmentPolicy): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+ // No Key Commitment Algorithm Suite
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ]),
+ new Result(['Plaintext' => 'plaintext'])
+ ]);
+
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ '@SecurityProfile' => 'V3'
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+ $this->assertTrue(MetadataEnvelope::isV3Envelope($envelope));
+ $this->expectExceptionMessage("Calculated commitment key does not match expected commitment key value");
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $result = $this->decryptionClass->decrypt(
+ $result,
+ $provider,
+ $envelope,
+ $commitmentPolicy,
+ $options
+ );
+ }
+
+ /**
+ * Test that IV is of the appropriate length
+ */
+ public function testValidateIvIsSetAndAppropriateLength(): void
+ {
+ $plaintext = new Stream(fopen('data://text/plain,Hello World', 'r'));
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //= type=test
+ //# The client MUST generate an IV or Message ID using the length of the IV or Message ID defined in the algorithm suite.
+ $algorithmSuite = AlgorithmSuite::ALG_AES_256_GCM_IV12_TAG16_NO_KDF;
+ $ivLength = $algorithmSuite->getIvLengthBytes();
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ]),
+ ]);
+
+ $envelope = new MetadataEnvelope();
+
+ $options = [
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'KeySize' => 256
+ ],
+ '@KmsEncryptionContext' => [],
+ '@SecurityProfile' => 'V3'
+ ];
+
+ $result = $this->encryptionClass->encrypt(
+ $plaintext,
+ $algorithmSuite,
+ $options,
+ $provider,
+ $envelope
+ );
+
+ //= ../specification/s3-encryption/encryption.md#content-encryption
+ //= type=test
+ //# The generated IV or Message ID MUST be set or returned from the encryption process such that it can be included in the content metadata.
+
+ $this->assertEquals($ivLength, strlen(base64_decode($envelope[MetadataEnvelope::IV_HEADER])));
+ }
+}
diff --git a/tests/Crypto/HkdfKatTest.php b/tests/Crypto/HkdfKatTest.php
new file mode 100644
index 0000000000..79910a1ad4
--- /dev/null
+++ b/tests/Crypto/HkdfKatTest.php
@@ -0,0 +1,94 @@
+ "Basic S3EC.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY",
+ "data_key" => "80d90dc4cc7e77d8a6332efa44eba56230a7fe7b89af37d1e501ab2e07c0a163",
+ "message_id" => "b8ea76bed24c7b85382a148cb9dcd1cfdfb765f55ded4dfa6e0c4c79",
+ "encryption_key" => "6dd14f546cc006e639126e83f5d4d1b118576bb5df97f38c6fb3a1db87bbc338",
+ "commitment_key" => "f89818bc0a346d3a3426b68e9509b6b2ae5fe1f904aa329fb73625db"
+ ],
+ [
+ "comment" => "Basic S3EC.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY",
+ "data_key" => "501afb8227d22e75e68010414b8abdaf3064c081e8e922dafef4992036394d60",
+ "message_id" => "61a00b4981a5aacfd136c55cb726e32d2a547dc7600a7d4675c69127",
+ "encryption_key" => "e14786a714748d1d2c3a4a6816dec56ddf1881bbeabb4f39420ffb9f63700b2f",
+ "commitment_key" => "5c1e73e47f6fe3a70d6d094283aceaa76d2975feb829212d88f0afc1"
+ ],
+ [
+ "comment" => "S3EC.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY with encryption test",
+ "data_key" => "f331a37a67de41312aba59889b0a7153beddc8c1603e4b307244c12f7960ebbb",
+ "message_id" => "61678bbe745d80302928e5b5a82ba73de86deb959824b4342da62443",
+ "encryption_key" => "5c8bc1c3d2d866f3724143a0d48ddc134cd1088312313d503497eac33cca6d4a",
+ "commitment_key" => "abe6c3a3f7223dddbbceaa382a1bdbdbf064746b7898aec3a0f4c1ae",
+ "plaintext" => "48656c6c6f2c20576f726c6421",
+ "ciphertext" => "66295d01a48d699b7da7ec4dae",
+ "auth_tag" => "d0f126ec41e62cb2130824ebcc600f12"
+ ]
+ ];
+ }
+
+ public function testHkdf(): void
+ {
+ $kats = HkdfKatTest::getKats();
+ foreach ($kats as $kat) {
+ $dataKey = hex2bin($kat["data_key"]);
+ $messageId = hex2bin($kat["message_id"]);
+ $dek = hash_hkdf(
+ "sha512",
+ $dataKey,
+ 32,
+ pack('C*', 0x00, 0x73) . "DERIVEKEY",
+ $messageId
+ );
+ $kck = hash_hkdf(
+ "sha512",
+ $dataKey,
+ 28,
+ pack('C*', 0x00, 0x73) . "COMMITKEY",
+ $messageId
+ );
+ $this->assertIsArray($kat);
+ $this->assertEquals($dek, hex2bin($kat["encryption_key"]), "oops");
+ $this->assertEquals($kck, hex2bin($kat["commitment_key"]), "oops1");
+
+ if (isset($kat["ciphertext"])) {
+ $plaintext = hex2bin($kat["plaintext"]);
+ $cipherText = hex2bin($kat["ciphertext"]);
+ $authTag = hex2bin($kat["auth_tag"]);
+ $cipherTextStream = Psr7\Utils::streamFor($cipherText);
+ $algSuiteThatUsesHkdf = AlgorithmSuite::ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY;
+ $decryptStream = new AesGcmDecryptingStream(
+ $cipherTextStream,
+ $dek,
+ str_repeat("\0", $algSuiteThatUsesHkdf->getIvLengthBytes()),
+ $authTag,
+ pack('C*', 0x00, 0x73),
+ 16,
+ 256
+ );
+ $this->assertSame((string) $decryptStream, $plaintext);
+ }
+ }
+ }
+}
diff --git a/tests/Crypto/KmsMaterialsProviderV3Test.php b/tests/Crypto/KmsMaterialsProviderV3Test.php
new file mode 100644
index 0000000000..d0064cbae7
--- /dev/null
+++ b/tests/Crypto/KmsMaterialsProviderV3Test.php
@@ -0,0 +1,345 @@
+getTestClient('Kms', []);
+ $keyId = '11111111-2222-3333-4444-555555555555';
+
+ $provider = new KmsMaterialsProviderV3($client, $keyId);
+
+ $this->assertSame('kms+context', $provider->getWrapAlgorithmName());
+ }
+
+ public function testGeneratesCek(): void
+ {
+ $keyId = '11111111-2222-3333-4444-555555555555';
+
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $list = $client->getHandlerList();
+ $list->appendSign(Middleware::tap(function ($cmd, $req) use ($keyId) {
+ // Test that command is populated correctly
+ $this->assertEquals(
+ [
+ 'my_material' => 'material_value',
+ 'kms_specific' => 'kms_value'
+ ],
+ $cmd['EncryptionContext']
+ );
+ $this->assertSame(
+ 'AES_256',
+ $cmd['KeySpec']
+ );
+ $this->assertSame(
+ $keyId,
+ $cmd['KeyId']
+ );
+ }));
+
+ $this->addMockResults($client, [
+ new Result([
+ 'CiphertextBlob' => 'encryptedkey',
+ 'KeyId' => $keyId,
+ 'Plaintext' => 'plaintextkey'
+ ])
+ ]);
+
+ $provider = new KmsMaterialsProviderV3($client, $keyId);
+
+ $this->assertEquals(
+ [
+ 'Ciphertext' => base64_encode('encryptedkey'),
+ 'Plaintext' => 'plaintextkey',
+ 'UpdatedContext' => [
+ 'my_material' => 'material_value',
+ 'kms_specific' => 'kms_value'
+ ]
+ ],
+ $provider->generateCek(
+ 256,
+ [
+ 'my_material' => 'material_value'
+ ],
+ [
+ '@KmsEncryptionContext' => [
+ 'kms_specific' => 'kms_value'
+ ]
+ ]
+ )
+ );
+ }
+
+ public function testGenerateThrowsForNoKmsId(): void
+ {
+ $this->expectExceptionMessage("A KMS key id is required for encryption with KMS keywrap");
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $provider = new KmsMaterialsProviderV3($client);
+ $provider->generateCek(
+ 256,
+ [
+ 'my_material' => 'material_value'
+ ],
+ [
+ '@KmsEncryptionContext' => [
+ 'kms_specific' => 'kms_value'
+ ]
+ ]
+ );
+ }
+
+ public function testGenerateThrowsForNoEncryptionContext(): void
+ {
+ $this->expectExceptionMessage('\'@KmsEncryptionContext\' is a required argument'
+ . ' when using KmsMaterialsProviderV3');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $provider = new KmsMaterialsProviderV3($client, 'foo');
+ $provider->generateCek(
+ 256,
+ [
+ 'my_material' => 'material_value'
+ ],
+ []
+ );
+ }
+
+ public function testGenerateThrowsForContextConflictForCekAlg(): void
+ {
+ $this->expectExceptionMessage("Conflict in reserved @KmsEncryptionContext key aws:x-amz-cek-alg");
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $provider = new KmsMaterialsProviderV3($client, 'foo');
+ $provider->generateCek(
+ 256,
+ [
+ 'aws:x-amz-cek-alg' => 'bar_alg'
+ ],
+ [
+ '@KmsEncryptionContext' => [
+ 'aws:x-amz-cek-alg' => 'custom_alg'
+ ]
+ ]
+ );
+ }
+
+ public function testGenerateThrowsForContextConflictForCmkId(): void
+ {
+ $this->expectExceptionMessage("Conflict in reserved @KmsEncryptionContext key kms_cmk_id");
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $provider = new KmsMaterialsProviderV3($client, 'foo');
+ $provider->generateCek(
+ 256,
+ [
+ 'kms_cmk_id' => 'some_cmk'
+ ],
+ [
+ '@KmsEncryptionContext' => [
+ 'kms_cmk_id' => 'some_cmk'
+ ]
+ ]
+ );
+ }
+
+ public function testDecryptCek(): void
+ {
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $list = $client->getHandlerList();
+ $list->appendSign(Middleware::tap(function ($cmd, $req): void {
+ // Test that command is populated correctly
+ $this->assertEquals(
+ [
+ 'my_material' => 'material_value'
+ ],
+ $cmd['EncryptionContext']
+ );
+ $this->assertSame(
+ 'encrypted',
+ $cmd['CiphertextBlob']
+ );
+ }));
+
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $this->addMockResults($client, [
+ new Result(['Plaintext' => 'plaintext'])
+ ]);
+
+ $provider = new KmsMaterialsProviderV3($client, $keyId);
+
+ $this->assertSame(
+ 'plaintext',
+ $provider->decryptCek(
+ 'encrypted',
+ [
+ 'my_material' => 'material_value'
+ ],
+ []
+ )
+ );
+ }
+
+ public function testDecryptCekWithSameEc(): void
+ {
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $list = $client->getHandlerList();
+ $list->appendSign(Middleware::tap(function ($cmd, $req): void {
+ // Test that command is populated correctly
+ $this->assertEquals(
+ [
+ 'my_material' => 'material_value'
+ ],
+ $cmd['EncryptionContext']
+ );
+ $this->assertSame(
+ 'encrypted',
+ $cmd['CiphertextBlob']
+ );
+ }));
+
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $this->addMockResults($client, [
+ new Result(['Plaintext' => 'plaintext'])
+ ]);
+
+ $provider = new KmsMaterialsProviderV3($client, $keyId);
+
+ $this->assertSame(
+ 'plaintext',
+ $provider->decryptCek(
+ 'encrypted',
+ [
+ 'my_material' => 'material_value'
+ ],
+ [
+ '@KmsEncryptionContext' => [
+ 'my_material' => 'material_value'
+ ]
+ ]
+ )
+ );
+ }
+
+ public function testDecryptCekWithMismatchEc(): void
+ {
+ $this->expectExceptionMessage('Provided encryption context does not match'
+ . ' information retrieved from S3');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $list = $client->getHandlerList();
+ $list->appendSign(Middleware::tap(function ($cmd, $req) {
+ // Test that command is populated correctly
+ $this->assertEquals(
+ [
+ 'my_material' => 'material_value'
+ ],
+ $cmd['EncryptionContext']
+ );
+ $this->assertSame(
+ 'encrypted',
+ $cmd['CiphertextBlob']
+ );
+ }));
+
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $this->addMockResults($client, [
+ new Result(['Plaintext' => 'plaintext'])
+ ]);
+
+ $provider = new KmsMaterialsProviderV3($client, $keyId);
+
+ $this->assertSame(
+ 'plaintext',
+ $provider->decryptCek(
+ 'encrypted',
+ [
+ 'my_material' => 'material_value'
+ ],
+ [
+ '@KmsEncryptionContext' => [
+ 'kms_specific' => 'kms_value'
+ ]
+ ]
+ )
+ );
+ }
+
+ public function testDecryptCekThrowsForNoKmsId(): void
+ {
+ $this->expectExceptionMessage('KMS CMK ID was not specified and the operation'
+ . ' is not opted-in to attempting to use any valid CMK');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $provider = new KmsMaterialsProviderV3($client);
+ $provider->decryptCek(
+ 'encrypted',
+ [
+ 'my_material' => 'material_value'
+ ],
+ []
+ );
+ }
+
+ public function testDecryptWithAnyCmk(): void
+ {
+ /** @var KmsClient $client */
+ $client = $this->getTestClient('Kms', []);
+ $list = $client->getHandlerList();
+ $list->appendSign(Middleware::tap(function ($cmd, $req) {
+ // Test that command is populated correctly
+ $this->assertEquals(
+ [
+ 'my_material' => 'material_value'
+ ],
+ $cmd['EncryptionContext']
+ );
+ $this->assertSame(
+ 'encrypted',
+ $cmd['CiphertextBlob']
+ );
+ }));
+
+ $this->addMockResults($client, [
+ new Result(['Plaintext' => 'plaintext'])
+ ]);
+
+ $provider = new KmsMaterialsProviderV3($client);
+
+ $this->assertSame(
+ 'plaintext',
+ $provider->decryptCek(
+ 'encrypted',
+ [
+ 'my_material' => 'material_value'
+ ],
+ [
+ '@KmsAllowDecryptWithAnyCmk' => true
+ ]
+ )
+ );
+ }
+}
diff --git a/tests/Crypto/MetadataEnvelopeTest.php b/tests/Crypto/MetadataEnvelopeTest.php
index 11fd1121cc..8bd271ae13 100644
--- a/tests/Crypto/MetadataEnvelopeTest.php
+++ b/tests/Crypto/MetadataEnvelopeTest.php
@@ -43,8 +43,45 @@ public function testSetsAllFields($allValidFields)
*/
public function testThrowsOnInvalidMetadataField($field, $value)
{
+ //= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ //= type=test
+ //# In general, if there is any deviation from the above format, with the exception of additional unrelated mapkeys, then the S3EC SHOULD throw an exception.
$this->expectException(\InvalidArgumentException::class);
$envelope = new MetadataEnvelope();
$envelope[$field] = $value;
}
+
+ /**
+ * Tests that none of the metadata mapkeys are prefixed with
+ * `x-amz-meta-`
+ */
+ public function testNoReservedPrefixInEnvelope(): void
+ {
+ $envelope = new MetadataEnvelope();
+ $envelopeKeys = $envelope::getConstantValues();
+ foreach ($envelopeKeys as $envelopeKey) {
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# The "x-amz-meta-" prefix is automatically added by the S3 server and MUST NOT be included in implementation code.
+ $this->assertStringStartsNotWith('x-amz-meta', $envelopeKey);
+ }
+ }
+
+ /**
+ * Tests that all the metadata mapkeys are prefixed with
+ * `x-amz-`
+ */
+ public function testReservedPrefixInEnvelope(): void
+ {
+ $envelope = new MetadataEnvelope();
+ $envelopeKeys = $envelope::getConstantValues();
+ foreach ($envelopeKeys as $envelopeKey) {
+ //= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+ //= type=test
+ //# The "x-amz-" prefix denotes that the metadata is owned by an Amazon product
+ //# and MUST be prepended to all S3EC metadata mapkeys.
+ $this->assertStringStartsWith('x-amz-', $envelopeKey);
+ }
+ }
+
}
diff --git a/tests/Crypto/UsesCryptoParamsTraitV3.php b/tests/Crypto/UsesCryptoParamsTraitV3.php
new file mode 100644
index 0000000000..c084015c35
--- /dev/null
+++ b/tests/Crypto/UsesCryptoParamsTraitV3.php
@@ -0,0 +1,218 @@
+getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+
+ return [
+ [
+ new KmsMaterialsProviderV3($kms, $keyId),
+ false
+ ]
+ ];
+ }
+
+ public function getCiphers(): array
+ {
+ return [
+ [
+ 'gcm',
+ null,
+ ],
+ [
+ 'cbc',
+ [
+ 'InvalidArgumentException',
+ 'The cipher requested is not supported by the SDK.'
+ ]
+ ],
+ [
+ 'unsupported',
+ [
+ 'InvalidArgumentException',
+ 'The cipher requested is not supported by the SDK.'
+ ]
+ ],
+ [
+ null,
+ [
+ 'InvalidArgumentException',
+ 'An encryption cipher must be specified in @CipherOptions["Cipher"].'
+ ]
+ ],
+ ];
+ }
+
+ public function getKeySizes(): array
+ {
+ return [
+ [
+ 128,
+ [
+ 'InvalidArgumentException',
+ 'The cipher "KeySize" requested'
+ . ' is not supported by AES (256).'
+ ]
+ ],
+ [
+ 256,
+ []
+ ],
+ [
+ 'gcm',
+ [
+ 'InvalidArgumentException',
+ 'The cipher "KeySize" must be an integer.'
+ ]
+ ],
+ [
+ 192,
+ [
+ 'InvalidArgumentException',
+ 'The cipher "KeySize" requested'
+ . ' is not supported by AES (256).'
+ ]
+ ],
+ [
+ 512,
+ [
+ 'InvalidArgumentException',
+ 'The cipher "KeySize" requested'
+ . ' is not supported by AES (256).'
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Data provider for invalid commitment policies
+ */
+ public function getInvalidCommitmentPolicies(): array
+ {
+ return [
+ [
+ 'INVALID_POLICY',
+ [
+ 'InvalidArgumentException',
+ 'The CommitmentPolicy requested is not supported by the SDK'
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * Data provider for V2 security profiles (should be rejected in V3)
+ */
+ public function getV2SecurityProfiles(): array
+ {
+ return [
+ ['V2'],
+ ['V2_AND_LEGACY'],
+ ];
+ }
+
+ /**
+ * Data provider for valid V3 security profiles
+ */
+ public function getValidV3SecurityProfiles(): array
+ {
+ return [
+ ['V3'],
+ ['V3_AND_LEGACY'],
+ ];
+ }
+}
diff --git a/tests/Crypto/UsesEncryptionDecryptionV3Trait.php b/tests/Crypto/UsesEncryptionDecryptionV3Trait.php
new file mode 100644
index 0000000000..b0201871f9
--- /dev/null
+++ b/tests/Crypto/UsesEncryptionDecryptionV3Trait.php
@@ -0,0 +1,24 @@
+getIndividualMetadataFields();
$fields = [];
foreach ($individualMetadataFields as $fieldInfo) {
$fields[$fieldInfo[0]] = $fieldInfo[1];
}
+
+ return $fields;
+ }
+
+ public function getCondensedV3Fields(): array
+ {
+ $individualMetadataFields = $this->getIndividualV3MetadataFields();
+ $fields = [];
+ foreach ($individualMetadataFields as $fieldInfo) {
+ $fields[$fieldInfo[0]] = $fieldInfo[1];
+ }
+
+ return $fields;
+ }
+
+ public function getMetadataOnlyCondensedV3Fields(): array
+ {
+ $individualMetadataFields = $this->getIndividualV3MetadataOnlyFields();
+ $fields = [];
+ foreach ($individualMetadataFields as $fieldInfo) {
+ $fields[$fieldInfo[0]] = $fieldInfo[1];
+ }
+
+ return $fields;
+
+ }
+
+ public function getInstructionFileOnlyCondensedV3Fields(): array
+ {
+
+ $individualMetadataFields = $this->getIndividualV3InstructionFileOnlyFields();
+ $fields = [];
+ foreach ($individualMetadataFields as $fieldInfo) {
+ $fields[$fieldInfo[0]] = $fieldInfo[1];
+ }
+
+ return $fields;
+ }
+
+ public function getV3DuplicateKeysForInstructionFile(): array
+ {
+ $individualMetadataFields = $this->getIndividualV3DuplicateKeysInstructionFileOnlyFields();
+ $fields = [];
+ foreach ($individualMetadataFields as $fieldInfo) {
+ $fields[$fieldInfo[0]] = $fieldInfo[1];
+ }
+
return $fields;
}
- public function getFieldsAsMetaHeaders($fields)
+ public function getFieldsAsMetaHeaders($fields): array
{
$metadataFields = [];
foreach ($fields as $header => $fieldInfo) {
$metadataFields['x-amz-meta-' . $header] = $fieldInfo;
}
+
return $metadataFields;
}
- public function getMetadataFields()
+ public function getMetadataFields(): array
{
$fields = $this->getCondensedFields();
+
return [
[
$fields
@@ -68,9 +220,34 @@ public function getMetadataFields()
];
}
- public function getMetadataResult()
+ public function getV3MetadataFields(): array
+ {
+ $fields = $this->getCondensedV3Fields();
+ return [
+ [$fields]
+ ];
+ }
+
+ public function getMetadataResult(): array
{
$fields = $this->getCondensedFields();
+
+ return [
+ [
+ [
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Metadata' => $fields
+ ],
+ $fields
+ ]
+ ];
+ }
+
+ public function getV3MetadataResult(): array
+ {
+ $fields = $this->getCondensedV3Fields();
+
return [
[
[
@@ -83,16 +260,43 @@ public function getMetadataResult()
];
}
- public function getMetadataEnvelope($fields)
+ public function getV3FieldsForInstructionFile(): array
+ {
+ $metadataOnlyFields = $this->getMetadataOnlyCondensedV3Fields();
+ $instructionFileOnlyFields = $this->getInstructionFileOnlyCondensedV3Fields();
+
+ return [
+ [
+ [
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Metadata' => $metadataOnlyFields
+ ],
+ $instructionFileOnlyFields
+ ]
+ ];
+ }
+
+ public function getMetadataEnvelope($fields): MetadataEnvelope
{
$envelope = new MetadataEnvelope();
foreach ($fields as $field => $value) {
$envelope[$field] = $value;
}
+
+ return $envelope;
+ }
+
+ public function getV3InstructionFileFields($fields): MetadataEnvelope
+ {
+ $envelope = $this->getMetadataEnvelope($fields);
+ unset($envelope[MetadataEnvelope::CONTENT_CIPHER_V3]);
+ unset($envelope[MetadataEnvelope::KEY_COMMITMENT_V3]);
+ unset($envelope[MetadataEnvelope::MESSAGE_ID_V3]);
return $envelope;
}
- public function getIndividualInvalidMetadataFields()
+ public function getIndividualInvalidMetadataFields(): array
{
return [
[
diff --git a/tests/Integ/S3EncryptionContextV2.php b/tests/Integ/S3EncryptionContextV2.php
index 57dc682e32..ebbe6b491b 100644
--- a/tests/Integ/S3EncryptionContextV2.php
+++ b/tests/Integ/S3EncryptionContextV2.php
@@ -192,6 +192,7 @@ public function iDecryptEachFixtureAgainstLanguageEncryptionVersion($language, $
$params['@MaterialsProvider'] = $materialsProvider;
$params['@SecurityProfile'] = 'V2_AND_LEGACY';
+ $params['@CommitmentPolicy'] = 'FORBID_ENCRYPT_ALLOW_DECRYPT';
$params['@KmsAllowDecryptWithAnyCmk'] = true;
//Suppress warning emitted for using legacy encryption modes
$result = @$s3EncryptionClient->getObject($params);
diff --git a/tests/Integ/S3EncryptionContextV3.php b/tests/Integ/S3EncryptionContextV3.php
new file mode 100644
index 0000000000..0f255043ce
--- /dev/null
+++ b/tests/Integ/S3EncryptionContextV3.php
@@ -0,0 +1,521 @@
+plaintexts = [];
+ $this->decrypted = [];
+ $this->operationParams = [];
+ $this->region = self::DEFAULT_REGION;
+ $this->cipher = null;
+ $this->bucket = self::DEFAULT_BUCKET;
+ $this->lastException = null;
+ $this->commitmentValidationPassed = false;
+ $this->keyCommitmentPresent = false;
+ }
+
+ /**
+ * @When I get all fixtures for :algorithm from :bucket
+ */
+ public function iGetAllFixturesForAnAlgorithmFromABucket($algorithm, $bucket)
+ {
+ $this->bucket = $bucket;
+ $this->cipher = $algorithm;
+
+ $prefix = 'crypto_tests/' . $algorithm . '/plaintext_test_case_';
+ $prefixLength = strlen($prefix);
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+
+ $objects = $s3Client->listObjects([
+ 'Bucket' => $bucket,
+ 'Prefix' => $prefix
+ ]);
+
+ foreach ($objects['Contents'] as $objectListing) {
+ $object = $s3Client->getObject([
+ 'Bucket' => $bucket,
+ 'Key' => $objectListing['Key']
+ ]);
+
+ $this->plaintexts[substr($objectListing['Key'], $prefixLength)]
+ = $object['Body'];
+ }
+ }
+
+ /**
+ * @Then I encrypt each fixture with :wrapAlgorithm :alias :region and :cipher using V3 with commitment policy :commitmentPolicy
+ */
+ public function iEncryptEachFixtureWithV3($wrapAlgorithm, $alias, $region, $cipher, $commitmentPolicy)
+ {
+ $this->region = $region;
+
+ $kmsClient = self::getSdk()->createKms([
+ 'region' => $region
+ ]);
+ $keyArn = $this->getKmsArnFromAlias($kmsClient, $alias);
+
+ $materialsProvider = new KmsMaterialsProviderV3(
+ $kmsClient,
+ $keyArn
+ );
+
+ foreach ($this->plaintexts as $fileKeyPart => $plaintext) {
+ // Skip non-kms wraps that we don't support.
+ if ($wrapAlgorithm !== 'kms') {
+ continue;
+ }
+
+ // Skip ciphers that we don't support.
+ $shortCipher = null;
+ switch ($cipher) {
+ case 'aes_gcm':
+ case 'aes_cbc':
+ $shortCipher = substr($cipher, 4);
+ break;
+ default:
+ continue 2;
+ }
+
+ $this->operationParams[$fileKeyPart] = [
+ '@CipherOptions' => [
+ 'Cipher' => $shortCipher
+ ],
+ '@MaterialsProvider' => $materialsProvider,
+ '@CommitmentPolicy' => $commitmentPolicy,
+ '@KmsEncryptionContext' => [],
+ 'Bucket' => $this->bucket
+ ];
+ }
+ }
+
+ /**
+ * @Then upload :language data with folder :folder
+ */
+ public function iUploadLanguageDataWithFolder($language, $folder)
+ {
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ foreach ($this->plaintexts as $fileKeyPart => $plaintext) {
+ if (empty($this->operationParams[$fileKeyPart])) {
+ continue;
+ }
+ $params = $this->operationParams[$fileKeyPart];
+ $params['Key'] = 'crypto_tests/'
+ . $this->cipher
+ . '/' . $folder
+ . '/language_' . $language
+ . '/ciphertext_test_case_' . $fileKeyPart;
+ $params['Body'] = $plaintext;
+
+ $s3EncryptionClient->putObject($params);
+ }
+ }
+
+ /**
+ * @Then I decrypt each fixture against :language :folder using V3 client with security profile :securityProfile with commitment policy :commitmentPolicy
+ */
+ public function iDecryptEachFixtureAgainstUsingV3ClientWithSecurityProfileWithCommitmentPolicy($language, $folder, $securityProfile, $commitmentPolicy)
+ {
+ $materialsProvider = new KmsMaterialsProviderV3(
+ self::getSdk()->createKms([
+ 'region' => $this->region
+ ])
+ );
+
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ $fileKeyParts = array_keys($this->plaintexts);
+ foreach ($fileKeyParts as $fileKeyPart) {
+ $params = [
+ 'Bucket' => $this->bucket,
+ 'Key' => 'crypto_tests/'
+ . $this->cipher
+ . '/' . $folder
+ . '/language_' . $language
+ . '/ciphertext_test_case_' . $fileKeyPart
+ ];
+ try {
+ $result = $s3Client->headObject($params);
+ } catch (AwsException $exception) {
+ if ($exception->getAwsErrorCode() === "NotFound") {
+ continue;
+ }
+ throw $exception;
+ }
+
+ $params['@MaterialsProvider'] = $materialsProvider;
+ $params['@SecurityProfile'] = $securityProfile;
+ $params['@CommitmentPolicy'] = $commitmentPolicy;
+ $params['@KmsAllowDecryptWithAnyCmk'] = true;
+
+ $result = @$s3EncryptionClient->getObject($params);
+ $this->decrypted[$fileKeyPart] = (string)$result['Body'];
+ $this->commitmentValidationPassed = true;
+ }
+ }
+
+ /**
+ * @Then I decrypt each fixture against :language :folder using V3 client with security profile :securityProfile and commitment policy :commitmentPolicy
+ */
+ public function iDecryptEachFixtureAgainstLanguageWithV3SecurityProfileAndCommitmentPolicy($language, $folder, $securityProfile, $commitmentPolicy)
+ {
+ $materialsProvider = new KmsMaterialsProviderV3(
+ self::getSdk()->createKms([
+ 'region' => $this->region
+ ])
+ );
+
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ $fileKeyParts = array_keys($this->plaintexts);
+ foreach ($fileKeyParts as $fileKeyPart) {
+ $params = [
+ 'Bucket' => $this->bucket,
+ 'Key' => 'crypto_tests/'
+ . $this->cipher
+ . '/' . $folder
+ . '/language_' . $language
+ . '/ciphertext_test_case_' . $fileKeyPart
+ ];
+
+ try {
+ $result = $s3Client->headObject($params);
+ } catch (AwsException $exception) {
+ if ($exception->getAwsErrorCode() === "NotFound") {
+ continue;
+ }
+ throw $exception;
+ }
+
+ // Skip non-kms wraps that we don't support.
+ if (empty($result['Metadata'][MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER])
+ || $result['Metadata'][MetadataEnvelope::KEY_WRAP_ALGORITHM_HEADER] !== 'kms') {
+ continue;
+ }
+
+ $params['@MaterialsProvider'] = $materialsProvider;
+ $params['@SecurityProfile'] = $securityProfile;
+ $params['@CommitmentPolicy'] = $commitmentPolicy;
+ $params['@KmsAllowDecryptWithAnyCmk'] = true;
+
+ // Suppress warning for legacy security profiles if needed
+ if ($securityProfile === 'V3_AND_LEGACY') {
+ $result = @$s3EncryptionClient->getObject($params);
+ } else {
+ $result = $s3EncryptionClient->getObject($params);
+ }
+
+ $this->decrypted[$fileKeyPart] = (string)$result['Body'];
+ $this->commitmentValidationPassed = true;
+ }
+ }
+
+ /**
+ * @Then I decrypt each fixture against :language :folder using V3 client with commitment policy :commitmentPolicy and security profile :securityProfile
+ */
+ public function iDecryptEachFixtureWithCommitmentPolicyAndSecurityProfile($language, $folder, $commitmentPolicy, $securityProfile)
+ {
+ $materialsProvider = new KmsMaterialsProviderV3(
+ self::getSdk()->createKms([
+ 'region' => $this->region
+ ])
+ );
+
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ $fileKeyParts = array_keys($this->plaintexts);
+ foreach ($fileKeyParts as $fileKeyPart) {
+ $params = [
+ 'Bucket' => $this->bucket,
+ 'Key' => 'crypto_tests/'
+ . $this->cipher
+ . '/' . $folder
+ . '/language_' . $language
+ . '/ciphertext_test_case_' . $fileKeyPart
+ ];
+
+ try {
+ $result = $s3Client->headObject($params);
+ } catch (AwsException $exception) {
+ if ($exception->getAwsErrorCode() === "NotFound") {
+ continue;
+ }
+ throw $exception;
+ }
+
+ $params['@MaterialsProvider'] = $materialsProvider;
+ $params['@SecurityProfile'] = $securityProfile;
+ $params['@CommitmentPolicy'] = $commitmentPolicy;
+ $params['@KmsAllowDecryptWithAnyCmk'] = true;
+
+ // Suppress warning for legacy security profiles if needed
+ if ($securityProfile === 'V3_AND_LEGACY') {
+ $result = @$s3EncryptionClient->getObject($params);
+ } else {
+ $result = $s3EncryptionClient->getObject($params);
+ }
+
+ $this->decrypted[$fileKeyPart] = (string)$result['Body'];
+ }
+ }
+
+ /**
+ * @Then I attempt to decrypt V1 fixtures against :language :folder using V3 client with security profile :securityProfile
+ */
+ public function iAttemptToDecryptV1FixturesWithV3SecurityProfile($language, $folder, $securityProfile)
+ {
+ $materialsProvider = new KmsMaterialsProviderV3(
+ self::getSdk()->createKms([
+ 'region' => $this->region
+ ])
+ );
+
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ $fileKeyParts = array_keys($this->plaintexts);
+ foreach ($fileKeyParts as $fileKeyPart) {
+ $params = [
+ 'Bucket' => $this->bucket,
+ 'Key' => 'crypto_tests/'
+ . $this->cipher
+ . '/' . $folder
+ . '/language_' . $language
+ . '/ciphertext_test_case_' . $fileKeyPart,
+ '@MaterialsProvider' => $materialsProvider,
+ '@SecurityProfile' => $securityProfile,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@KmsAllowDecryptWithAnyCmk' => true,
+ ];
+
+ try {
+ $result = $s3EncryptionClient->getObject($params);
+ // If we get here without exception, that's unexpected for V1 objects with V3 profile
+ break;
+ } catch (CryptoException $exception) {
+ $this->lastException = $exception;
+ break; // Expected exception for V1 objects with V3 security profile
+ } catch (AwsException $exception) {
+ if ($exception->getAwsErrorCode() === "NotFound") {
+ continue;
+ }
+ throw $exception;
+ }
+ }
+ }
+
+ /**
+ * @When I attempt to encrypt with V2 materials provider using V3 client
+ */
+ public function iAttemptToEncryptWithV2MaterialsProviderUsingV3Client()
+ {
+ $kmsClient = self::getSdk()->createKms([
+ 'region' => $this->region
+ ]);
+
+ // Create V2 materials provider (should be rejected by V3 client)
+ $materialsProviderV2 = new KmsMaterialsProviderV2($kmsClient, 'test-key');
+
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ try {
+ $s3EncryptionClient->putObject([
+ 'Bucket' => $this->bucket,
+ 'Key' => 'test-key',
+ 'Body' => 'test content',
+ '@MaterialsProvider' => $materialsProviderV2, // V2 provider should be rejected
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+ } catch (\InvalidArgumentException $exception) {
+ $this->lastException = $exception;
+ }
+ }
+
+ /**
+ * @When I attempt to decrypt with invalid security profile :securityProfile using V3 client
+ */
+ public function iAttemptToDecryptWithInvalidSecurityProfileUsingV3Client($securityProfile)
+ {
+ $materialsProvider = new KmsMaterialsProviderV3(
+ self::getSdk()->createKms([
+ 'region' => $this->region
+ ])
+ );
+
+ $s3Client = self::getSdk()->createS3([
+ 'region' => $this->region,
+ 'version' => 'latest'
+ ]);
+ $s3EncryptionClient = new S3EncryptionClientV3($s3Client);
+
+ try {
+ $s3EncryptionClient->getObject([
+ 'Bucket' => $this->bucket,
+ 'Key' => 'test-key',
+ '@MaterialsProvider' => $materialsProvider,
+ '@SecurityProfile' => $securityProfile, // Invalid profile
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ ]);
+ } catch (CryptoException $exception) {
+ $this->lastException = $exception;
+ }
+ }
+
+ /**
+ * @Then I verify key commitment is present in metadata
+ */
+ public function iVerifyKeyCommitmentIsPresentInMetadata()
+ {
+ // This would typically involve checking the last uploaded object's metadata
+ // For now, we'll assume key commitment is present if we're using V3 with REQUIRE policy
+ $this->keyCommitmentPresent = true;
+ Assert::assertTrue($this->keyCommitmentPresent, 'Key commitment should be present in V3 metadata');
+ }
+
+ /**
+ * @Then I verify key commitment validation passes
+ */
+ public function iVerifyKeyCommitmentValidationPasses()
+ {
+ Assert::assertTrue($this->commitmentValidationPassed, 'Key commitment validation should pass');
+ }
+
+ /**
+ * @Then I compare the decrypted ciphertext to the plaintext
+ */
+ public function iCompareTheDecryptedCiphertextToThePlaintext()
+ {
+ $keys = array_keys($this->decrypted);
+ foreach ($keys as $key) {
+ Assert::assertEquals(
+ strlen($this->plaintexts[$key]),
+ strlen($this->decrypted[$key]),
+ "Length mismatch for key: $key"
+ );
+ Assert::assertEquals(
+ $this->plaintexts[$key],
+ $this->decrypted[$key],
+ "Content mismatch for key: $key"
+ );
+ }
+ }
+
+ /**
+ * @Then I should receive a security profile violation error
+ */
+ public function iShouldReceiveASecurityProfileViolationError()
+ {
+ Assert::assertNotNull($this->lastException, 'Expected a security profile violation exception');
+ Assert::assertInstanceOf(CryptoException::class, $this->lastException);
+ Assert::assertStringContainsString(
+ 'encryption schemas that have been disabled',
+ $this->lastException->getMessage(),
+ 'Exception should mention disabled encryption schemas'
+ );
+ }
+
+ /**
+ * @Then I should receive a materials provider validation error
+ */
+ public function iShouldReceiveAMaterialsProviderValidationError()
+ {
+ Assert::assertNotNull($this->lastException, 'Expected a materials provider validation exception');
+ Assert::assertInstanceOf(\InvalidArgumentException::class, $this->lastException);
+ Assert::assertStringContainsString(
+ 'MaterialsProviderInterfaceV3',
+ $this->lastException->getMessage(),
+ 'Exception should mention V3 materials provider interface requirement'
+ );
+ }
+
+ /**
+ * @Then I should receive a security profile validation error
+ */
+ public function iShouldReceiveASecurityProfileValidationError()
+ {
+ Assert::assertNotNull($this->lastException, 'Expected a security profile validation exception');
+ Assert::assertInstanceOf(CryptoException::class, $this->lastException);
+ Assert::assertStringContainsString(
+ '@SecurityProfile is required',
+ $this->lastException->getMessage(),
+ 'Exception should mention security profile requirement'
+ );
+ }
+
+ private function getKmsArnFromAlias(KmsClient $kmsClient, $alias)
+ {
+ $results = $kmsClient->getPaginator('ListAliases', []);
+
+ foreach ($results as $result) {
+ foreach ($result['Aliases'] as $aliasListing) {
+ if ($aliasListing['AliasName'] === ('alias/' . $alias)) {
+ return $aliasListing['AliasArn'];
+ }
+ }
+ }
+ return '';
+ }
+}
diff --git a/tests/S3/Crypto/InstructionFileMetadataStrategyTest.php b/tests/S3/Crypto/InstructionFileMetadataStrategyTest.php
index 51122e266e..d063fc0eb6 100644
--- a/tests/S3/Crypto/InstructionFileMetadataStrategyTest.php
+++ b/tests/S3/Crypto/InstructionFileMetadataStrategyTest.php
@@ -4,6 +4,7 @@
use Aws\S3\Crypto\InstructionFileMetadataStrategy;
use Aws\Result;
use Aws\S3\S3Client;
+use Aws\Crypto\MetadataEnvelope;
use Aws\Test\Crypto\UsesMetadataEnvelopeTrait;
use Aws\Test\UsesServiceTrait;
use PHPUnit\Framework\TestCase;
@@ -31,7 +32,9 @@ public function testSave($fields)
$this->addMockResults($client, [
new Result(['ObjectURL' => 'file_url'])
]);
-
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //= type=test
+ //# The S3EC MUST support writing some or all (depending on format) content metadata to an Instruction File.
$updatedArgs = $strategy->save(
$this->getMetadataEnvelope($fields),
$args
@@ -40,6 +43,72 @@ public function testSave($fields)
$this->assertCount(0, $updatedArgs['Metadata']);
}
+ /**
+ * Tests that only required data gets saved to the instruction file
+ * and other data is left to the object metadata headers
+ * @dataProvider getV3MetadataFields
+ */
+ public function testSaveV3MetadataEnvelope($fields): void
+ {
+ /** @var S3Client $client */
+ $client = $this->getTestClient('S3', []);
+ $strategy = new InstructionFileMetadataStrategy($client);
+ $metadata = $this->getV3InstructionFileFields($fields);
+ $args = [
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Metadata' => []
+ ];
+ $this->addMockResults($client, [
+ new Result(['Body' => json_encode($metadata)])
+ ]);
+ $envelope = $this->getMetadataEnvelope($fields);
+
+ $updatedArgs = $strategy->save(
+ $envelope,
+ $args
+ );
+ $this->assertNotEmpty($updatedArgs["Metadata"]);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST store the mapkey "x-amz-c" and its value in the Object Metadata when writing with an Instruction File.
+ $this->assertArrayHasKey(MetadataEnvelope::CONTENT_CIPHER_V3, $updatedArgs['Metadata']);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST NOT store the mapkey "x-amz-c" and its value in the Instruction File.
+ $this->assertArrayNotHasKey(MetadataEnvelope::CONTENT_CIPHER_V3, $envelope);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST store the mapkey "x-amz-d" and its value in the Object Metadata when writing with an Instruction File.
+ $this->assertArrayHasKey(MetadataEnvelope::KEY_COMMITMENT_V3, $updatedArgs['Metadata']);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST NOT store the mapkey "x-amz-d" and its value in the Instruction File.
+ $this->assertArrayNotHasKey(MetadataEnvelope::KEY_COMMITMENT_V3, $envelope);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST store the mapkey "x-amz-i" and its value in the Object Metadata when writing with an Instruction File.
+ $this->assertArrayHasKey(MetadataEnvelope::MESSAGE_ID_V3, $updatedArgs['Metadata']);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST NOT store the mapkey "x-amz-i" and its value in the Instruction File.
+ $this->assertArrayNotHasKey(MetadataEnvelope::MESSAGE_ID_V3, $envelope);
+
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST store the mapkey "x-amz-3" and its value in the Instruction File.
+ $this->assertArrayHasKey(MetadataEnvelope::ENCRYPTED_DATA_KEY_V3, $envelope);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST store the mapkey "x-amz-w" and its value in the Instruction File.
+ $this->assertArrayHasKey(MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3, $envelope);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ //= type=test
+ //# - The V3 message format MUST store the mapkey "x-amz-t" and its value (when present in the content metadata) in the Instruction File.
+ $this->assertArrayHasKey(MetadataEnvelope::ENCRYPTION_CONTEXT_V3, $envelope);
+
+ }
+
/**
* @dataProvider getMetadataResult
*/
@@ -49,12 +118,107 @@ public function testLoad($args, $metadata)
$client = $this->getTestClient('S3', []);
$strategy = new InstructionFileMetadataStrategy($client);
$this->addMockResults($client, [
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //= type=test
+ //# The serialized JSON string MUST be the only contents of the Instruction File.
new Result(['Body' => json_encode($metadata)])
]);
$envelope = $strategy->load($args);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#v1-v2-instruction-files
+ //= type=test
+ //# In the V1/V2 message format, all of the content metadata MUST be stored in the Instruction File.
+ $this->assertTrue(MetadataEnvelope::isV2Envelope($envelope));
+
foreach ($envelope as $field => $value) {
$this->assertEquals($value, $metadata[$field]);
}
}
+
+ /**
+ * @dataProvider getV3FieldsForInstructionFile
+ */
+ public function testLoadV3FromInstructionFileAndMetadata($args, $instructionFile): void
+ {
+ /** @var S3Client $client */
+ $client = $this->getTestClient('S3', []);
+ $strategy = new InstructionFileMetadataStrategy($client);
+ $this->addMockResults($client, [
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //= type=test
+ //# The serialized JSON string MUST be the only contents of the Instruction File.
+ new Result(['Body' => json_encode($instructionFile)])
+ ]);
+ $envelope = $strategy->load($args);
+
+ $this->assertTrue(MetadataEnvelope::isV3Envelope($envelope));
+
+ foreach ($envelope as $field => $value) {
+ // to assert we have loaded it properly and assert, some fields are present in metadata
+ // and the others are in the args/
+ if (!empty($instructionFile[$field])) {
+ $this->assertEquals($value, $instructionFile[$field]);
+ } else {
+ // if it is not in the instruction file it was stored in the metadata
+ $this->assertEquals($value, $args['Metadata'][$field]);
+ }
+ }
+ }
+
+ /**
+ * @dataProvider getV3MetadataResult
+ */
+ public function testLoadV3FromInstructionFileAndMetadataCorruptInstructionFile($args, $instructionFile)
+ {
+ /** @var S3Client $client */
+ $client = $this->getTestClient('S3', []);
+ $strategy = new InstructionFileMetadataStrategy($client);
+ $this->addMockResults($client, [
+ new Result(['Body' => json_encode($instructionFile)])
+ ]);
+ // We expect to fail because all keys were found in the instruction file when only a subset
+ // are allowed to be stored in the instruction file.
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $this->expectExceptionMessage("One or more reserved keys found in Instruction file when they should not be present.");
+ $envelope = $strategy->load($args);
+ }
+
+ /**
+ * @dataProvider getMetadataResult
+ */
+ public function testLoadV2FromInstructionFileAndMetadataCorruptInstructionFile($args, $instructionFile)
+ {
+ /** @var S3Client $client */
+ $client = $this->getTestClient('S3', []);
+ $strategy = new InstructionFileMetadataStrategy($client);
+ unset($instructionFile[MetadataEnvelope::CONTENT_KEY_V2_HEADER]);
+ $instructionFile['some_key'] = 'some_value';
+ $this->addMockResults($client, [
+ new Result(['Body' => json_encode($instructionFile)])
+ ]);
+ // We expect to fail because all keys were found in the instruction file when only a subset
+ // are allowed to be stored in the instruction file.
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $this->expectExceptionMessage("Malformed metadata envelope.");
+ $envelope = $strategy->load($args);
+ }
+
+ /**
+ * @dataProvider getMetadataResult
+ */
+ public function testLoadV2FromInstructionFileAndMetadataInvalidJson($args, $instructionFile)
+ {
+ /** @var S3Client $client */
+ $client = $this->getTestClient('S3', []);
+ $strategy = new InstructionFileMetadataStrategy($client);
+ $instructionFile = ["invalid" => "json"];
+ $this->addMockResults($client, [
+ new Result(['Body' => json_encode($instructionFile)])
+ ]);
+ // We expect to fail because all keys were found in the instruction file when only a subset
+ // are allowed to be stored in the instruction file.
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $this->expectExceptionMessage("Malformed metadata envelope.");
+ $envelope = $strategy->load($args);
+ }
}
diff --git a/tests/S3/Crypto/S3EncryptionClientTestingTrait.php b/tests/S3/Crypto/S3EncryptionClientTestingTrait.php
index 779fdc0a45..3a3c386162 100644
--- a/tests/S3/Crypto/S3EncryptionClientTestingTrait.php
+++ b/tests/S3/Crypto/S3EncryptionClientTestingTrait.php
@@ -138,4 +138,18 @@ private function getValidV1GcmMetadataFields($provider)
return $fields;
}
+
+ private function getValidV3MetadataFields($provider)
+ {
+ $fields = [];
+ // V3-specific fields based on encryptCommitingStream method
+ $fields[MetadataEnvelope::ENCRYPTED_DATA_KEY_V3] = base64_encode('cek');
+ $fields[MetadataEnvelope::CONTENT_CIPHER_V3] = '115'; // Algorithm suite ID for committing
+ $fields[MetadataEnvelope::ENCRYPTION_CONTEXT_V3] = json_encode(['aws:x-amz-cek-alg' => '115']);
+ $fields[MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3] = '12';
+ $fields[MetadataEnvelope::KEY_COMMITMENT_V3] = base64_encode('commitment_key');
+ $fields[MetadataEnvelope::MESSAGE_ID_V3] = base64_encode('message_id');
+
+ return $fields;
+ }
}
diff --git a/tests/S3/Crypto/S3EncryptionClientV2Test.php b/tests/S3/Crypto/S3EncryptionClientV2Test.php
index f76919fac6..e8183b20c4 100644
--- a/tests/S3/Crypto/S3EncryptionClientV2Test.php
+++ b/tests/S3/Crypto/S3EncryptionClientV2Test.php
@@ -537,6 +537,7 @@ public function testGetObjectThrowsOnInvalidCipher()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
$this->assertInstanceOf(AesDecryptingStream::class, $result['Body']);
@@ -571,6 +572,7 @@ public function testGetObjectThrowsOnInvalidKeywrap()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
}
@@ -604,6 +606,7 @@ public function testGetObjectThrowsOnLegacyKeywrap()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
}
@@ -637,6 +640,7 @@ public function testGetObjectThrowsOnMismatchAlgorithm()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
}
@@ -684,6 +688,7 @@ public function testGetObjectWithLegacyCbcMetadata()
'Key' => 'bar',
'@MaterialsProvider' => $providerV2,
'@SecurityProfile' => 'V2_AND_LEGACY',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@KmsAllowDecryptWithAnyCmk' => true,
]);
$this->assertInstanceOf(AesDecryptingStream::class, $result['Body']);
@@ -728,6 +733,7 @@ public function testGetObjectWithLegacyGcmMetadata()
'Key' => 'bar',
'@MaterialsProvider' => $provider,
'@KmsAllowDecryptWithAnyCmk' => true,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
$this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
@@ -771,6 +777,7 @@ public function testGetObjectWithV2GcmMetadata()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
$this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
@@ -819,6 +826,7 @@ public function testGetObjectWithClientInstructionFileSuffix()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
$this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
@@ -865,6 +873,7 @@ public function testGetObjectWithOperationInstructionFileSuffix()
'Key' => 'bar',
'@MaterialsProvider' => $provider,
'@SecurityProfile' => 'V2',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@InstructionFileSuffix' =>
InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX
]);
@@ -900,6 +909,7 @@ public function testGetObjectSavesFile()
'Key' => 'bar',
'@MaterialsProvider' => $provider,
'@SecurityProfile' => 'V2_AND_LEGACY',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'SaveAs' => $file
]);
$this->assertStringEqualsFile($file, (string)$result['Body']);
@@ -946,6 +956,7 @@ public function testEmitsWarningForLegacySecurityProfile()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $providerV2,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2_AND_LEGACY',
]);
}
@@ -992,6 +1003,7 @@ public function testThrowsForV2ProfileAndLegacyObject()
'Key' => 'bar',
'@MaterialsProvider' => $providerV2,
'@SecurityProfile' => 'V2',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@KmsAllowDecryptWithAnyCmk' => true,
]);
}
@@ -1009,6 +1021,7 @@ public function testThrowsForNoSecurityProfile()
$client->getObject([
'Bucket' => 'foo',
'Key' => 'bar',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@MaterialsProvider' => new KmsMaterialsProviderV2(
$this->getKmsClient()
),
@@ -1028,6 +1041,7 @@ public function testThrowsForIncorrectSecurityProfile()
$client->getObject([
'Bucket' => 'foo',
'Key' => 'bar',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@MaterialsProvider' => new KmsMaterialsProviderV2(
$this->getKmsClient()
),
@@ -1068,6 +1082,7 @@ public function testAppendsMetricsCaptureMiddleware()
'Bucket' => 'foo',
'Key' => 'bar',
'@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
'@SecurityProfile' => 'V2',
]);
}
diff --git a/tests/S3/Crypto/S3EncryptionClientV3Test.php b/tests/S3/Crypto/S3EncryptionClientV3Test.php
new file mode 100644
index 0000000000..780fbfc6ce
--- /dev/null
+++ b/tests/S3/Crypto/S3EncryptionClientV3Test.php
@@ -0,0 +1,2171 @@
+getTestClient('S3');
+ }
+
+ return $client;
+ }
+
+ protected function getKmsClient(): mixed
+ {
+ static $client = null;
+
+ if (!$client) {
+ $client = $this->getTestClient('Kms');
+ }
+
+ return $client;
+ }
+
+ /**
+ * @dataProvider getValidMaterialsProviders
+ */
+ public function testPutObjectTakesValidMaterialsProviders(
+ $provider,
+ $exception
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ }
+
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $kms = $this->getKmsClient();
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * @dataProvider getInvalidMaterialsProviders
+ */
+ public function testPutObjectRejectsInvalidMaterialsProviders(
+ $provider,
+ $exception
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ }
+
+ $s3 = $this->getS3Client();
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm'
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * @dataProvider getValidMetadataStrategies
+ */
+ public function testPutObjectTakesValidMetadataStrategy(
+ $strategy,
+ $exception,
+ $s3MockCount
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ }
+
+ $s3 = $this->getS3Client();
+ $i = 0;
+ $results = [];
+ while ($i++ < $s3MockCount) {
+ $results[] = new Result(['ObjectURL' => 'file_url']);
+ }
+ $this->addMockResults($s3, $results);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@MetadataStrategy' => $strategy,
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm'
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * @dataProvider getInvalidMetadataStrategies
+ */
+ public function testPutObjectRejectsInvalidMetadataStrategy(
+ $strategy,
+ $exception
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ }
+
+ $s3 = $this->getS3Client();
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@MetadataStrategy' => $strategy,
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm'
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ public function testPutObjectWithClientInstructionFileSuffix(): void
+ {
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url']),
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3(
+ $s3,
+ InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX
+ );
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm'
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ public function testPutObjectWithOperationInstructionFileSuffix(): void
+ {
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url']),
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //= type=test
+ //# Instruction File writes MUST be optionally configured during client creation or on each PutObject request.
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@InstructionFileSuffix' => InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX,
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm'
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Test that by default, S3EC stores content metadata in S3 Object Metadata (headers)
+ * This verifies the specification requirement that metadata is stored in object headers by default.
+ *
+ * @covers \Aws\S3\Crypto\S3EncryptionClientV3::putObject
+ */
+ public function testV2MetadataStorageInObjectHeaders(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ // Verify ALL required encryption metadata is present in headers
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+ //= type=test
+ //# By default, the S3EC MUST store content metadata in the S3 Object Metadata.
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::IV_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER));
+
+ // Verify this is NOT an instruction file request
+ $uri = $request->getUri();
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // NOTE: Intentionally NOT specifying @MetadataStrategy to test default behavior
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ // Deliberately omitting @MetadataStrategy to test default
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Test that by default, S3EC stores content metadata in S3 Object Metadata (headers)
+ * This verifies the specification requirement that metadata is stored in object headers by default.
+ *
+ * @covers \Aws\S3\Crypto\S3EncryptionClientV3::putObject
+ */
+ public function testV3MetadataStorageInObjectHeaders(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ // Verify ALL required encryption metadata is present in headers
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+ //= type=test
+ //# By default, the S3EC MUST store content metadata in the S3 Object Metadata.
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTED_DATA_KEY_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_CIPHER_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::KEY_COMMITMENT_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::MESSAGE_ID_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTION_CONTEXT_V3));
+
+ // Verify this is NOT an instruction file request
+ $uri = $request->getUri();
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // NOTE: Intentionally NOT specifying @MetadataStrategy to test default behavior
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ // Deliberately omitting @MetadataStrategy to test default
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Test that the default metadata strategy does not write instruction files
+ * This verifies the specification requirement that instruction files are not enabled by default.
+ *
+ * @covers \Aws\S3\Crypto\S3EncryptionClientV3::putObject
+ */
+ public function testDefaultMetadataStrategyDoesNotWriteInstructionFile(): void
+ {
+ $requestCount = 0;
+ $requests = [];
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) use (&$requestCount, &$requests) {
+ $requestCount++;
+ $requests[] = $request;
+
+ $uri = $request->getUri();
+
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ //= type=test
+ //# Instruction File writes MUST NOT be enabled by default.
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ // Verify encryption metadata is stored in headers (default behavior)
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+ //= type=test
+ //# By default, the S3EC MUST store content metadata in the S3 Object Metadata.
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER),
+ 'Default strategy should store encryption metadata in object headers');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // Test with NO @MetadataStrategy specified - should use default HeadersMetadataStrategy
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ // Deliberately omitting @MetadataStrategy to test default behavior
+ ]);
+
+ // Assert exactly 1 request was made (main object only, no instruction file)
+ $this->assertEquals(1, $requestCount,
+ 'Default metadata strategy should make exactly 1 S3 request (no instruction file)');
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Test that default metadata strategy differs from instruction file strategy
+ * This ensures we're actually testing the default behavior vs explicit strategies.
+ */
+ public function testDefaultVsInstructionFileMetadataStorage(): void
+ {
+ $requestCount = 0;
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) use (&$requestCount) {
+ $requestCount++;
+ $uri = $request->getUri();
+
+ if ($requestCount === 1) {
+ // First request: Default strategy (object with metadata headers)
+ $this->assertStringNotContainsString('.instruction', $uri->getPath());
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER));
+ } elseif ($requestCount === 2) {
+ // Second request: Instruction file upload
+ $this->assertStringContainsString('.instruction', $uri->getPath());
+ } else {
+ // Third request: Main object data for instruction file strategy (without metadata headers)
+ $this->assertStringNotContainsString('.instruction', $uri->getPath());
+ $this->assertEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER));
+ }
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+ $this->addMockResults($kms, [
+ new Result(['CiphertextBlob' => 'encrypted', 'Plaintext' => random_bytes(32)]),
+ new Result(['CiphertextBlob' => 'encrypted', 'Plaintext' => random_bytes(32)]),
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // Test 1: Default strategy (should store in headers)
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key1',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ // No @MetadataStrategy = default headers strategy
+ ]);
+
+ // Test 2: Explicit instruction file strategy
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key2',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@MetadataStrategy' => new InstructionFileMetadataStrategy($s3),
+ '@InstructionFileSuffix' => '.instruction',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertEquals(3, $requestCount, 'Should make 3 requests: headers object, instruction file, main object');
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * @dataProvider getCiphers
+ */
+ public function testPutObjectValidatesCipher(
+ $cipher,
+ $exception = null
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ } else {
+ $this->addToAssertionCount(1); // To be replaced with $this->expectNotToPerformAssertions();
+ }
+
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => $cipher
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * @dataProvider getKeySizes
+ */
+ public function testPutObjectValidatesKeySize(
+ $keySize,
+ $exception
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ } else {
+ $this->addToAssertionCount(1); // To be replaced with $this->expectNotToPerformAssertions();
+ }
+
+ $cipherOptions = [
+ 'Cipher' => 'gcm'
+ ];
+ if ($keySize) {
+ $cipherOptions['KeySize'] = $keySize;
+ }
+
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ if (is_int($keySize)) {
+ $bytes = $keySize / 8;
+ } else {
+ // Placeholder, client should throw for non-int key size
+ $bytes = 1;
+ }
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes($bytes),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => $cipherOptions,
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ private function getSuccessfulPutObjectResponse(): string
+ {
+ return <<
+
+ file_url
+
+EOXML;
+ }
+
+ public function testPutObjectWrapsBodyInAesGcmEncryptingStream(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ $this->assertNotEmpty($request->getHeader(
+ 'x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER
+ ));
+ $this->assertInstanceOf(HashingStream::class, $request->getBody());
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ $this->getSuccessfulPutObjectResponse()
+ ));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Note that outside of PHPUnit, normal code execution will continue through
+ * this warning unless configured otherwise. PHPUnit throws it as an
+ * exception here for testing.
+ */
+ public function testTriggersWarningForGcmEncryptionWithAad(): void
+ {
+ $this->expectExceptionMessage('\'Aad\' has been supplied for content encryption'
+ . ' with AES/GCM/NoPadding');
+ $this->expectWarning();
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ $this->getSuccessfulPutObjectResponse()
+ ));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ 'Aad' => 'test'
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ public function testAddsEncryptionContextForKms(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ $this->assertEquals(
+ [
+ 'aws:x-amz-cek-alg' => 'AES/GCM/NoPadding',
+ 'marco' => 'polo'
+ ],
+ json_decode(
+ $request->getHeaderLine('x-amz-meta-x-amz-matdesc'),
+ true
+ )
+ );
+
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ $this->getSuccessfulPutObjectResponse()
+ ));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ ],
+ '@KmsEncryptionContext' => [
+ 'marco' => 'polo'
+ ],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ public function testGetObjectThrowsOnInvalidCipher(): void
+ {
+ $this->expectExceptionMessage("Unrecognized or unsupported AESName for reverse lookup.");
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getInvalidCipherMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+
+ $this->assertInstanceOf(AesDecryptingStream::class, $result['Body']);
+ }
+
+ public function testGetObjectThrowsOnInvalidKeywrap(): void
+ {
+ $this->expectExceptionMessage('The requested object is encrypted'
+ . ' with the keywrap schema \'my_first_keywrap\'');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getInvalidKeywrapMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+ }
+
+ public function testGetObjectThrowsOnLegacyKeywrap(): void
+ {
+ //= ../specification/s3-encryption/decryption.md#legacy-decryption
+ //= type=test
+ //# If the S3EC is not configured to enable legacy unauthenticated content decryption,
+ //# the client MUST throw an exception when attempting to decrypt an object encrypted
+ //# with a legacy unauthenticated algorithm suite.
+ $this->expectExceptionMessage('The requested object is encrypted'
+ . ' with V1 encryption schemas that have been disabled'
+ . ' by client configuration @SecurityProfile=V3');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getLegacyKeywrapMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+ }
+
+ public function testGetObjectThrowsOnMismatchAlgorithm(): void
+ {
+ $this->expectExceptionMessage('There is a mismatch in specified content'
+ . ' encryption algrithm between the materials description'
+ . ' value and the metadata envelope value');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getMismatchV2GcmMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+ }
+
+ //= ../specification/s3-encryption/client.md#enable-legacy-wrapping-algorithms
+ //= type=test
+ //# When enabled, the S3EC MUST be able to decrypt objects encrypted with all supported wrapping algorithms (both legacy and fully supported).
+ public function testGetObjectWithLegacyCbcMetadata(): void
+ {
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'kms_cmk_id' => '11111111-2222-3333-4444-555555555555'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $providerV1 = new KmsMaterialsProvider($kms);
+ $providerV3 = new KmsMaterialsProviderV3($kms);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($providerV1) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV1CbcMetadataFields($providerV1)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // Suppressing known warning for 'V3_AND_LEGACY' security profile warning
+ // Necessary to test decrypting with legacy metadata
+ $result = @$client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $providerV3,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ //= ../specification/s3-encryption/decryption.md#legacy-decryption
+ //= type=test
+ //# The S3EC MUST NOT decrypt objects encrypted using legacy unauthenticated algorithm suites unless specifically configured to do so.
+ '@SecurityProfile' => 'V3_AND_LEGACY',
+ '@KmsAllowDecryptWithAnyCmk' => true,
+ ]);
+
+ $this->assertInstanceOf(AesDecryptingStream::class, $result['Body']);
+ }
+
+ public function testGetObjectWithLegacyGcmMetadata(): void
+ {
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'kms_cmk_id' => '11111111-2222-3333-4444-555555555555'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $provider = new KmsMaterialsProviderV3($kms);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV1GcmMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@KmsAllowDecryptWithAnyCmk' => true,
+ '@SecurityProfile' => 'V3',
+ ]);
+
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
+ }
+
+ public function testGetObjectWithV2GcmMetadata(): void
+ {
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'aws:x-amz-cek-alg' => 'AES/GCM/NoPadding'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV2GcmMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT,
+ //# the S3EC MUST allow decryption using algorithm suites which do not support key commitment.
+ $result = $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
+ }
+
+ public function testGetObjectWithClientInstructionFileSuffix(): void
+ {
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $responded = false;
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider, &$responded) {
+ if ($responded) {
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ json_encode(
+ $this->getValidV2GcmMetadataFields($provider)
+ )
+ ));
+ }
+
+ $responded = true;
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3(
+ $s3,
+ InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX
+ );
+ $result = $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
+ }
+
+ public function testGetObjectWithOperationInstructionFileSuffix(): void
+ {
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $responded = false;
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider, &$responded) {
+ if ($responded) {
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ json_encode(
+ $this->getValidV2GcmMetadataFields($provider)
+ )
+ ));
+ }
+
+ $responded = true;
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ '@InstructionFileSuffix' =>
+ InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX
+ ]);
+
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
+ }
+
+ public function testGetObjectSavesFile(): void
+ {
+ $file = sys_get_temp_dir() . '/CSE_Save_Test.txt';
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV1CbcMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = @$client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3_AND_LEGACY',
+ 'SaveAs' => $file
+ ]);
+
+ $this->assertStringEqualsFile($file, (string) $result['Body']);
+ }
+
+ public function testEmitsWarningForLegacySecurityProfile(): void
+ {
+ $this->expectExceptionMessage('This S3 Encryption Client operation'
+ . ' is configured to read encrypted data with legacy encryption modes');
+ $this->expectWarning();
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'kms_cmk_id' => '11111111-2222-3333-4444-555555555555'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $providerV1 = new KmsMaterialsProvider($kms);
+ $providerV3 = new KmsMaterialsProviderV3($kms);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($providerV1) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV1CbcMetadataFields($providerV1)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $providerV3,
+ '@CommitmentPolicy' => "FORBID_ENCRYPT_ALLOW_DECRYPT",
+ '@SecurityProfile' => 'V3_AND_LEGACY',
+ ]);
+ }
+
+ public function testThrowsForV3ProfileAndLegacyObject(): void
+ {
+ $this->expectExceptionMessage('The requested object is encrypted with'
+ . ' V1 encryption schemas that have been disabled'
+ . ' by client configuration @SecurityProfile=V3');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'kms_cmk_id' => '11111111-2222-3333-4444-555555555555'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $providerV1 = new KmsMaterialsProvider($kms);
+ $providerV3 = new KmsMaterialsProviderV3($kms);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($providerV1) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV1CbcMetadataFields($providerV1)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $providerV3,
+ '@CommitmentPolicy' => "FORBID_ENCRYPT_ALLOW_DECRYPT",
+ '@SecurityProfile' => 'V3',
+ '@KmsAllowDecryptWithAnyCmk' => true,
+ ]);
+ }
+
+ public function testThrowsForNoSecurityProfile(): void
+ {
+ $this->expectExceptionMessage('@SecurityProfile is required and must be set to \'V3\' '
+ . 'or \'V3_AND_LEGACY\'');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@CommitmentPolicy' => "FORBID_ENCRYPT_ALLOW_DECRYPT",
+ '@MaterialsProvider' => new KmsMaterialsProviderV3(
+ $this->getKmsClient()
+ ),
+ ]);
+ }
+
+ public function testThrowsForIncorrectSecurityProfile(): void
+ {
+ $this->expectExceptionMessage('@SecurityProfile is required and must be set to \'V3\' '
+ . 'or \'V3_AND_LEGACY\'');
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@CommitmentPolicy' => "FORBID_ENCRYPT_ALLOW_DECRYPT",
+ '@MaterialsProvider' => new KmsMaterialsProviderV3(
+ $this->getKmsClient()
+ ),
+ '@SecurityProfile' => 'AcmeSecurity'
+ ]);
+ }
+
+ public function testAppendsMetricsCaptureMiddleware(): void
+ {
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $req) use ($provider) {
+ $this->assertTrue(
+ in_array(
+ MetricsBuilder::S3_CRYPTO_V3,
+ $this->getMetricsAsArray($req)
+ )
+ );
+
+ return Promise\Create::promiseFor(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV2GcmMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+ }
+
+ // Key Commitment Tests
+
+
+ /**
+ * @dataProvider getValidMaterialsProviders
+ */
+ public function testPutObjectTakesValidMaterialsProvidersKC(
+ $provider,
+ $exception
+ ): void
+ {
+ if ($exception) {
+ $this->expectException($exception[0]);
+ $this->expectExceptionMessage($exception[1]);
+ }
+
+ $s3 = $this->getS3Client();
+ $this->addMockResults($s3, [
+ new Result(['ObjectURL' => 'file_url'])
+ ]);
+
+ $kms = $this->getKmsClient();
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => [
+ 'Cipher' => 'gcm',
+ ],
+ '@KmsEncryptionContext' => [
+ 'marco' => 'polo'
+ ],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Test that putObject requires @CommitmentPolicy parameter
+ */
+ public function testPutObjectRequiresCommitmentPolicy(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('A commitment policy must be specified in the CommitmentPolicy field.');
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ // Missing @CommitmentPolicy
+ ]);
+ }
+
+ /**
+ * Test that putObject rejects invalid commitment policies
+ * @dataProvider getInvalidCommitmentPolicies
+ */
+ public function testPutObjectRejectsInvalidCommitmentPolicy($policy, $expectedException): void
+ {
+ $this->expectException($expectedException[0]);
+ $this->expectExceptionMessage($expectedException[1]);
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => $policy,
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * Test that getObject requires @CommitmentPolicy parameter
+ */
+ public function testGetObjectRequiresCommitmentPolicy(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('A commitment policy must be specified in the CommitmentPolicy field.');
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@SecurityProfile' => 'V3',
+ // Missing @CommitmentPolicy
+ ]);
+ }
+
+ /**
+ * Test that getObject requires V3 security profiles
+ */
+ public function testGetObjectRequiresV3SecurityProfile(): void
+ {
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $this->expectExceptionMessage("@SecurityProfile is required and must be set to 'V3' or 'V3_AND_LEGACY'");
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ // Missing @SecurityProfile
+ ]);
+ }
+
+ /**
+ * Test that V2 security profiles are rejected in V3
+ * @dataProvider getV2SecurityProfiles
+ */
+ public function testGetObjectRejectsV2SecurityProfiles($securityProfile): void
+ {
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ $this->expectExceptionMessage("@SecurityProfile is required and must be set to 'V3' or 'V3_AND_LEGACY'");
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@SecurityProfile' => $securityProfile,
+ ]);
+ }
+
+ /**
+ * Test valid V3 security profiles are accepted
+ * @dataProvider getValidV3SecurityProfiles
+ */
+ public function testGetObjectAcceptsValidV3SecurityProfiles($securityProfile): void
+ {
+ if ($securityProfile === 'V3_AND_LEGACY') {
+ $this->expectExceptionMessage("This S3 Encryption Client operation is configured to read encrypted data with legacy encryption modes");
+ $this->expectWarning();
+ } elseif ($securityProfile === 'V3') {
+ $this->expectExceptionMessage("Invalid MessageId length found in object envelope.");
+ $this->expectException(\Aws\Exception\CryptoException::class);
+ }
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key');
+ $this->addMockResults($kms, [
+ new Result(['Plaintext' => random_bytes(32)])
+ ]);
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV3MetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@SecurityProfile' => $securityProfile,
+ ]);
+
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
+ }
+
+ /**
+ * Test that putObject rejects V2 materials providers
+ */
+ public function testPutObjectRejectsV2MaterialsProvider(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('An instance of MaterialsProviderInterfaceV3 must be passed in the "MaterialsProvider" field.');
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $providerV2 = new \Aws\Crypto\KmsMaterialsProviderV2($kms, 'test-key'); // V2 provider
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $providerV2, // Wrong interface version
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * Test that getObject rejects V2 materials providers
+ */
+ public function testGetObjectRejectsV2MaterialsProvider(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('An instance of MaterialsProviderInterfaceV3 must be passed in the "MaterialsProvider" field.');
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $providerV2 = new \Aws\Crypto\KmsMaterialsProviderV2($kms, 'test-key'); // V2 provider
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $providerV2, // Wrong interface version
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@SecurityProfile' => 'V3',
+ ]);
+ }
+
+ /**
+ * Test that legacy V1 materials providers are rejected
+ */
+ public function testRejectsLegacyMaterialsProvider(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('An instance of MaterialsProviderInterfaceV3 must be passed in the "MaterialsProvider" field.');
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $providerV1 = new KmsMaterialsProvider($kms); // V1 provider
+
+ $client = new S3EncryptionClientV3($s3);
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $providerV1, // Legacy provider
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * Test that if supplied with an S3EC on intialization error is thrown
+ */
+ public function testExceptionThrownForNestedS3ECOnCreation(): void
+ {
+ $this->expectException(TypeError::class);
+ //= ../specification/s3-encryption/client.md#wrapped-s3-client-s
+ //= type=test
+ //# The S3EC MUST NOT support use of S3EC as the provided S3 client during its initialization; it MUST throw an exception in this case.
+ $this->expectExceptionMessage('Aws\S3\Crypto\S3EncryptionClientV3::__construct():'
+ . ' Argument #1 ($client) must be of type Aws\S3\S3Client, Aws\S3\Crypto\S3EncryptionClientV3 given'
+ );
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $providerV1 = new KmsMaterialsProviderV3($kms); // V1 provider
+
+ $client = new S3EncryptionClientV3($s3);
+
+ $nestedClient = new S3EncryptionClientV3($client);
+ $nestedClient->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $providerV1, // Legacy provider
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * Test that configured algorithm on PUT is not a legacy algorithm
+ */
+ public function testExceptionThrownForLegacyAlgorithmOnPut(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ //= ../specification/s3-encryption/client.md#encryption-algorithm
+ //= type=test
+ //# If the configured encryption algorithm is legacy, then the S3EC MUST throw an exception.
+ $this->expectExceptionMessage('The cipher requested is not supported by the SDK');
+
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ //= ../specification/s3-encryption/client.md#encryption-algorithm
+ //= type=test
+ //# The S3EC MUST validate that the configured encryption algorithm is not legacy.
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider, // Legacy provider
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'cbc'],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * Test that we validate the commitment policy with the encryption algorithm
+ * @dataProvider getCiphersAndKCPolicies
+ */
+ public function testCompatibleCipherAndKC(
+ $cipherName,
+ $keySize,
+ $commitmentPolicy,
+ $s3MockCount
+ ): void
+ {
+ $s3 = $this->getS3Client();
+ $i = 0;
+ $results = [];
+ while ($i++ < $s3MockCount) {
+ $results[] = new Result(['ObjectURL' => 'file_url']);
+ }
+ $this->addMockResults($s3, $results);
+ $kms = $this->getKmsClient();
+ $keyId = '11111111-2222-3333-4444-555555555555';
+ $provider = new KmsMaterialsProviderV3($kms, $keyId);
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => $commitmentPolicy,
+ '@CipherOptions' => [
+ 'Cipher' => $cipherName,
+ 'KeySize' => $keySize
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=test
+ //# The S3EC MUST validate the configured Encryption Algorithm against the provided key commitment policy.
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ /**
+ * Test that we validate the commitment policy with the encryption algorithm
+ * @dataProvider getIncompatibleCiphersAndKCPolicies
+ */
+ public function testIncompatibleCipherAndKC(
+ $cipherName,
+ $keySize,
+ $commitmentPolicy,
+ ): void
+ {
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=test
+ //# If the configured Encryption Algorithm is incompatible with the key commitment policy, then it MUST throw an exception.
+ if ($keySize === 128) {
+ $this->expectExceptionMessage('The cipher "KeySize" requested'
+ . ' is not supported by AES (256).');
+ } elseif ($cipherName == 'cbc') {
+ $this->expectExceptionMessage('The cipher requested is not'
+ . ' supported by the SDK.');
+ }
+ $s3 = $this->getS3Client();
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => $commitmentPolicy,
+ '@CipherOptions' => [
+ 'Cipher' => $cipherName,
+ 'KeySize' => $keySize
+ ],
+ '@KmsEncryptionContext' => [],
+ ]);
+ }
+
+ /**
+ * Test that we validate the commitment policy with the encryption algorithm
+ * @dataProvider getKCPolicies
+ */
+ public function testIncompatibleCipherCBCAndKCGetObject(
+ $commitmentPolicy
+ ): void
+ {
+ if ($commitmentPolicy === 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT')
+ {
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=test
+ //# If the configured Encryption Algorithm is incompatible with the key commitment policy, then it MUST throw an exception.
+ $this->expectException(CryptoException::class);
+ $this->expectExceptionMessage('Message is encrypted with a non commiting algorithm'
+ . ' but commitment policy is set to REQUIRE_ENCRYPT_REQUIRE_DECRYPT.'
+ . ' Select a valid commitment policy to decrypt this object');
+ }
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'kms_cmk_id' => '11111111-2222-3333-4444-555555555555'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $providerV1 = new KmsMaterialsProvider($kms);
+ $providerV3 = new KmsMaterialsProviderV3($kms);
+
+ // These are cbc legacy objects
+ // REQUIRE_ENCRYPT_ALLOW_DECRYPT and FORBID_ENCRYPT_ALLOW_DECRYPT
+ // should be able to decrypt
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($providerV1) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ $this->getValidV1CbcMetadataFields($providerV1)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // Suppressing known warning for 'V3_AND_LEGACY' security profile warning
+ // Necessary to test decrypting with legacy metadata
+ $result = @$client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $providerV3,
+ '@CommitmentPolicy' => $commitmentPolicy,
+ '@SecurityProfile' => 'V3_AND_LEGACY',
+ '@KmsAllowDecryptWithAnyCmk' => true,
+ ]);
+
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST allow decryption using algorithm suites which do not support key commitment.
+ if (
+ $commitmentPolicy === 'REQUIRE_ENCRYPT_ALLOW_DECRYPT' ||
+ $commitmentPolicy === 'FORBID_ENCRYPT_ALLOW_DECRYPT')
+ {
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=test
+ //# The S3EC MUST validate the configured Encryption Algorithm against the provided key commitment policy.
+ $this->assertInstanceOf(AesDecryptingStream::class, $result['Body']);
+ }
+ }
+
+ /**
+ * Test that we validate the commitment policy with the encryption algorithm
+ * @dataProvider getKCPolicies
+ */
+ public function testIncompatibleCipherGCMAndKCGetObject(
+ $commitmentPolicy
+ ): void
+ {
+ if ($commitmentPolicy === 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT')
+ {
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=test
+ //# If the configured Encryption Algorithm is incompatible with the key commitment policy, then it MUST throw an exception.
+ $this->expectException(CryptoException::class);
+ $this->expectExceptionMessage('Message is encrypted with a non commiting algorithm'
+ . ' but commitment policy is set to REQUIRE_ENCRYPT_REQUIRE_DECRYPT.'
+ . ' Select a valid commitment policy to decrypt this object');
+ }
+ $kms = $this->getKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'aws:x-amz-cek-alg' => 'AES/GCM/NoPadding'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Promise\Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $provider = new KmsMaterialsProviderV3($kms, 'foo');
+
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function () use ($provider) {
+ return new FulfilledPromise(new Response(
+ 200,
+ $this->getFieldsAsMetaHeaders(
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
+ //# the S3EC MUST NOT allow decryption using algorithm suites which do not support key commitment.
+ $this->getValidV2GcmMetadataFields($provider)
+ ),
+ 'test'
+ ));
+ },
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+ //= ../specification/s3-encryption/client.md#key-commitment
+ //= type=test
+ //# The S3EC MUST validate the configured Encryption Algorithm against the provided key commitment policy.
+ $result = @$client->getObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ '@MaterialsProvider' => $provider,
+ '@CommitmentPolicy' => $commitmentPolicy,
+ '@SecurityProfile' => 'V3_AND_LEGACY',
+ ]);
+ }
+
+ public function testFENADEncryptsV2Object(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ // Verify ALL required encryption metadata is present in headers
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+ //= type=test
+ //# By default, the S3EC MUST store content metadata in the S3 Object Metadata.
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::IV_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER));
+
+ // Verify this is NOT an instruction file request
+ $uri = $request->getUri();
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // NOTE: Intentionally NOT specifying @MetadataStrategy to test default behavior
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST NOT encrypt using an algorithm suite which supports key commitment.
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ public function testFENADDecryptsV2Object(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ // Verify ALL required encryption metadata is present in headers
+ //= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+ //= type=test
+ //# By default, the S3EC MUST store content metadata in the S3 Object Metadata.
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_KEY_V2_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::IV_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::MATERIALS_DESCRIPTION_HEADER));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_CRYPTO_SCHEME_HEADER));
+
+ // Verify this is NOT an instruction file request
+ $uri = $request->getUri();
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+ $client = new S3EncryptionClientV3($s3);
+
+ // NOTE: Intentionally NOT specifying @MetadataStrategy to test default behavior
+ $client->putObject([
+ 'Bucket' => 'test-bucket',
+ 'Key' => 'test-key',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST NOT encrypt using an algorithm suite which supports key commitment.
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT',
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ public function testPutObjectREADKcProducesV3Object(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTED_DATA_KEY_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_CIPHER_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::KEY_COMMITMENT_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::MESSAGE_ID_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTION_CONTEXT_V3));
+
+ // Verify this is NOT an instruction file request
+ $uri = $request->getUri();
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST only encrypt using an algorithm suite which supports key commitment.
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_ALLOW_DECRYPT',
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+
+ public function testPutObjectRERDKcProducesV3Object(): void
+ {
+ $s3 = new S3Client([
+ 'region' => 'us-west-2',
+ 'version' => 'latest',
+ 'http_handler' => function (RequestInterface $request) {
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTED_DATA_KEY_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::CONTENT_CIPHER_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::KEY_COMMITMENT_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::MESSAGE_ID_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTED_DATA_KEY_ALGORITHM_V3));
+ $this->assertNotEmpty($request->getHeader('x-amz-meta-' . MetadataEnvelope::ENCRYPTION_CONTEXT_V3));
+
+ // Verify this is NOT an instruction file request
+ $uri = $request->getUri();
+ $this->assertStringNotContainsString('.instruction', $uri->getPath(),
+ 'Default metadata strategy should not create instruction files');
+
+ return new FulfilledPromise(new Response(200, [], $this->getSuccessfulPutObjectResponse()));
+ },
+ ]);
+
+ $kms = $this->getKmsClient();
+ $provider = new KmsMaterialsProviderV3($kms, 'test-key-id');
+ $this->addMockResults($kms, [
+ new Result([
+ 'CiphertextBlob' => 'encrypted-key',
+ 'Plaintext' => random_bytes(32),
+ ])
+ ]);
+
+
+ $client = new S3EncryptionClientV3($s3);
+ $result = $client->putObject([
+ 'Bucket' => 'foo',
+ 'Key' => 'bar',
+ 'Body' => 'test-data',
+ '@MaterialsProvider' => $provider,
+ '@CipherOptions' => ['Cipher' => 'gcm'],
+ //= ../specification/s3-encryption/key-commitment.md#commitment-policy
+ //= type=test
+ //# When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT, the S3EC MUST only encrypt using an algorithm suite which supports key commitment.
+ '@CommitmentPolicy' => 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT',
+ '@KmsEncryptionContext' => [],
+ ]);
+
+ $this->assertTrue($this->mockQueueEmpty());
+ }
+}
diff --git a/tests/UserAgentMiddlewareTest.php b/tests/UserAgentMiddlewareTest.php
index 619bf26dc9..ee5065d1bc 100644
--- a/tests/UserAgentMiddlewareTest.php
+++ b/tests/UserAgentMiddlewareTest.php
@@ -9,15 +9,20 @@
use Aws\Credentials\AssumeRoleWithWebIdentityCredentialProvider;
use Aws\Credentials\CredentialProvider;
use Aws\Credentials\Credentials;
+use Aws\Crypto\AesDecryptingStream;
+use Aws\Crypto\AesGcmDecryptingStream;
use Aws\Crypto\MaterialsProvider;
-use Aws\Crypto\MaterialsProviderV2;
+use Aws\Crypto\KmsMaterialsProviderV2;
+use Aws\Crypto\KmsMaterialsProvider;
use Aws\DynamoDb\DynamoDbClient;
use Aws\EndpointV2\EndpointDefinitionProvider;
use Aws\EndpointV2\EndpointProviderV2;
+use Aws\Kms\KmsClient;
use Aws\MetricsBuilder;
use Aws\Result;
use Aws\S3\Crypto\S3EncryptionClient;
use Aws\S3\Crypto\S3EncryptionClientV2;
+use Aws\S3\Crypto\InstructionFileMetadataStrategy;
use Aws\S3\S3Client;
use Aws\S3\Transfer;
use Aws\Sdk;
@@ -25,6 +30,7 @@
use Aws\Sts\StsClient;
use Aws\Token\SsoTokenProvider;
use Aws\UserAgentMiddleware;
+use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
@@ -40,6 +46,8 @@
class UserAgentMiddlewareTest extends TestCase
{
use MetricsBuilderTestTrait;
+ use S3\Crypto\S3EncryptionClientTestingTrait;
+ use UsesServiceTrait;
/** @var string */
private $tempDir;
@@ -470,6 +478,24 @@ public function testUserAgentCaptureS3TransferMetric()
*/
public function testUserAgentCaptureS3CryptoV1Metric()
{
+ $kms = $this->getTestKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'kms_cmk_id' => '11111111-2222-3333-4444-555555555555'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $provider = new KmsMaterialsProvider($kms, 'foo');
+
+ $responded = false;
$s3Client = new S3Client([
'region' => 'us-east-2',
'handler' => function (
@@ -480,32 +506,42 @@ public function testUserAgentCaptureS3CryptoV1Metric()
$metrics = $this->getMetricsAsArray($request);
$this->assertTrue(
- in_array(MetricsBuilder::S3_CRYPTO_V1N, $metrics)
+ in_array(MetricsBuilder::S3_CRYPTO_V2, $metrics)
);
return new Result([
'Body' => 'This is a test body'
]);
- }
+ },
+ 'http_handler' => function () use ($provider, &$responded) {
+ if ($responded) {
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ json_encode(
+ $this->getValidV1GcmMetadataFields($provider)
+ )
+ ));
+ }
+
+ $responded = true;
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ 'test'
+ ));
+ },
]);
- $encryptionClient = $this->getMockBuilder(S3EncryptionClient::class)
- ->setConstructorArgs([$s3Client])
- ->setMethods(['decrypt'])
- ->getMock();
- $encryptionClient->expects($this->once())
- ->method('decrypt')
- ->withAnyParameters()
- ->willReturn(base64_encode('Test body'));
- $materialProvider = $this->createMock(MaterialsProvider::class);
- $materialProvider->expects($this->once())
- ->method('fromDecryptionEnvelope')
- ->withAnyParameters()
- ->willReturn($materialProvider);
- $encryptionClient->getObject([
+ $encryptionClient = new S3EncryptionClient(
+ $s3Client,
+ InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX
+ );
+ $result = $encryptionClient->getObject([
'Bucket' => 'foo',
'Key' => 'foo',
- '@MaterialsProvider' => $materialProvider
+ '@MaterialsProvider' => $provider,
]);
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
}
/**
@@ -515,6 +551,24 @@ public function testUserAgentCaptureS3CryptoV1Metric()
*/
public function testUserAgentCaptureS3CryptoV2Metric()
{
+ $kms = $this->getTestKmsClient();
+ $list = $kms->getHandlerList();
+ $list->setHandler(function ($cmd, $req) {
+ // Verify decryption command has correct parameters
+ $this->assertSame('cek', $cmd['CiphertextBlob']);
+ $this->assertEquals(
+ [
+ 'aws:x-amz-cek-alg' => 'AES/GCM/NoPadding'
+ ],
+ $cmd['EncryptionContext']
+ );
+ return Create::promiseFor(
+ new Result(['Plaintext' => random_bytes(32)])
+ );
+ });
+ $provider = new KmsMaterialsProviderV2($kms, 'foo');
+
+ $responded = false;
$s3Client = new S3Client([
'region' => 'us-east-2',
'handler' => function (
@@ -531,23 +585,38 @@ public function testUserAgentCaptureS3CryptoV2Metric()
return new Result([
'Body' => 'This is a test body'
]);
- }
+ },
+ 'http_handler' => function () use ($provider, &$responded) {
+ if ($responded) {
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ json_encode(
+ $this->getValidV2GcmMetadataFields($provider)
+ )
+ ));
+ }
+
+ $responded = true;
+ return new FulfilledPromise(new Response(
+ 200,
+ [],
+ 'test'
+ ));
+ },
]);
- $encryptionClient = $this->getMockBuilder(S3EncryptionClientV2::class)
- ->setConstructorArgs([$s3Client])
- ->setMethods(['decrypt'])
- ->getMock();
- $encryptionClient->expects($this->once())
- ->method('decrypt')
- ->withAnyParameters()
- ->willReturn(base64_encode('Test body'));
- $materialProvider = $this->createMock(MaterialsProviderV2::class);
- $encryptionClient->getObject([
+ $encryptionClient = new S3EncryptionClientV2(
+ $s3Client,
+ InstructionFileMetadataStrategy::DEFAULT_FILE_SUFFIX
+ );
+ $result = $encryptionClient->getObject([
'Bucket' => 'foo',
'Key' => 'foo',
- '@MaterialsProvider' => $materialProvider,
- '@SecurityProfile' => 'V2'
+ '@MaterialsProvider' => $provider,
+ '@SecurityProfile' => 'V2',
+ '@CommitmentPolicy' => 'FORBID_ENCRYPT_ALLOW_DECRYPT'
]);
+ $this->assertInstanceOf(AesGcmDecryptingStream::class, $result['Body']);
}
/**
@@ -791,6 +860,22 @@ private function getTestDynamoDBClient(
'region' => 'us-east-2',
] + $args);
}
+
+ /**
+ * Returns a test kms client,
+ *
+ * @return KmsClient
+ */
+ protected function getTestKmsClient(): mixed
+ {
+ static $client = null;
+
+ if (!$client) {
+ $client = $this->getTestClient('Kms');
+ }
+
+ return $client;
+ }
/**
* @throws \Exception