Skip to content

Commit

Permalink
Enhanced AAD Support
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
paragonie-security committed May 11, 2024
1 parent d3cfc61 commit 0566fec
Show file tree
Hide file tree
Showing 19 changed files with 826 additions and 185 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/.gitignore
/.idea
/composer.lock
/vendor
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"encrypt",
"encryption",
"field-level encryption",
"FIPS 140-2",
"NIST cryptography",
"FIPS 140-3",
"libsodium",
"queryable encryption",
"searchable encryption",
Expand Down
185 changes: 185 additions & 0 deletions src/AAD.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace ParagonIE\CipherSweet;

use ParagonIE\ConstantTime\Binary;

/**
* Defines an interface for combining multiple plaintext fields into the AAD
* for a given field.
*/
class AAD
{
public function __construct(
protected array $fieldNames = [],
protected array $literals = [],
protected bool $legacy = false
) {}

public function addFieldName(string $fieldName): self
{
if (!in_array($fieldName, $this->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);
}
}
23 changes: 21 additions & 2 deletions src/Backend/BoringCrypto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\{
Expand Down Expand Up @@ -302,6 +303,7 @@ public function deriveKeyFromPassword(
* @param resource $outputFP
* @param SymmetricKey $key
* @param int $chunkSize
* @param ?AAD $aad
* @return bool
*
* @throws CryptoOperationException
Expand All @@ -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);
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -401,6 +411,7 @@ public function doStreamDecrypt(
* @param SymmetricKey $key
* @param int $chunkSize
* @param string $salt
* @param ?AAD $aad
* @return bool
*
* @throws CryptoOperationException
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 25 additions & 4 deletions src/Backend/FIPSCrypto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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\{
Expand All @@ -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
Expand Down Expand Up @@ -315,6 +319,7 @@ public function deriveKeyFromPassword(
* @param resource $outputFP
* @param SymmetricKey $key
* @param int $chunkSize
* @param ?AAD $aad
* @return bool
*
* @throws CryptoOperationException
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -404,6 +416,7 @@ public function doStreamDecrypt(
* @param SymmetricKey $key
* @param int $chunkSize
* @param string $salt
* @param ?AAD $aad
* @return bool
*
* @throws CryptoOperationException
Expand All @@ -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);
Expand All @@ -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 {
Expand Down
Loading

0 comments on commit 0566fec

Please sign in to comment.