From 0566fecbd7d6d1bed871bd1d140697303b62258c Mon Sep 17 00:00:00 2001 From: "Paragon Initiative Enterprises, LLC" Date: Sat, 11 May 2024 02:31:19 -0400 Subject: [PATCH] Enhanced AAD Support - Added a new AAD class, which allows users to bind an encrypted field to the contents of multiple plaintext fields - EncryptedFile now accepts an optional AAD param, which binds the file's contents to the AAD value - Improved test coverage - EncryptedRow now allows you to automatically bind fields to their context (i.e. primary key) - EncryptedMultiRows now allows you to enable auto-binding mode, which ensures that all fields are explicitly bound (via the AAD parameter) to, at minimum, the database row primary key, table name, and field name --- .gitignore | 1 + README.md | 4 +- composer.json | 3 +- src/AAD.php | 185 +++++++++++++++++++++++ src/Backend/BoringCrypto.php | 23 ++- src/Backend/FIPSCrypto.php | 29 +++- src/Backend/ModernCrypto.php | 23 ++- src/Contract/BackendInterface.php | 7 +- src/EncryptedField.php | 12 +- src/EncryptedFile.php | 57 ++++--- src/EncryptedJsonField.php | 16 +- src/EncryptedMultiRows.php | 67 +++++++-- src/EncryptedRow.php | 196 +++++++++++++++++-------- src/Exception/CipherSweetException.php | 4 +- src/Exception/InvalidAADException.php | 8 + tests/AADTest.php | 57 +++++++ tests/EncryptedFileTest.php | 44 ++++++ tests/EncryptedMultiRowsTest.php | 131 ++++++++++++++--- tests/EncryptedRowTest.php | 144 +++++++++++++----- 19 files changed, 826 insertions(+), 185 deletions(-) create mode 100644 src/AAD.php create mode 100644 src/Exception/InvalidAADException.php create mode 100644 tests/AADTest.php diff --git a/.gitignore b/.gitignore index 77aae3d..62d1e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.gitignore /.idea /composer.lock /vendor diff --git a/README.md b/README.md index 9ccacad..c365af0 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ or noncommercial, open source or proprietary, at no cost to you. with extended nonces to minimize users' rekeying burden). * **Compliance-Specific Protocol Support.** Multiple backends to satisfy a diverse range of compliance requirements. More can be added as needed: - * `ModernCrypto` uses [libsodium](https://download.libsodium.org/doc/), the de + * `BoringCrypto` uses [libsodium](https://download.libsodium.org/doc/), the de facto standard encryption library for software developers. [Algorithm details](https://ciphersweet.paragonie.com/security#moderncrypto). * `FIPSCrypto` only uses the cryptographic algorithms covered by the - FIPS 140-2 recommendations to avoid auditing complexity. + FIPS 140-3 recommendations to avoid auditing complexity. [Algorithm details](https://ciphersweet.paragonie.com/security#fipscrypto). * **Key separation.** Each column is encrypted with a different key, all of which are derived from your master encryption key using secure key-splitting algorithms. diff --git a/composer.json b/composer.json index 004d44c..6780304 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "encrypt", "encryption", "field-level encryption", - "FIPS 140-2", + "NIST cryptography", + "FIPS 140-3", "libsodium", "queryable encryption", "searchable encryption", diff --git a/src/AAD.php b/src/AAD.php new file mode 100644 index 0000000..b895af2 --- /dev/null +++ b/src/AAD.php @@ -0,0 +1,185 @@ +fieldNames, true)) { + $this->fieldNames [] = $fieldName; + } + return $this; + } + + public function addLiteral(string $literal): self + { + if (!in_array($literal, $this->literals, true)) { + $this->literals [] = $literal; + } + return $this; + } + + /** + * Returns a canonicalized string representing these AAD inputs + * + * @param array $plaintextRow + * @return string + */ + public function canonicalize(array $plaintextRow = []): string + { + if ($this->legacy) { + if (count($this->fieldNames) === 0 && count($this->literals) === 0) { + return ''; + } + // Old behavior, only one value, so we just return that: + if (count($this->fieldNames) === 1) { + $fieldName = array_values($this->fieldNames)[0]; + return (string) $plaintextRow[$fieldName]; + } elseif (count($this->literals) === 1) { + return (string) array_values($this->literals)[0]; + } + } + // We assume field names and literal AAD values are not sensitive + // and can therefore be sorted without worry of side-channel leaks + sort($this->fieldNames); + sort($this->literals); + + $encoded = ''; + // First 8 bytes: number of pieces total + $count = count($this->fieldNames) + count($this->literals); + $encoded .= self::le64($count); + + // Next 8 bytes: number of fields + $count = count($this->fieldNames); + $encoded .= self::le64($count); + + // Next 8 bytes: number of literals + $count = count($this->literals); + $encoded .= self::le64($count); + + // Now let's encode each field + // |name| + name + |value| + value + foreach ($this->fieldNames as $fieldName) { + $encoded .= self::le64(Binary::safeStrlen($fieldName)); + $encoded .= $fieldName; + + $fieldValue = (string) ($plaintextRow[$fieldName] ?? ''); + $encoded .= self::le64(Binary::safeStrlen($fieldValue)); + $encoded .= $fieldValue; + } + + // Now encode each literal value + // |value| + value + foreach ($this->literals as $literal) { + $literalValue = (string) $literal; + $encoded .= self::le64(Binary::safeStrlen($literalValue)); + $encoded .= $literalValue; + } + + // We should now have a canonical string representing this AAD + return $encoded; + } + + /** + * Return a new AAD object with all field values collapsed to literals. + * + * @param array $row + * @return self + */ + public function getCollapsed(array $row): self + { + $clone = new AAD([], $this->literals); + sort($this->fieldNames); + foreach ($this->fieldNames as $fieldName) { + if (array_key_exists($fieldName, $row)) { + $clone->addLiteral((string) $row[$fieldName]); + } + } + sort($clone->literals); + return $clone; + } + + public function getFieldNames(): array + { + return $this->fieldNames; + } + + public function getLiterals(): array + { + return $this->literals; + } + + /** + * Append multiple AADs to the same field + * + * @param AAD $other + * @return $this + */ + public function merge(AAD $other): self + { + $self = clone $this; + foreach ($other->fieldNames as $fieldName) { + if (!in_array($fieldName, $self->fieldNames, true)) { + $self->fieldNames []= $fieldName; + } + } + foreach ($other->literals as $literal) { + if (!in_array($literal, $self->literals, true)) { + $self->literals []= $literal; + } + } + // We aren't using legacy mode for this: + $self->legacy = false; + return $self; + } + + /** + * Enforce for a field name. Enforces legacy behavior. + * + * @param string|AAD $input + * @return self + */ + public static function field(string|AAD $input): self + { + if ($input instanceof AAD) { + return clone $input; + } elseif (empty($input)) { + return new AAD([], [], true); + } + return new AAD([$input], [], true); + } + + /** + * Initialize for a string literal. Enforces legacy behavior. + * + * @param string|AAD $input + * @return self + */ + public static function literal(string|AAD $input): self + { + if ($input instanceof AAD) { + return clone $input; + } elseif (empty($input)) { + return new AAD([], [], true); + } + return new AAD([], [$input], true); + } + + private static function le64(int $length): string + { + return pack('P', $length); + } +} diff --git a/src/Backend/BoringCrypto.php b/src/Backend/BoringCrypto.php index fcd38be..344d672 100644 --- a/src/Backend/BoringCrypto.php +++ b/src/Backend/BoringCrypto.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace ParagonIE\CipherSweet\Backend; +use ParagonIE\CipherSweet\AAD; use ParagonIE\CipherSweet\Backend\Key\SymmetricKey; use ParagonIE\CipherSweet\Constants; use ParagonIE\CipherSweet\Contract\{ @@ -302,6 +303,7 @@ public function deriveKeyFromPassword( * @param resource $outputFP * @param SymmetricKey $key * @param int $chunkSize + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -311,7 +313,8 @@ public function doStreamDecrypt( $inputFP, $outputFP, SymmetricKey $key, - int $chunkSize = 8192 + int $chunkSize = 8192, + ?AAD $aad = null ): bool { \fseek($inputFP, 0, SEEK_SET); \fseek($outputFP, 0, SEEK_SET); @@ -339,6 +342,13 @@ public function doStreamDecrypt( self::MAC_SIZE ); SodiumCompat::crypto_generichash_update($b2mac, (string) (static::MAGIC_HEADER) . $salt . $nonce); + // Include optional AAD + if ($aad) { + $aadCanon = $aad->canonicalize(); + $adlen += Binary::safeStrlen($aadCanon); + SodiumCompat::crypto_generichash_update($b2mac, $aadCanon); + unset($aadCanon); + } $pos = \ftell($inputFP); $chunkMacKey = $this->getIntegrityKey($this->getIntegrityKey($key))->getRawKey(); @@ -401,6 +411,7 @@ public function doStreamDecrypt( * @param SymmetricKey $key * @param int $chunkSize * @param string $salt + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -411,7 +422,8 @@ public function doStreamEncrypt( $outputFP, SymmetricKey $key, int $chunkSize = 8192, - string $salt = Constants::DUMMY_SALT + string $salt = Constants::DUMMY_SALT, + ?AAD $aad = null ): bool { \fseek($inputFP, 0, SEEK_SET); \fseek($outputFP, 0, SEEK_SET); @@ -440,6 +452,13 @@ public function doStreamEncrypt( self::MAC_SIZE ); SodiumCompat::crypto_generichash_update($b2mac, (string) (static::MAGIC_HEADER) . $salt . $nonce); + // Include optional AAD + if ($aad) { + $aadCanon = $aad->canonicalize(); + $adlen += Binary::safeStrlen($aadCanon); + SodiumCompat::crypto_generichash_update($b2mac, $aadCanon); + unset($aadCanon); + } $ctr = 1; $ctrIncrease = ($chunkSize + 63) >> 6; diff --git a/src/Backend/FIPSCrypto.php b/src/Backend/FIPSCrypto.php index c639d02..2684c05 100644 --- a/src/Backend/FIPSCrypto.php +++ b/src/Backend/FIPSCrypto.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace ParagonIE\CipherSweet\Backend; +use ParagonIE\CipherSweet\AAD; use ParagonIE\CipherSweet\Constants; use ParagonIE\CipherSweet\Backend\Key\SymmetricKey; use ParagonIE\CipherSweet\Contract\{ @@ -22,10 +23,13 @@ /** * Class FIPSCrypto * - * This only uses algorithms supported by FIPS-140-2. + * This only uses algorithms supported by FIPS 140-3. + * + * If you use a FIPS module with OpenSSL, we expect this backend to work. + * If it doesn't, that is a bug. * * Please consult your FIPS compliance auditor before you claim that your use - * of this library is FIPS 140-2 compliant. + * of this library is FIPS 140-3 compliant. * * @ref https://csrc.nist.gov/CSRC/media//Publications/fips/140/2/final/documents/fips1402annexa.pdf * @ref https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38a.pdf @@ -315,6 +319,7 @@ public function deriveKeyFromPassword( * @param resource $outputFP * @param SymmetricKey $key * @param int $chunkSize + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -324,7 +329,8 @@ public function doStreamDecrypt( $inputFP, $outputFP, SymmetricKey $key, - int $chunkSize = 8192 + int $chunkSize = 8192, + ?AAD $aad = null ): bool { \fseek($inputFP, 0, SEEK_SET); \fseek($outputFP, 0, SEEK_SET); @@ -349,6 +355,12 @@ public function doStreamDecrypt( \hash_update($hmac, $salt); \hash_update($hmac, $hkdfSalt); \hash_update($hmac, $ctrNonce); + // Include optional AAD + if ($aad) { + $aadCanon = $aad->canonicalize(); + \hash_update($hmac, $aadCanon); + unset($aadCanon); + } $pos = \ftell($inputFP); // MAC each chunk in memory to defend against race conditions @@ -404,6 +416,7 @@ public function doStreamDecrypt( * @param SymmetricKey $key * @param int $chunkSize * @param string $salt + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -413,7 +426,8 @@ public function doStreamEncrypt( $outputFP, SymmetricKey $key, int $chunkSize = 8192, - string $salt = Constants::DUMMY_SALT + string $salt = Constants::DUMMY_SALT, + ?AAD $aad = null ): bool { \fseek($inputFP, 0, SEEK_SET); \fseek($outputFP, 0, SEEK_SET); @@ -440,6 +454,13 @@ public function doStreamEncrypt( \hash_update($hmac, $hkdfSalt); \hash_update($hmac, $ctrNonce); + // Include optional AAD + if ($aad) { + $aadCanon = $aad->canonicalize(); + \hash_update($hmac, $aadCanon); + unset($aadCanon); + } + // We want to increase our CTR value by the number of blocks we used previously $ctrIncrease = ($chunkSize + 15) >> 4; do { diff --git a/src/Backend/ModernCrypto.php b/src/Backend/ModernCrypto.php index 8c12ca2..c9ad6c8 100644 --- a/src/Backend/ModernCrypto.php +++ b/src/Backend/ModernCrypto.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace ParagonIE\CipherSweet\Backend; +use ParagonIE\CipherSweet\AAD; use ParagonIE\CipherSweet\Backend\Key\SymmetricKey; use ParagonIE\CipherSweet\Constants; use ParagonIE\CipherSweet\Contract\BackendInterface; @@ -256,6 +257,7 @@ public function deriveKeyFromPassword( * @param resource $outputFP * @param SymmetricKey $key * @param int $chunkSize + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -265,7 +267,8 @@ public function doStreamDecrypt( $inputFP, $outputFP, SymmetricKey $key, - int $chunkSize = 8192 + int $chunkSize = 8192, + ?AAD $aad = null ): bool { \fseek($inputFP, 0, SEEK_SET); \fseek($outputFP, 0, SEEK_SET); @@ -298,6 +301,13 @@ public function doStreamDecrypt( // Update the Poly1305 authenticator with our metadata $poly1305->update((string) (static::MAGIC_HEADER) . $salt . $nonce); + // Include optional AAD + if ($aad) { + $aadCanon = $aad->canonicalize(); + $adlen += Binary::safeStrlen($aadCanon); + $poly1305->update($aadCanon); + unset($aadCanon); + } $poly1305->update(str_repeat("\x00", ((0x10 - $adlen) & 0xf))); $pos = \ftell($inputFP); @@ -363,6 +373,7 @@ public function doStreamDecrypt( * @param SymmetricKey $key * @param int $chunkSize * @param string $salt + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -373,7 +384,8 @@ public function doStreamEncrypt( $outputFP, SymmetricKey $key, int $chunkSize = 8192, - string $salt = Constants::DUMMY_SALT + string $salt = Constants::DUMMY_SALT, + ?AAD $aad = null ): bool { \fseek($inputFP, 0, SEEK_SET); \fseek($outputFP, 0, SEEK_SET); @@ -402,6 +414,13 @@ public function doStreamEncrypt( // Update the Poly1305 authenticator with our metadata $poly1305->update((string) (static::MAGIC_HEADER) . $salt . $nonce); + // Include optional AAD + if ($aad) { + $aadCanon = $aad->canonicalize(); + $adlen += Binary::safeStrlen($aadCanon); + $poly1305->update($aadCanon); + unset($aadCanon); + } $poly1305->update(str_repeat("\x00", ((0x10 - $adlen) & 0xf))); // XChaCha20-Poly1305 diff --git a/src/Contract/BackendInterface.php b/src/Contract/BackendInterface.php index fb874bf..b5502d2 100644 --- a/src/Contract/BackendInterface.php +++ b/src/Contract/BackendInterface.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace ParagonIE\CipherSweet\Contract; +use ParagonIE\CipherSweet\AAD; use ParagonIE\CipherSweet\Backend\Key\SymmetricKey; use ParagonIE\CipherSweet\Constants; @@ -79,7 +80,8 @@ public function doStreamDecrypt( $inputFP, $outputFP, SymmetricKey $key, - int $chunkSize = 8192 + int $chunkSize = 8192, + ?AAD $aad = null ): bool; /** @@ -95,7 +97,8 @@ public function doStreamEncrypt( $outputFP, SymmetricKey $key, int $chunkSize = 8192, - string $salt = Constants::DUMMY_SALT + string $salt = Constants::DUMMY_SALT, + ?AAD $aad = null ): bool; /** diff --git a/src/EncryptedField.php b/src/EncryptedField.php index 058dc04..4fdd31d 100644 --- a/src/EncryptedField.php +++ b/src/EncryptedField.php @@ -108,7 +108,7 @@ public function prepareForStorage( #[\SensitiveParameter] string $plaintext, #[\SensitiveParameter] - string $aad = '' + string|AAD $aad = '' ): array { return [ $this->encryptValue($plaintext, $aad), @@ -120,14 +120,14 @@ public function prepareForStorage( * Encrypt a single value, using the per-field symmetric key. * * @param string $plaintext - * @param string $aad Additional authenticated data + * @param string|AAD $aad Additional authenticated data * @return string */ public function encryptValue( #[\SensitiveParameter] string $plaintext, #[\SensitiveParameter] - string $aad = '' + string|AAD $aad = '' ): string { return $this ->engine @@ -135,7 +135,7 @@ public function encryptValue( ->encrypt( $plaintext, $this->key, - $aad + AAD::literal($aad)->canonicalize() ); } @@ -146,7 +146,7 @@ public function decryptValue( #[\SensitiveParameter] string $ciphertext, #[\SensitiveParameter] - string $aad = '' + string|AAD $aad = '' ): string { return $this ->engine @@ -154,7 +154,7 @@ public function decryptValue( ->decrypt( $ciphertext, $this->key, - $aad + AAD::literal($aad)->canonicalize() ); } diff --git a/src/EncryptedFile.php b/src/EncryptedFile.php index 86d9531..280cc7d 100644 --- a/src/EncryptedFile.php +++ b/src/EncryptedFile.php @@ -63,12 +63,14 @@ public function getEngine(): CipherSweet * * @param string $inputFile * @param string $outputFile + * @param ?AAD $aad * @return bool * + * @throws CipherSweetException * @throws CryptoOperationException * @throws FilesystemException */ - public function decryptFile(string $inputFile, string $outputFile): bool + public function decryptFile(string $inputFile, string $outputFile, ?AAD $aad = null): bool { if (\realpath($inputFile) === \realpath($outputFile)) { $inputRealStream = $this->getStreamForFile($inputFile, 'rb'); @@ -79,7 +81,7 @@ public function decryptFile(string $inputFile, string $outputFile): bool } $outputStream = $this->getStreamForFile($outputFile); try { - return $this->decryptStream($inputStream, $outputStream); + return $this->decryptStream($inputStream, $outputStream, $aad); } finally { \fclose($inputStream); \fclose($outputStream); @@ -92,16 +94,16 @@ public function decryptFile(string $inputFile, string $outputFile): bool * @param string $inputFile * @param string $outputFile * @param string $password + * @param ?AAD $aad * @return bool * - * @throws CryptoOperationException * @throws FilesystemException - * @throws SodiumException */ public function decryptFileWithPassword( string $inputFile, string $outputFile, - string $password + string $password, + ?AAD $aad = null ): bool { if (\realpath($inputFile) === \realpath($outputFile)) { $inputRealStream = $this->getStreamForFile($inputFile, 'rb'); @@ -115,7 +117,8 @@ public function decryptFileWithPassword( return $this->decryptStreamWithPassword( $inputStream, $outputStream, - $password + $password, + $aad ); } finally { \fclose($inputStream); @@ -128,12 +131,13 @@ public function decryptFileWithPassword( * * @param resource $inputFP * @param resource $outputFP + * @param ?AAD $aad * @return bool * * @throws CipherSweetException * @throws CryptoOperationException */ - public function decryptStream($inputFP, $outputFP): bool + public function decryptStream($inputFP, $outputFP, ?AAD $aad = null): bool { $key = $this->engine->getFieldSymmetricKey( Constants::FILE_TABLE, @@ -143,7 +147,8 @@ public function decryptStream($inputFP, $outputFP): bool $inputFP, $outputFP, $key, - $this->chunkSize + $this->chunkSize, + $aad ); } @@ -153,12 +158,14 @@ public function decryptStream($inputFP, $outputFP): bool * @param resource $inputFP * @param resource $outputFP * @param string $password + * @param ?AAD $aad * @return bool */ public function decryptStreamWithPassword( $inputFP, $outputFP, - string $password + string $password, + ?AAD $aad = null ): bool { $backend = $this->engine->getBackend(); $salt = $this->getSaltFromStream($inputFP); @@ -167,7 +174,8 @@ public function decryptStreamWithPassword( $inputFP, $outputFP, $key, - $this->chunkSize + $this->chunkSize, + $aad ); } @@ -176,14 +184,14 @@ public function decryptStreamWithPassword( * * @param string $inputFile * @param string $outputFile + * @param ?AAD $aad * @return bool * * @throws CipherSweetException * @throws CryptoOperationException * @throws FilesystemException - * @throws SodiumException */ - public function encryptFile(string $inputFile, string $outputFile): bool + public function encryptFile(string $inputFile, string $outputFile, ?AAD $aad = null): bool { if (\realpath($inputFile) === \realpath($outputFile)) { $inputRealStream = $this->getStreamForFile($inputFile, 'rb'); @@ -194,7 +202,7 @@ public function encryptFile(string $inputFile, string $outputFile): bool } $outputStream = $this->getStreamForFile($outputFile); try { - return $this->encryptStream($inputStream, $outputStream); + return $this->encryptStream($inputStream, $outputStream, $aad); } finally { \fclose($inputStream); \fclose($outputStream); @@ -207,16 +215,17 @@ public function encryptFile(string $inputFile, string $outputFile): bool * @param string $inputFile * @param string $outputFile * @param string $password + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException * @throws FilesystemException - * @throws SodiumException */ public function encryptFileWithPassword( string $inputFile, string $outputFile, - string $password + string $password, + ?AAD $aad = null ): bool { if (\realpath($inputFile) === \realpath($outputFile)) { $inputRealStream = $this->getStreamForFile($inputFile, 'rb'); @@ -230,7 +239,8 @@ public function encryptFileWithPassword( return $this->encryptStreamWithPassword( $inputStream, $outputStream, - $password + $password, + $aad ); } finally { \fclose($inputStream); @@ -243,12 +253,13 @@ public function encryptFileWithPassword( * * @param resource $inputFP * @param resource $outputFP + * @param ?AAD $aad * @return bool * * @throws CipherSweetException * @throws CryptoOperationException */ - public function encryptStream($inputFP, $outputFP): bool + public function encryptStream($inputFP, $outputFP, ?AAD $aad = null): bool { $key = $this->engine->getFieldSymmetricKey( Constants::FILE_TABLE, @@ -258,7 +269,9 @@ public function encryptStream($inputFP, $outputFP): bool $inputFP, $outputFP, $key, - $this->chunkSize + $this->chunkSize, + Constants::DUMMY_SALT, + $aad ); } @@ -268,6 +281,7 @@ public function encryptStream($inputFP, $outputFP): bool * @param resource $inputFP * @param resource $outputFP * @param string $password + * @param ?AAD $aad * @return bool * * @throws CryptoOperationException @@ -275,7 +289,8 @@ public function encryptStream($inputFP, $outputFP): bool public function encryptStreamWithPassword( $inputFP, $outputFP, - string $password + string $password, + ?AAD $aad = null ): bool { try { // Do not generate a dummy salt! @@ -293,7 +308,8 @@ public function encryptStreamWithPassword( $outputFP, $key, $this->chunkSize, - $salt + $salt, + $aad ); } @@ -319,7 +335,6 @@ public function getSaltFromStream($inputFP): string * @return bool * * @throws FilesystemException - * @throws SodiumException */ public function isFileEncrypted(string $filename): bool { diff --git a/src/EncryptedJsonField.php b/src/EncryptedJsonField.php index e46d17d..126c305 100644 --- a/src/EncryptedJsonField.php +++ b/src/EncryptedJsonField.php @@ -137,7 +137,7 @@ public function addField(array $indices, string $type): static /** * @throws CipherSweetException */ - public function decryptJson(string $encoded, string $aad = ''): array + public function decryptJson(string $encoded, string|AAD $aad = ''): array { $field = \json_decode($encoded, true); if (!\is_array($field)) { @@ -169,7 +169,7 @@ public function decryptJson(string $encoded, string $aad = ''): array * @throws CipherSweetException * @throws SodiumException */ - public function encryptJson(array $field, string $aad = ''): string + public function encryptJson(array $field, string|AAD $aad = ''): string { /** * @var array{flat: string, path: array, type: string} $mapped @@ -214,7 +214,7 @@ public function setStrictMode(bool $bool = false): static * @param array &$field * @param array $path * @param string $type - * @param string $aad + * @param string|AAD $aad * @return void * * @throws CipherSweetException @@ -226,7 +226,7 @@ protected function decryptInPlace( array &$field, array $path, string $type, - string $aad = '' + string|AAD $aad = '' ): void { // Walk down the path $curr = &$field; @@ -243,7 +243,7 @@ protected function decryptInPlace( if (\is_null($curr)) { return; } - $decrypted = $this->backend->decrypt($curr, $derivedKey, $aad); + $decrypted = $this->backend->decrypt($curr, $derivedKey, AAD::literal($aad)->canonicalize()); $curr = $this->convertFromString($decrypted, $type); } @@ -252,7 +252,7 @@ protected function decryptInPlace( * @param array &$field * @param array $path * @param string $type - * @param string $aad + * @param string|AAD $aad * @return void * * @throws CipherSweetException @@ -264,7 +264,7 @@ protected function encryptInPlace( array &$field, array $path, string $type, - string $aad = '' + string|AAD $aad = '' ): void { // Walk down the path $curr = &$field; @@ -285,7 +285,7 @@ protected function encryptInPlace( $curr = $this->backend->encrypt( $this->convertToString($curr, $type), $derivedKey, - $aad + AAD::literal($aad)->canonicalize() ); } diff --git a/src/EncryptedMultiRows.php b/src/EncryptedMultiRows.php index e7df7b4..5ace24b 100644 --- a/src/EncryptedMultiRows.php +++ b/src/EncryptedMultiRows.php @@ -37,16 +37,25 @@ class EncryptedMultiRows */ protected ?bool $permitEmpty = null; + /** + * @var bool $autoBindContext + */ + protected bool $autoBindContext = false; + /** * EncryptedFieldSet constructor. * * @param CipherSweet $engine * @param bool $useTypedIndexes */ - public function __construct(CipherSweet $engine, bool $useTypedIndexes = false) - { + public function __construct( + CipherSweet $engine, + bool $useTypedIndexes = false, + bool $autoBindContext = false + ) { $this->engine = $engine; $this->typedIndexes = $useTypedIndexes; + $this->autoBindContext = $autoBindContext; } /** @@ -72,10 +81,18 @@ public function addField( string $tableName, string $fieldName, string $type = Constants::TYPE_TEXT, - string $aadSource = '' + string|AAD $aadSource = '' ): static { + if ($this->autoBindContext) { + // We automatically bind every field to the table and column name + if (empty($aadSource)) { + $aadSource = new AAD(); + } + $aadSource = AAD::field($aadSource) + ->merge(AAD::literal('table=' . $tableName . ';field=' . $fieldName)); + } $this->getEncryptedRowObjectForTable($tableName) - ->addField($fieldName, $type, $aadSource); + ->addField($fieldName, $type, $aadSource, $this->autoBindContext); return $this; } @@ -87,7 +104,7 @@ public function addField( public function addBooleanField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -105,7 +122,7 @@ public function addBooleanField( public function addFloatField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -123,7 +140,7 @@ public function addFloatField( public function addIntegerField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -142,7 +159,7 @@ public function addJsonField( string $tableName, string $fieldName, JsonFieldMap $fieldMap, - string $aadSource = '', + string|AAD $aadSource = '', bool $strict = true ): static { $this->getEncryptedRowObjectForTable($tableName) @@ -158,7 +175,7 @@ public function addJsonField( public function addTextField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -177,7 +194,7 @@ public function addTextField( public function addOptionalBooleanField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -195,7 +212,7 @@ public function addOptionalBooleanField( public function addOptionalFloatField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -213,7 +230,7 @@ public function addOptionalFloatField( public function addOptionalIntegerField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -232,7 +249,7 @@ public function addOptionalJsonField( string $tableName, string $fieldName, JsonFieldMap $fieldMap, - string $aadSource = '', + string|AAD $aadSource = '', bool $strict = true ): static { $this->getEncryptedRowObjectForTable($tableName) @@ -248,7 +265,7 @@ public function addOptionalJsonField( public function addOptionalTextField( string $tableName, string $fieldName, - string $aadSource = '' + string|AAD $aadSource = '' ): static { return $this->addField( $tableName, @@ -536,10 +553,30 @@ public function listTables(): array return \array_keys($this->tables); } + /** + * @param bool $autoBindContext + * @return self + */ + public function setAutoBindContext(bool $autoBindContext = false): self + { + $this->autoBindContext = $autoBindContext; + return $this; + } + + /** + * @throws CipherSweetException + */ + public function setPrimaryKeyColumnName(string $tableName, ?string $columnName): self + { + $this->getEncryptedRowObjectForTable($tableName) + ->setPrimaryKeyColumnName($columnName); + return $this; + } + /** * @throws CipherSweetException */ - public function setAadSourceField(string $tableName, string $fieldName, string $aadSource): static + public function setAadSourceField(string $tableName, string $fieldName, string|AAD $aadSource): static { $this->getEncryptedRowObjectForTable($tableName) ->setAadSourceField($fieldName, $aadSource); diff --git a/src/EncryptedRow.php b/src/EncryptedRow.php index adcb317..0a66899 100644 --- a/src/EncryptedRow.php +++ b/src/EncryptedRow.php @@ -10,6 +10,7 @@ CipherSweetException, CryptoOperationException, EmptyFieldException, + InvalidAADException, InvalidCiphertextException }; use ParagonIE\ConstantTime\Hex; @@ -50,7 +51,7 @@ class EncryptedRow protected array $jsonStrict = []; /** - * @var array $aadSourceField + * @var array $aadSourceField */ protected array $aadSourceField = []; @@ -79,22 +80,30 @@ class EncryptedRow */ protected string $tableName; + /** + * @var ?string $primaryKeyColumnName + */ + protected ?string $primaryKeyColumnName; + /** * EncryptedFieldSet constructor. * * @param CipherSweet $engine * @param string $tableName * @param bool $useTypedIndexes + * @param ?string $primaryKeyColumnName */ public function __construct( CipherSweet $engine, #[\SensitiveParameter] string $tableName, - bool $useTypedIndexes = false + bool $useTypedIndexes = false, + ?string $primaryKeyColumnName = null ) { $this->engine = $engine; $this->tableName = $tableName; $this->typedIndexes = $useTypedIndexes; + $this->primaryKeyColumnName = $primaryKeyColumnName; } /** @@ -102,17 +111,25 @@ public function __construct( * * @param string $fieldName * @param string $type - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ public function addField( string $fieldName, string $type = Constants::TYPE_TEXT, - string $aadSource = '' + string|AAD $aadSource = '', + bool $autoBindContext = false ): static { $this->fieldsToEncrypt[$fieldName] = $type; - if ($aadSource) { - $this->aadSourceField[$fieldName] = $aadSource; + // If we set a primary key column name, we bind it to that field's value: + if ($autoBindContext) { + if (!is_null($this->primaryKeyColumnName)) { + $aadSource = AAD::field($aadSource) + ->merge(AAD::field($this->primaryKeyColumnName)); + } + $this->aadSourceField[$fieldName] = AAD::field($aadSource); + } elseif ($aadSource) { + $this->aadSourceField[$fieldName] = AAD::field($aadSource); } return $this; } @@ -121,10 +138,10 @@ public function addField( * Define a boolean field that will be encrypted. Nullable. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addBooleanField(string $fieldName, string $aadSource = ''): static + public function addBooleanField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_BOOLEAN, $aadSource); } @@ -133,10 +150,10 @@ public function addBooleanField(string $fieldName, string $aadSource = ''): stat * Define a floating point number (decimal) field that will be encrypted. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addFloatField(string $fieldName, string $aadSource = ''): static + public function addFloatField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_FLOAT, $aadSource); } @@ -145,10 +162,10 @@ public function addFloatField(string $fieldName, string $aadSource = ''): static * Define an integer field that will be encrypted. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addIntegerField(string $fieldName, string $aadSource = ''): static + public function addIntegerField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_INT, $aadSource); } @@ -157,10 +174,10 @@ public function addIntegerField(string $fieldName, string $aadSource = ''): stat * Define a boolean field that will be encrypted. Permits NULL. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addOptionalBooleanField(string $fieldName, string $aadSource = ''): static + public function addOptionalBooleanField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_OPTIONAL_BOOLEAN, $aadSource); } @@ -169,10 +186,10 @@ public function addOptionalBooleanField(string $fieldName, string $aadSource = ' * Define a floating point number (decimal) field that will be encrypted. Permits NULL. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addOptionalFloatField(string $fieldName, string $aadSource = ''): static + public function addOptionalFloatField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_OPTIONAL_FLOAT, $aadSource); } @@ -181,10 +198,10 @@ public function addOptionalFloatField(string $fieldName, string $aadSource = '') * Define an integer field that will be encrypted. Permits NULL. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addOptionalIntegerField(string $fieldName, string $aadSource = ''): static + public function addOptionalIntegerField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_OPTIONAL_INT, $aadSource); } @@ -193,10 +210,10 @@ public function addOptionalIntegerField(string $fieldName, string $aadSource = ' * Define an integer field that will be encrypted. Permits NULL. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addOptionalTextField(string $fieldName, string $aadSource = ''): static + public function addOptionalTextField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_OPTIONAL_TEXT, $aadSource); } @@ -206,14 +223,14 @@ public function addOptionalTextField(string $fieldName, string $aadSource = ''): * * @param string $fieldName * @param JsonFieldMap $fieldMap - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @param bool $strict * @return static */ public function addNullableJsonField( string $fieldName, JsonFieldMap $fieldMap, - string $aadSource = '', + string|AAD $aadSource = '', bool $strict = true ): static { $this->jsonMaps[$fieldName] = $fieldMap; @@ -226,14 +243,14 @@ public function addNullableJsonField( * * @param string $fieldName * @param JsonFieldMap $fieldMap - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @param bool $strict * @return static */ public function addJsonField( string $fieldName, JsonFieldMap $fieldMap, - string $aadSource = '', + string|AAD $aadSource = '', bool $strict = true ): static { $this->jsonMaps[$fieldName] = $fieldMap; @@ -245,10 +262,10 @@ public function addJsonField( * Define a text field that will be encrypted. * * @param string $fieldName - * @param string $aadSource Field name to source AAD from + * @param string|AAD $aadSource Field name to source AAD from * @return static */ - public function addTextField(string $fieldName, string $aadSource = ''): static + public function addTextField(string $fieldName, string|AAD $aadSource = ''): static { return $this->addField($fieldName, Constants::TYPE_TEXT, $aadSource); } @@ -500,6 +517,7 @@ public function decryptRow( #[\SensitiveParameter] array $row ): array { + $this->throwIfPrimaryKeyMisconfigured($row); /** @var array $return */ $return = $row; $backend = $this->engine->getBackend(); @@ -532,16 +550,7 @@ public function decryptRow( } throw new TypeError('Invalid type for ' . $field); } - if ( - !empty($this->aadSourceField[$field]) - && - \array_key_exists($this->aadSourceField[$field], $row) - ) { - $aad = $this->coaxAadToString($row[$this->aadSourceField[$field]]); - } else { - $aad = ''; - } - + $aad = $this->canonicalizeAADForField($field, $row); if (in_array($type, Constants::TYPES_JSON, true) && !empty($this->jsonMaps[$field])) { // JSON is a special case $jsonEncryptor = new EncryptedJsonField( @@ -577,6 +586,7 @@ public function encryptRow( #[\SensitiveParameter] array $row ): array { + $this->throwIfPrimaryKeyMisconfigured($row); /** @var array $return */ $return = $row; $backend = $this->engine->getBackend(); @@ -592,15 +602,7 @@ public function encryptRow( $this->tableName, $field ); - if ( - !empty($this->aadSourceField[$field]) - && - array_key_exists($this->aadSourceField[$field], $row) - ) { - $aad = $this->coaxAadToString($row[$this->aadSourceField[$field]]); - } else { - $aad = ''; - } + $aad = $this->canonicalizeAADForField($field, $row); if (in_array($type, Constants::TYPES_JSON, true) && !empty($this->jsonMaps[$field])) { // JSON is a special case $jsonEncryptor = new EncryptedJsonField( @@ -688,12 +690,12 @@ public function listEncryptedFields(): array * column. * * @param string $fieldName - * @param string $aadSource + * @param string|AAD $aadSource * @return static */ - public function setAadSourceField(string $fieldName, string $aadSource): static + public function setAadSourceField(string $fieldName, string|AAD $aadSource): static { - $this->aadSourceField[$fieldName] = $aadSource; + $this->aadSourceField[$fieldName] = AAD::field($aadSource); return $this; } @@ -913,6 +915,24 @@ protected function calcCompoundIndexRaw( ); } + /** + * Get the AAD source for a given field. + * + * Returns an AAD object or the column name. + * + * @param string $fieldName + * @return AAD|string + * + * @throws CipherSweetException + */ + public function getAADSource(string $fieldName): AAD|string + { + if (!array_key_exists($fieldName, $this->aadSourceField)) { + throw new CipherSweetException('Source field not found for field: ' . $fieldName); + } + return $this->aadSourceField[$fieldName]; + } + /** * @return BackendInterface */ @@ -965,6 +985,16 @@ public function setPermitEmpty(bool $permitted): static return $this; } + /** + * @param ?string $columnName + * @return self + */ + public function setPrimaryKeyColumnName(?string $columnName = null): self + { + $this->primaryKeyColumnName = $columnName; + return $this; + } + /** * @return bool */ @@ -1005,22 +1035,6 @@ protected function coaxToArray(mixed $input): array throw new TypeError("Cannot coax to array: " . gettype($input)); } - /** - * @param mixed $input - * @return string - */ - protected function coaxAadToString(mixed $input): string - { - if (is_string($input)) { - return $input; - } - if (is_numeric($input)) { - return '' . $input; - } - /** psalm-suppress PossiblyInvalidCast */ - return (string) $input; - } - /** * New exception message to make it clear this is a deliberate behavior, not a bug. * @@ -1065,4 +1079,58 @@ protected function fieldNotOptional(string $field, string $type): void 'To fix this, try changing the type declaration from ' . $oldConst . ' to ' . $newConst . '.' ); } + + /** + * Canonicalize the AAD as a string OR return an empty string. + * + * @param string $field + * @param array $row + * @return string + * @throws InvalidAADException + */ + protected function canonicalizeAADForField(string $field, array $row): string + { + if (empty($this->aadSourceField[$field])) { + return ''; + } + if (is_string($this->aadSourceField[$field])) { + return $this->aadSourceField[$field];; + } + if (array_intersect( + array_keys($this->fieldsToEncrypt), + $this->aadSourceField[$field]->getFieldNames() + )) { + throw new InvalidAADException('Cannot use encrypted field as AAD - field: ' . $field); + } + return $this->aadSourceField[$field]->canonicalize($row); + } + + /** + * This method throws an exception if the object is misconfigured in a way that would + * allow accidental loss of data. + * + * i.e. You cannot encrypt the primary key and still bind other fields to it + * Additionally, you cannot bind other fields to it, if you didn't set it. + * + * @throws CipherSweetException + */ + protected function throwIfPrimaryKeyMisconfigured( + #[\SensitiveParameter] + array $row + ): void { + if (is_null($this->primaryKeyColumnName)) { + // Nothing to do here! + return; + } + if (!array_key_exists($this->primaryKeyColumnName, $row)) { + throw new CipherSweetException( + 'EncryptedRow is configured with a primary key name, so it must be pre-populated on inserts' + ); + } + if (in_array($this->primaryKeyColumnName, $this->fieldsToEncrypt, true)) { + throw new CipherSweetException( + 'Primary key must bot be encrypted' + ); + } + } } diff --git a/src/Exception/CipherSweetException.php b/src/Exception/CipherSweetException.php index 6aa78f0..4ca380f 100644 --- a/src/Exception/CipherSweetException.php +++ b/src/Exception/CipherSweetException.php @@ -2,7 +2,9 @@ declare(strict_types=1); namespace ParagonIE\CipherSweet\Exception; -class CipherSweetException extends \Exception +use Exception; + +class CipherSweetException extends Exception { } diff --git a/src/Exception/InvalidAADException.php b/src/Exception/InvalidAADException.php new file mode 100644 index 0000000..81d9008 --- /dev/null +++ b/src/Exception/InvalidAADException.php @@ -0,0 +1,8 @@ +assertSame('foo', $aad1->canonicalize()); + $this->assertSame('baz', $aad2->canonicalize(['bar' => 'baz'])); + } + + public function testMerge(): void + { + $aad1 = AAD::literal('foo'); + $aad2 = AAD::field('bar'); + $aad3 = $aad1->merge($aad2); + + $this->assertSame( + '0200000000000000010000000000000001000000000000000300000000000000626172030000000000000062617a0300000000000000666f6f', + Hex::encode($aad3->canonicalize(['bar' => 'baz'])), + 'Canonical encoding' + ); + } + + public function testCollapse(): void + { + $aad = new AAD(['foo', 'bar', 'baz'], ['apple', 'pear']); + $collapsed = $aad->getCollapsed([ + 'foo' => 'banana', + 'bar' => 'pineapple', + 'baz' => 'blueberry' + ]); + $fields = $collapsed->getFieldNames(); + $this->assertCount(0, $fields); + $literals = $collapsed->getLiterals(); + $this->assertCount(5, $literals); + } + + public function testFieldOrder(): void + { + $this->assertSame( + '02000000000000000200000000000000000000000000000005000000000000006170706c65030000000000000031323305000000000000007a656272610300000000000000313232', + Hex::encode((new AAD(['zebra', 'apple']))->canonicalize(['zebra' => '122', 'apple' => '123'])), + 'Keys must be sorted' + ); + } +} diff --git a/tests/EncryptedFileTest.php b/tests/EncryptedFileTest.php index 7daf95a..ed3cd06 100644 --- a/tests/EncryptedFileTest.php +++ b/tests/EncryptedFileTest.php @@ -1,7 +1,10 @@ brng) { + $this->before(); + } + return [ + [$this->brng], + [$this->fips], + [$this->nacl], + ]; + } + + /** + * @dataProvider encryptedFileProvider + */ + public function testEncryptedFileWithAAD(EncryptedFile $encFile): void + { + $message = "Paragon Initiative Enterprises\n" . \random_bytes(256); + + $input = $encFile->getStreamForFile('php://temp'); + \fwrite($input, $message); + $this->assertFalse($encFile->isStreamEncrypted($input)); + $aad = AAD::literal('unit testing'); + + $output = $encFile->getStreamForFile('php://temp'); + $encFile->encryptStream($input, $output, $aad); + $this->assertTrue($encFile->isStreamEncrypted($output)); + + $dummy1 = $encFile->getStreamForFile('php://temp'); + $encFile->decryptStream($output, $dummy1, $aad); + $contents = stream_get_contents($dummy1); + $this->assertSame($message, $contents, 'Sanity check on encryption'); + + try { + $dummy2 = $encFile->getStreamForFile('php://temp'); + $encFile->decryptStream($output, $dummy2); + $this->fail('Decryption with wrong AAD should fail!'); + } catch (CipherSweetException|\SodiumException) { + } + } + /** * @throws CryptoOperationException * @throws FilesystemException diff --git a/tests/EncryptedMultiRowsTest.php b/tests/EncryptedMultiRowsTest.php index 6bebbf8..d879a6a 100644 --- a/tests/EncryptedMultiRowsTest.php +++ b/tests/EncryptedMultiRowsTest.php @@ -1,6 +1,7 @@ fipsEngine = $this->createFipsEngine('4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc'); $this->naclEngine = $this->createModernEngine('4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc'); @@ -67,7 +69,7 @@ public function before() $this->boringRandom = $this->createBoringEngine(); } - public function testFlatInherits() + public function testFlatInherits(): void { $engines = [$this->fipsEngine, $this->fipsRandom, $this->naclEngine, $this->naclRandom]; foreach ($engines as $engine) { @@ -84,7 +86,7 @@ public function testFlatInherits() } } - public function testEncryptedMultiRowsSetup() + public function testEncryptedMultiRowsSetup(): void { $engines = [$this->fipsEngine, $this->fipsRandom, $this->naclEngine, $this->naclRandom]; foreach ($engines as $engine) { @@ -112,9 +114,12 @@ public function testEncryptedMultiRowsSetup() } /** + * @param ?CipherSweet $engine * @return EncryptedMultiRows + * @throws CipherSweetException + * @throws JsonMapException */ - public function getMultiRows($engine = null) + public function getMultiRows(?CipherSweet $engine = null): EncryptedMultiRows { if (empty($engine)) { $engine = $this->fipsEngine; @@ -148,13 +153,15 @@ public function getMultiRows($engine = null) * @throws CipherSweetException * @throws SodiumException */ - public function testUsage() + public function testUsage(): void { $mr = $this->getMultiRows(); $rows = $this->getDummyPlaintext(); $mr->setTypedIndexes(true); list($outRow, $indexes) = $mr->prepareForStorage($rows); + $again = $mr->getBlindIndexesForTable('foo', $rows['foo']); + $this->assertSame($again, $indexes['foo']); $decrypted = $mr->decryptManyRows($outRow); $this->assertIsNotArray($outRow['foo']['column4'], 'column4 not encrypted'); $this->assertNotSame($outRow, $decrypted, 'prepareForStorage() encryption'); @@ -186,18 +193,18 @@ public function testUsage() try { $mr->decryptManyRows($outRow2); $this->fail('AAD stripping was permitted'); - } catch (\Exception $ex) { + } catch (Exception $ex) { $this->assertInstanceOf(InvalidCiphertextException::class, $ex); } try { $mr2->decryptManyRows($outRow); $this->fail('AAD stripping was permitted'); - } catch (\Exception $ex) { + } catch (Exception $ex) { $this->assertInstanceOf(InvalidCiphertextException::class, $ex); } } - private function getDummyPlaintext() + private function getDummyPlaintext(): array { return [ 'foo' => [ @@ -229,7 +236,7 @@ private function getDummyPlaintext() * @throws CipherSweetException * @throws SodiumException */ - public function testXAllEngines(CipherSweet $engine = null) + public function testXAllEngines(CipherSweet $engine = null): void { $mr = $this->getMultiRows($engine); $rows = $this->getDummyPlaintext(); @@ -257,18 +264,18 @@ public function testXAllEngines(CipherSweet $engine = null) try { $mr->decryptManyRows($outRow2); $this->fail('AAD stripping was permitted'); - } catch (\Exception $ex) { + } catch (Exception $ex) { $this->assertInstanceOf(InvalidCiphertextException::class, $ex); } try { $mr2->decryptManyRows($outRow); $this->fail('AAD stripping was permitted'); - } catch (\Exception $ex) { + } catch (Exception $ex) { $this->assertInstanceOf(InvalidCiphertextException::class, $ex); } } - public function engineProvider() + public function engineProvider(): array { if (!isset($this->fipsEngine)) { $this->before(); @@ -336,6 +343,92 @@ public function testOptionalFields(CipherSweet $engine): void $null['foo']['testing'] = null; $encrypted = $eR->encryptManyRows($null); $this->assertNotSame($null, $encrypted); + } + /** + * @dataProvider engineProvider + */ + public function testAutoBind(CipherSweet $engine): void + { + $eR = (new EncryptedMultiRows($engine)) + ->setAutoBindContext(true) + ->setPrimaryKeyColumnName('users', 'user_id') + ->addTextField('users', 'first_name', 'tenant_id') + ->addTextField('users', 'last_name', 'tenant_id') + ->addOptionalIntegerField('users', 'salary') + ->addFloatField('payments', 'amount') + ->addOptionalJsonField('payments', 'metadata', (new JsonFieldMap())); + + $eR->createCompoundIndex( + 'users', + 'users_full_name', + ['first_name', 'last_name'], + 16 + ); + $eR->createFastCompoundIndex( + 'users', + 'users_full_name_salary', + ['first_name', 'last_name', 'salary'], + 16 + ); + + $encrypted = $eR->encryptManyRows(['users' => [ + 'user_id' => 12345, + 'tenant_id' => 'local_library_34120', + 'first_name' => 'Samuel', + 'last_name' => 'Clemens', + 'salary' => 55000 + ]]); + $table = $eR->getEncryptedRowObjectForTable('users'); + + $decrypted = $eR->decryptManyRows($encrypted); + $this->assertSame($decrypted['users']['first_name'], 'Samuel'); + $this->assertSame($decrypted['users']['salary'], 55000); + + // Verify we cannot swap them and still decrypt + try { + $badColumns = $encrypted; + [ + $badColumns['users']['last_name'], + $badColumns['users']['first_name'] + ] = [ + $encrypted['users']['first_name'], + $encrypted['users']['last_name'] + ]; + $eR->decryptManyRows($badColumns); + $this->fail('Confused deputy attack is possible'); + } catch (InvalidCiphertextException|SodiumException) { + } + + // Drop the primary key + try { + $eR->encryptManyRows(['users' => [ + // 'user_id' => 12345, + 'tenant_id' => 'local_library_34120', + 'first_name' => 'Samuel', + 'last_name' => 'Clemens', + 'salary' => 55000 + ]]); + $this->fail('Primary key binding requires primary key to be defined'); + } catch (CipherSweetException) { + } + + // Drop the AAD column: tenant_id + try { + $badColumns = $encrypted; + unset($badColumns['users']['tenant_id']); + $eR->decryptManyRows($badColumns); + $this->fail('AAD column is necessary to decrypt'); + } catch (CipherSweetException|SodiumException) { + } + + // Alter the AAD column: tenant_id + try { + $badColumns = $encrypted; + $badColumns['users']['tenant_id'] = 'wromg value lol'; + $eR->decryptManyRows($badColumns); + $this->fail('Incorrect AAD column accepted'); + } catch (CipherSweetException|SodiumException) { + } } } diff --git a/tests/EncryptedRowTest.php b/tests/EncryptedRowTest.php index ee6c6e8..8736a94 100644 --- a/tests/EncryptedRowTest.php +++ b/tests/EncryptedRowTest.php @@ -1,17 +1,26 @@ fipsEngine = $this->createFipsEngine('4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc'); $this->naclEngine = $this->createModernEngine('4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc'); @@ -71,23 +80,30 @@ public function before() $this->brngRandom = $this->createBoringEngine(); } - public function testConstructor() + public function testConstructor(): void { $encRow = new EncryptedRow($this->brngEngine, 'test', true); $this->assertTrue($encRow->getTypedIndexes(), 'Constructor argument not handled correctly'); + $this->assertFalse($encRow->getFlatIndexes(), 'Constructor argument not handled correctly'); + $this->assertInstanceOf(CipherSweet::class, $encRow->getEngine(), 'Impossible'); + $this->assertFalse($encRow->getPermitEmpty(), 'Regression: Default value of permitEmpty has changed'); } /** * @throws ArrayKeyException + * @throws CipherSweetException * @throws CryptoOperationException - * @throws \SodiumException + * @throws InvalidCiphertextException + * @throws SodiumException */ - public function testSimpleEncrypt() + public function testSimpleEncrypt(): void { $eF = (new EncryptedRow($this->fipsRandom, 'contacts')); $eM = (new EncryptedRow($this->naclRandom, 'contacts')); + $eB = (new EncryptedRow($this->brngRandom, 'contacts')); $eF->addTextField('message'); $eM->addTextField('message'); + $eB->addTextField('message'); $message = 'This is a test message: ' . \random_bytes(16); $row = [ @@ -96,6 +112,7 @@ public function testSimpleEncrypt() $fCipher = $eF->encryptRow($row); $mCipher = $eM->encryptRow($row); + $bCipher = $eB->encryptRow($row); $this->assertSame( FIPSCrypto::MAGIC_HEADER, @@ -105,7 +122,12 @@ public function testSimpleEncrypt() ModernCrypto::MAGIC_HEADER, Binary::safeSubstr($mCipher['message'], 0, 5) ); + $this->assertSame( + BoringCrypto::MAGIC_HEADER, + Binary::safeSubstr($bCipher['message'], 0, 5) + ); + $this->assertSame($row, $eB->decryptRow($bCipher)); $this->assertSame($row, $eF->decryptRow($fCipher)); $this->assertSame($row, $eM->decryptRow($mCipher)); } @@ -113,9 +135,9 @@ public function testSimpleEncrypt() /** * @throws ArrayKeyException * @throws CryptoOperationException - * @throws \SodiumException + * @throws SodiumException */ - public function testEncryptWithAAD() + public function testEncryptWithAAD(): void { $eFwithout = (new EncryptedRow($this->fipsRandom, 'contacts')); $eMwithout = (new EncryptedRow($this->naclRandom, 'contacts')); @@ -214,9 +236,9 @@ public function testEncryptWithAAD() * @throws ArrayKeyException * @throws BlindIndexNotFoundException * @throws CryptoOperationException - * @throws \SodiumException + * @throws SodiumException */ - public function testGetIndexFromPartialInfo() + public function testGetIndexFromPartialInfo(): void { $row = [ 'ssn' => '123-45-6789', @@ -251,9 +273,9 @@ public function testGetIndexFromPartialInfo() /** * @throws CryptoOperationException * @throws ArrayKeyException - * @throws \SodiumException + * @throws SodiumException */ - public function testGetAllIndexes() + public function testGetAllIndexes(): void { $row = [ 'extraneous' => 'this is unecnrypted', @@ -265,6 +287,10 @@ public function testGetAllIndexes() $this->assertEquals('a88e74ada916ab9b', $indexes['contact_ssn_last_four']); $this->assertEquals('9c3d53214ab71d7f', $indexes['contact_ssnlast4_hivstatus']); + // This doesn't count Compound Indexes, which are defined over rows not fields. + $objects = $eF->getBlindIndexObjectsForColumn('ssn'); + $this->assertCount(1, $objects); + $eF->setTypedIndexes(true); $indexes = $eF->getAllBlindIndexes($row); $this->assertEquals('idlzpypmia6qu', $indexes['contact_ssn_last_four']['type']); @@ -275,9 +301,9 @@ public function testGetAllIndexes() /** * @throws CryptoOperationException * @throws ArrayKeyException - * @throws \SodiumException + * @throws SodiumException */ - public function testGetAllIndexesFlat() + public function testGetAllIndexesFlat(): void { $row = [ 'extraneous' => 'this is unecnrypted', @@ -307,7 +333,7 @@ public function testGetAllIndexesFlat() * @throws CryptoOperationException * @throws SodiumException */ - public function testEncrypt() + public function testEncrypt(): void { $row = [ 'extraneous' => 'this is unecnrypted', @@ -337,7 +363,7 @@ public function testEncrypt() * @throws CipherSweetException * @throws SodiumException */ - public function testPrepareForStorage(CipherSweet $engine) + public function testPrepareForStorage(CipherSweet $engine): void { $typed = $this->getExampleRow($engine, true); $flat = $this->getExampleRow($engine, true); @@ -401,9 +427,9 @@ public function testPrepareForStorage(CipherSweet $engine) */ public function getExampleRow( CipherSweet $backend, - $longer = false, - $fast = false - ) { + bool $longer = false, + bool $fast = false + ): EncryptedRow { $row = (new EncryptedRow($backend, 'contacts')) ->addTextField('ssn') ->addBooleanField('hivstatus'); @@ -435,9 +461,9 @@ public function getExampleRow( * @throws ArrayKeyException * @throws CryptoOperationException * @throws BlindIndexNotFoundException - * @throws \SodiumException + * @throws SodiumException */ - public function testRowTransform(CipherSweet $engine) + public function testRowTransform(CipherSweet $engine): void { $row = (new EncryptedRow($engine, 'users')) ->addTextField('first_name') @@ -478,7 +504,7 @@ public function testRowTransform(CipherSweet $engine) ); } - public function engineProvider() + public function engineProvider(): array { if (!isset($this->fipsEngine)) { $this->before(); @@ -496,7 +522,7 @@ public function engineProvider() /** * @dataProvider engineProvider */ - public function testJsonField(CipherSweet $engine) + public function testJsonField(CipherSweet $engine): void { $eR = new EncryptedRow($engine, 'foo'); $eR->addJsonField('bar', new JsonFieldMap()); @@ -573,6 +599,48 @@ public function tesOptionalFields(CipherSweet $engine): void $this->assertNotSame($null, $encrypted); } + /** + * @dataProvider engineProvider + */ + public function testGuardrailAgainstUnrecoverableData(CipherSweet $engine): void + { + $row = (new EncryptedRow($engine, 'users')) + ->addTextField('first_name', new AAD(['last_name'])) + ->addTextField('last_name'); + + $this->expectException(InvalidAADException::class); + $row->encryptRow(['first_name' => 'Joe', 'last_name' => 'Pesci']); + } + + /** + * @dataProvider engineProvider + */ + public function testThrowsIfPrimaryKeyMisconfigured(CipherSweet $engine): void + { + $row = (new EncryptedRow($engine, 'users')) + ->addTextField('user_id') + ->setPrimaryKeyColumnName('user_id') + ->addTextField('first_name') + ->addTextField('last_name'); + + $this->expectException(CipherSweetException::class); + $row->encryptRow(['first_name' => 'Joe', 'last_name' => 'Pesci']); + } + + /** + * @dataProvider engineProvider + */ + public function testAadIsPopulated(CipherSweet $engine): void + { + $eR = new EncryptedRow($engine, 'inventory'); + $eR->setPrimaryKeyColumnName('item_id') + ->addOptionalTextField('foo') + ->addOptionalTextField('bar', AAD::field('item_id')) + ->addFloatField('baz') + ->addIntegerField('qux'); + $this->assertInstanceOf(AAD::class, $eR->getAADSource('bar')); + } + /** * @dataProvider engineProvider */