Skip to content

Commit

Permalink
Add generic encryption methods for use on WordPress.org. (#390)
Browse files Browse the repository at this point in the history
* Add initial Encryption methods.
* Clarify what '$additional_data' is.
* Tests: Add tests for the Encryption methods.
* Use #[\SensitiveParameter] within HiddenString.
* Encrypted values don't need to return HiddenString.
* Remove the non-auth encryption methods, and require $additional_data be specified.
* Rename $additional_data to $context for easier reading.
  • Loading branch information
dd32 authored Apr 20, 2023
1 parent 01251d5 commit d3fd967
Show file tree
Hide file tree
Showing 5 changed files with 604 additions and 0 deletions.
165 changes: 165 additions & 0 deletions mu-plugins/encryption/class-hiddenstring.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php
namespace WordPressdotorg\MU_Plugins\Encryption;
/**
* Class HiddenString. This is a copy of https://github.com/paragonie/hidden-string without the additional dependencies.
*
* The purpose of this class is to encapsulate strings and hide their contents
* from stack traces should an unhandled exception occur.
*
* The only things that should be protected:
* - Passwords
* - Plaintext (before encryption)
* - Plaintext (after decryption)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
final class HiddenString
{
/**
* @var string
*/
protected $internalStringValue = '';

/**
* Disallow the contents from being accessed via __toString()?
*
* @var bool
*/
protected $disallowInline = false;

/**
* Disallow the contents from being accessed via __sleep()?
*
* @var bool
*/
protected $disallowSerialization = false;

/**
* HiddenString constructor.
* @param string $value
* @param bool $disallowInline
* @param bool $disallowSerialization
*
* @throws \TypeError
*/
public function __construct(
#[\SensitiveParameter]
string $value,
bool $disallowInline = true,
bool $disallowSerialization = true
) {
$this->internalStringValue = self::safeStrcpy($value);
$this->disallowInline = $disallowInline;
$this->disallowSerialization = $disallowSerialization;
}

/**
* @param HiddenString $other
* @return bool
* @throws \TypeError
*/
public function equals(HiddenString $other)
{
return \hash_equals(
$this->getString(),
$other->getString()
);
}

/**
* Hide its internal state from var_dump()
*
* @return array
*/
public function __debugInfo()
{
return [
'internalStringValue' =>
'*',
'attention' =>
'If you need the value of a HiddenString, ' .
'invoke getString() instead of dumping it.'
];
}

/**
* Wipe it from memory after it's been used.
* @return void
*/
public function __destruct()
{
if (\is_callable('\sodium_memzero')) {
try {
\sodium_memzero($this->internalStringValue);
return;
} catch (\Throwable $ex) {
}
}
}

/**
* Explicit invocation -- get the raw string value
*
* @return string
* @throws \TypeError
*/
public function getString(): string
{
return self::safeStrcpy($this->internalStringValue);
}

/**
* Returns a copy of the string's internal value, which should be zeroed.
* Optionally, it can return an empty string.
*
* @return string
* @throws \TypeError
*/
public function __toString(): string
{
if (!$this->disallowInline) {
return self::safeStrcpy($this->internalStringValue);
}
return '';
}

/**
* @return array
*/
public function __sleep(): array
{
if (!$this->disallowSerialization) {
return [
'internalStringValue',
'disallowInline',
'disallowSerialization'
];
}
return [];
}

/**
* PHP 7 uses interned strings. We don't want altering this one to alter
* the original string.
*
* @param string $string
* @return string
* @throws \TypeError
*/
public static function safeStrcpy(string $string): string
{
$length = mb_strlen($string, '8bit');
$return = '';
/** @var int $chunk */
$chunk = $length >> 1;
if ($chunk < 1) {
$chunk = 1;
}
for ($i = 0; $i < $length; $i += $chunk) {
$return .= mb_substr($string, $i, $chunk, '8bit');
}
return $return;
}
}
60 changes: 60 additions & 0 deletions mu-plugins/encryption/exports.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
use WordPressdotorg\MU_Plugins\Encryption\HiddenString;
/**
* This file contains globally-exported function names for the Encryption plugin.
*
* It provides a wrapper around the libsodium's Authenticated Encryption with
* Additional Data ciphers (AEAD with XChaCha20-Poly1305)
*
* NOTE: $context should always be passed, and should either be set to the stringy User ID, or a unique-per-item string.
* The context is not stored within the Encrypted data, but is used to validate that the value is being decrypted in the same context.
*/

/**
* Encrypt a value, with authentication.
*
* Unlike the Encryption plugin, this function simply returns false for any errors.
*
* @param string $value The plaintext value.
* @param string $context Additional, authenticated data. This is used in the verification of the authentication tag appended to the ciphertext, but it is not encrypted or stored in the ciphertext.
* @param string $key_name The name of the key to use for encryption. Optional.
* @return string|false The encrypted value, or false on error.
*/
function wporg_encrypt( $value, string $context, string $key_name = '' ) {
try {
return \WordPressdotorg\MU_Plugins\Encryption\encrypt( $value, $context, $key_name );
} catch ( Exception $e ) {
return false;
}
}

/**
* Decrypt a value, with authentication.
*
* Unlike the Encryption plugin, this function simply returns false for any errors, and
* HiddenStrings that can be cast to string as needed.
*
* @param string $value The encrypted value.
* @param string $context Additional, authenticated data. This is used in the verification of the authentication tag appended to the ciphertext, but it is not encrypted or stored in the ciphertext.
* @param string $key_name The name of the key to use for decryption. Optional.
* @return HiddenString|false The decrypted value stored within a HiddenString instance, or false on error.
*/
function wporg_decrypt( string $value, string $context, string $key_name = '' ) {
try {
$value = \WordPressdotorg\MU_Plugins\Encryption\decrypt( $value, $context, $key_name );

return new HiddenString( $value->getString(), false );
} catch ( Exception $e ) {
return false;
}
}

/**
* Determine if a value is encrypted.
*
* @param HiddenString|string $value The value to check.
* @return bool True if the value is encrypted, false otherwise.
*/
function wporg_is_encrypted( string $value ) : bool {
return \WordPressdotorg\MU_Plugins\Encryption\is_encrypted( $value );
}
155 changes: 155 additions & 0 deletions mu-plugins/encryption/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php
namespace WordPressdotorg\MU_Plugins\Encryption;
use Exception;
/**
* Plugin Name: WordPress.org Encryption
* Description: Encryption functions for use on WordPress.org.
*/
require __DIR__ . '/exports.php';

/**
* Prefix for encrypted secrets. Contains a version identifier.
*
* $t1$ -> v1 (RFC 6238, encrypted with XChaCha20-Poly1305, with a key derived from HMAC-SHA256
* of the defined key.
*
* @var string
*/
const PREFIX = '$t1$';

/**
* The length of the keys.
*
* @var int
*/
const KEY_LENGTH = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES;

/**
* The length of the per-encrypted-item nonce.
*
* @var int
*/
const NONCE_LENGTH = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES;

/**
* Encrypt a value.
*
* @param string $value Value to encrypt.
* @param string $context Additional, authenticated data. This is used in the verification of the authentication tag appended to the ciphertext, but it is not encrypted or stored in the ciphertext.
* @param string $key_name The name of the key to use for encryption. Optional.
* @return string Encrypted value, exceptions thrown on error.
*/
function encrypt( $value, string $context, string $key_name = '' ) {
$nonce = random_bytes( NONCE_LENGTH );
if ( ! $nonce ) {
throw new Exception( 'Unable to create a nonce.' );
}

if ( empty( $context ) ) {
throw new Exception( '$context cannot be empty.' );
}

if ( $value instanceOf HiddenString ) {
$value = $value->getString();
}

$key = get_encryption_key( $key_name );
$encrypted = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $value, $context, $nonce, $key->getString() );

sodium_memzero( $value );

return PREFIX . sodium_bin2hex( $nonce . $encrypted );
}

/**
* Decrypt a value.
*
* @param string $value Value to decrypt.
* @param string $context Additional, authenticated data. This is used in the verification of the authentication tag appended to the ciphertext, but it is not encrypted or stored in the ciphertext.
* @param string $key_name The name of the key to use for decryption. Optional.
* @return HiddenString Decrypted value.
*/
function decrypt( string $value, string $context, string $key_name = '' ) : HiddenString {
if ( ! is_encrypted( $value ) ) {
throw new Exception( 'Value is not encrypted.' );
}

// Remove the prefix, and convert back to binary.
$value = mb_substr( $value, mb_strlen( PREFIX, '8bit' ), null, '8bit' );
$value = sodium_hex2bin( $value );

if ( mb_strlen( $value, '8bit' ) < NONCE_LENGTH ) {
throw new Exception( 'Invalid cipher text.' );
}

$key = get_encryption_key( $key_name );
$nonce = mb_substr( $value, 0, NONCE_LENGTH, '8bit' );
$value = mb_substr( $value, NONCE_LENGTH, null, '8bit' );
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( $value, $context, $nonce, $key->getString() );

sodium_memzero( $nonce );
sodium_memzero( $value );

if ( false === $plaintext ) {
throw new Exception( 'Invalid cipher text.' );
}

return new HiddenString( $plaintext );
}

/**
* Check if a value is encrypted.
*
* @param HiddenString|string $value Value to check.
* @return bool True if the value is encrypted, false otherwise.
*/
function is_encrypted( $value ) {
if ( $value instanceOf HiddenString ) {
$value = $value->getString();
}

if ( ! str_starts_with( $value, PREFIX ) ) {
return false;
}

if ( mb_strlen( $value, '8bit' ) < NONCE_LENGTH + mb_strlen( PREFIX, '8bit' ) ) {
return false;
}

sodium_memzero( $value );

return true;
}

/**
* Get the encryption key.
*
* @param string $key_name The name of the key to use for decryption.
* @return HiddenString The encryption key.
*/
function get_encryption_key( string $key_name = '' ) {

$keys = [];
if ( function_exists( 'wporg_encryption_keys' ) ) {
$keys = wporg_encryption_keys();
}

if ( ! $key_name ) {
$key_name = 'default';
}

if ( ! isset( $keys[ $key_name ] ) ) {
throw new Exception( sprintf( 'Encryption key "%s" not defined.', $key_name ) );
}

return $keys[ $key_name ];
}

/**
* Generate a random encryption key.
*
* @return HiddenString The encryption key.
*/
function generate_encryption_key() {
return new HiddenString( random_bytes( KEY_LENGTH ) );
}
1 change: 1 addition & 0 deletions mu-plugins/loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
require_once __DIR__ . '/rest-api/index.php';
require_once __DIR__ . '/skip-to/skip-to.php';
require_once __DIR__ . '/db-user-sessions/index.php';
require_once __DIR__ . '/encryption/index.php';
Loading

0 comments on commit d3fd967

Please sign in to comment.