Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Encryption methods. #390

Merged
merged 19 commits into from
Apr 20, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
482235d
Add initial Encryption methods.
dd32 Apr 12, 2023
65cd2e6
Clarify what '$additional_data' is.
dd32 Apr 13, 2023
e887fb8
Add wporg_authenticated_encrypt() and wporg_authenticated_decrypt() w…
dd32 Apr 13, 2023
dc879db
Remove the $key parameter from is_encrypted(). It's not an ideal scen…
dd32 Apr 13, 2023
542fe9f
Always use mb_strlen() for consistentness.
dd32 Apr 13, 2023
636e792
Tests: Add tests for the Encryption methods.
dd32 Apr 13, 2023
0b6a979
Whitespace and zero'ing of strings.
dd32 Apr 14, 2023
52133b1
Standardise on Exceptions with a trailing full-stop.
dd32 Apr 14, 2023
e50fd63
get_encryption_key() throws an exception, there's no need to check it…
dd32 Apr 14, 2023
0651e3b
Tests: Don't use `expectException()` and instead test the exceptions …
dd32 Apr 14, 2023
6c9d58a
Use #[\SensitiveParameter] within HiddenString.
dd32 Apr 18, 2023
3d5b2f6
Switch from storing keys in constants, and instead within the return …
dd32 Apr 18, 2023
3caed56
Encrypted values don't need to return HiddenString.
dd32 Apr 18, 2023
6f8d488
Remove the non-auth encryption methods, and require $additional_data …
dd32 Apr 18, 2023
a484032
Rename $additional_data to $context for easier reading.
dd32 Apr 18, 2023
6468c2d
Rename the $key parameters to $key_name to better define that it's th…
dd32 Apr 18, 2023
c12a107
encrypt() no longer returns HiddenString, so when decrypting we don't…
dd32 Apr 18, 2023
1face28
Document the key methods as returning a HiddenString.
dd32 Apr 18, 2023
c535603
Correct incorrect types in docblock.
dd32 Apr 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
dd32 marked this conversation as resolved.
Show resolved Hide resolved
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 string|false The decrypted value, or false on error.
dd32 marked this conversation as resolved.
Show resolved Hide resolved
*/
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 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 string $value Value to check.
dd32 marked this conversation as resolved.
Show resolved Hide resolved
* @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