diff --git a/mu-plugins/encryption/class-hiddenstring.php b/mu-plugins/encryption/class-hiddenstring.php new file mode 100644 index 000000000..a28241a99 --- /dev/null +++ b/mu-plugins/encryption/class-hiddenstring.php @@ -0,0 +1,165 @@ +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; + } +} \ No newline at end of file diff --git a/mu-plugins/encryption/exports.php b/mu-plugins/encryption/exports.php new file mode 100644 index 000000000..3ac8229de --- /dev/null +++ b/mu-plugins/encryption/exports.php @@ -0,0 +1,60 @@ +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 ); +} \ No newline at end of file diff --git a/mu-plugins/encryption/index.php b/mu-plugins/encryption/index.php new file mode 100644 index 000000000..d22e20bdd --- /dev/null +++ b/mu-plugins/encryption/index.php @@ -0,0 +1,155 @@ + 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 ) ); +} diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php index e9ad6ee4c..c92cb8d0c 100644 --- a/mu-plugins/loader.php +++ b/mu-plugins/loader.php @@ -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'; diff --git a/phpunit/test-encryption.php b/phpunit/test-encryption.php new file mode 100644 index 000000000..1c9131e5f --- /dev/null +++ b/phpunit/test-encryption.php @@ -0,0 +1,223 @@ + generate_encryption_key(), + 'secondary' => generate_encryption_key(), + ]; + } + + return $keys; + } + } + + public function test_encrypt_decrypt() { + $input = 'This is a plaintext string. It contains no sensitive data.'; + $context = 'USER1'; + $encrypted = encrypt( $input, $context ); + + $this->assertNotEquals( $input, $encrypted ); + $this->assertStringNotContainsString( $context, $encrypted ); + + // Decrypt without $context. + try { + decrypt( $encrypted, '' ); + } catch( Exception $e ) { + $this->assertEquals( 'Invalid cipher text.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Decrypt with incorrect $context. + try { + decrypt( $encrypted, 'USER2' ); + } catch( Exception $e ) { + $this->assertEquals( 'Invalid cipher text.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Decrypt with incorrect key specified. + try { + decrypt( $encrypted, $context, 'secondary' ); + } catch( Exception $e ) { + $this->assertEquals( 'Invalid cipher text.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Decrypt with unknown key specified. + try { + decrypt( $encrypted, $context, 'unknown-key' ); + } catch( Exception $e ) { + $this->assertEquals( 'Encryption key "unknown-key" not defined.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + $decrypted = decrypt( $encrypted, $context ); + + $this->assertTrue( $decrypted instanceOf HiddenString ); + + $this->assertNotEquals( $input, $decrypted ); + $this->assertEquals( $input, $decrypted->getString() ); + } + + public function test_is_encrypted() { + $this->assertFalse( is_encrypted( 'TEST STRING' ) ); + $this->assertFalse( is_encrypted( PREFIX ) ); + $this->assertFalse( is_encrypted( PREFIX . 'TEST STRING' ) ); + + $string_prefix_length = str_repeat( '.', mb_strlen( PREFIX, '8bit' ) ); + $string_nonce_length = str_repeat( '.', NONCE_LENGTH ); + + $this->assertFalse( is_encrypted( $string_prefix_length . $string_nonce_length ) ); + $this->assertFalse( is_encrypted( $string_prefix_length . $string_nonce_length . 'TEST STRING' ) ); + + $this->assertTrue( is_encrypted( PREFIX . $string_nonce_length ) ); + $this->assertTrue( is_encrypted( PREFIX . $string_nonce_length . 'TEST STRING' ) ); + + $test_string = 'This is a plaintext string. It contains no sensitive data.'; + $this->assertTrue( is_encrypted( encrypt( $test_string, 'context' ) ) ); + } + + public function test_generate_key_different() { + $one_key = generate_encryption_key(); + + $length = mb_strlen( $one_key->getString(), '8bit' ); + $this->assertEquals( KEY_LENGTH, $length ); + + $two_key = generate_encryption_key(); + $this->assertNotEquals( $one_key->getString(), $two_key->getString() ); + } + + public function test_get_encryption_key() { + $this->assertSame( wporg_encryption_keys()['default']->getString(), get_encryption_key()->getString() ); + $this->assertSame( wporg_encryption_keys()['default']->getString(), get_encryption_key( '' )->getString() ); + $this->assertSame( wporg_encryption_keys()['default']->getString(), get_encryption_key( false )->getString() ); + + $this->assertSame( wporg_encryption_keys()['secondary']->getString(), get_encryption_key( 'secondary' )->getString() ); + + // Get an unknown key. + try { + get_encryption_key( 'unknown-key' ); + } catch( Exception $e ) { + $this->assertEquals( 'Encryption key "unknown-key" not defined.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + } + + public function test_can_encrypt_hiddenstring() { + $hidden_string = new HiddenString( "TEST STRING" ); + $context = 'test-context'; + + $encrypted = encrypt( $hidden_string, $context ); + + $this->assertTrue( is_encrypted( $encrypted ) ); + + $this->assertSame( $hidden_string->getString(), decrypt( $encrypted, $context )->getString() ); + } + + public function test_encrypt_decrypt_invalid_inputs() { + $context = 'test-context'; + + // Invalid key specified. + try { + encrypt( 'TEST STRING', $context, 'unknown-key' ); + } catch( Exception $e ) { + $this->assertEquals( 'Encryption key "unknown-key" not defined.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Not-encrypted Invalid data that. + try { + decrypt( PREFIX . 'TESTSTRINGTESTSTRINGTESTSTRINGTESTSTRINGTESTSTRINGTESTSTRING', $context ); + } catch( Exception $e ) { + // This is thrown by sodium_hex2bin(). + $this->assertEquals( 'invalid hex string', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Not-encrypted Possibly-valid data. + try { + decrypt( PREFIX . '012345678901234567890123456789012345678901234567890123456789', $context ); + } catch( Exception $e ) { + $this->assertEquals( 'Invalid cipher text.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Invalid key specified, not-encrypted data that's not long enough. + try { + decrypt( 'TEST STRING', $context, 'unknown-key' ); + } catch( Exception $e ) { + $this->assertEquals( 'Value is not encrypted.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + // Not-encrypted data that's not long enough. + try { + decrypt( 'TEST STRING', $context ); + } catch( Exception $e ) { + $this->assertEquals( 'Value is not encrypted.', $e->getMessage() ); + } finally { + $this->assertNotEmpty( $e, 'No Exception thrown?' ); + unset( $e ); + } + + } + + public function test_exported_functions() { + // This only tests the behavioural functions, not the encryption/decryption. + + $input = 'This is a plaintext string. It contains no sensitive data.'; + $context = 'test-context'; + + $encrypted = wporg_encrypt( $input, $context ); + + $this->assertNotEquals( $input, $encrypted ); + + $decrypted = wporg_decrypt( $encrypted, $context ); + + $this->assertTrue( $decrypted instanceOf HiddenString ); + + $this->assertNotSame( $input, $decrypted ); + $this->assertEquals( $input, $decrypted->getString() ); + $this->assertEquals( $input, (string) $decrypted ); + + $this->assertFalse( wporg_encrypt( '', $context, 'unknown-key' ) ); + $this->assertFalse( wporg_decrypt( '', $context, 'unknown-key' ) ); + $this->assertFalse( wporg_decrypt( 'TEST STRING', $context ) ); + } + +}