key = $this->derive_key( $key_material ?? $this->resolve_key_material() ); } /** * Encrypt a plaintext secret. Empty strings are returned unchanged. * * @param string $plaintext Plaintext value. * @return string Ciphertext with version prefix, or empty string. * * @throws RuntimeException When OpenSSL fails. * * @since 0.1.0 */ public function encrypt( string $plaintext ): string { if ( '' === $plaintext ) { return ''; } $iv_length = openssl_cipher_iv_length( self::CIPHER ); if ( false === $iv_length || $iv_length < 1 ) { throw new RuntimeException( 'Unable to determine cipher IV length.' ); } $iv = random_bytes( $iv_length ); $tag = ''; $cipher = openssl_encrypt( $plaintext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 ); if ( false === $cipher ) { throw new RuntimeException( 'Encryption failed.' ); } return self::PREFIX . bin2hex( $iv . $tag . $cipher ); } /** * Decrypt a ciphertext produced by encrypt(). Plain values pass through. * * @param string $ciphertext Possibly encrypted value. * @return string Plaintext. * * @throws RuntimeException When decryption fails. * * @since 0.1.0 */ public function decrypt( string $ciphertext ): string { if ( '' === $ciphertext || ! str_starts_with( $ciphertext, self::PREFIX ) ) { return $ciphertext; } $raw = hex2bin( substr( $ciphertext, strlen( self::PREFIX ) ) ); if ( false === $raw ) { throw new RuntimeException( 'Encrypted payload is not valid hex.' ); } $iv_length = openssl_cipher_iv_length( self::CIPHER ); if ( false === $iv_length || $iv_length < 1 ) { throw new RuntimeException( 'Unable to determine cipher IV length.' ); } $tag_length = 16; $min_length = $iv_length + $tag_length + 1; if ( strlen( $raw ) < $min_length ) { throw new RuntimeException( 'Encrypted payload is truncated.' ); } $iv = substr( $raw, 0, $iv_length ); $tag = substr( $raw, $iv_length, $tag_length ); $encrypted = substr( $raw, $iv_length + $tag_length ); $plaintext = openssl_decrypt( $encrypted, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag ); if ( false === $plaintext ) { throw new RuntimeException( 'Decryption failed.' ); } return $plaintext; } /** * Whether a stored value looks like Bookline ciphertext. * * @param string $value Stored value. * * @since 0.1.0 */ public function is_encrypted( string $value ): bool { return str_starts_with( $value, self::PREFIX ); } /** * Resolve key material from the override constant or WordPress salts. * * @throws RuntimeException When no key material is available. */ private function resolve_key_material(): string { if ( defined( 'BOOKLINE_ENCRYPTION_KEY' ) ) { $override = constant( 'BOOKLINE_ENCRYPTION_KEY' ); if ( is_string( $override ) && '' !== $override ) { return $override; } } $parts = []; foreach ( [ 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT' ] as $constant ) { if ( defined( $constant ) ) { $value = constant( $constant ); if ( is_string( $value ) && '' !== $value ) { $parts[] = $value; } } } if ( [] === $parts ) { throw new RuntimeException( 'No encryption key available. Define BOOKLINE_ENCRYPTION_KEY or WordPress salts.' ); } return implode( '|', $parts ); } /** * Derive a binary key of fixed length. * * @param string $material Key material. */ private function derive_key( string $material ): string { return hash( 'sha256', $material, true ); } }