key ) { if ( defined( 'RT_WA_ENCRYPTION_KEY' ) && RT_WA_ENCRYPTION_KEY !== '' ) { $this->key = hash( 'sha256', 'rt-wa-analytics:' . RT_WA_ENCRYPTION_KEY, true ); } else { $this->key = hash_hmac( 'sha256', 'rt-wa-analytics:enc:v1', (string) wp_salt( 'auth' ), true ); } } return $this->key; } /** * Optional previous key, only used for decryption during rotation. * * @return string|false */ public function previous_key() { if ( null === $this->previous ) { if ( defined( 'RT_WA_ENCRYPTION_KEY_PREVIOUS' ) && RT_WA_ENCRYPTION_KEY_PREVIOUS !== '' ) { $this->previous = hash( 'sha256', 'rt-wa-analytics:' . RT_WA_ENCRYPTION_KEY_PREVIOUS, true ); } else { $this->previous = false; } } return $this->previous; } /** * Encrypt a plaintext value. Empty input stays empty. Returns false on * failure so callers can fail closed instead of storing plaintext. * * @param string $plaintext * @return string|false */ public function encrypt( $plaintext ) { $plaintext = (string) $plaintext; if ( $plaintext === '' ) { return ''; } if ( ! $this->is_available() ) { return false; } $iv = random_bytes( self::IV_LENGTH ); $tag = ''; $ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $this->key(), OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LENGTH ); if ( $ciphertext === false ) { return false; } return self::PREFIX . base64_encode( $iv . $ciphertext . $tag ); } /** * Decrypt a value produced by encrypt(). Non-encrypted or empty values * pass through untouched. Corrupted ciphertext or a failed * authentication check returns an empty string (never an exception and * never a plaintext leak). * * @param string|mixed $value * @return string */ public function decrypt( $value ) { $value = (string) $value; if ( $value === '' || ! $this->is_encrypted( $value ) ) { return $value; } $payload = base64_decode( substr( $value, strlen( self::PREFIX ) ), true ); if ( $payload === false || strlen( $payload ) <= self::IV_LENGTH + self::TAG_LENGTH ) { return ''; } $iv = substr( $payload, 0, self::IV_LENGTH ); $tag = substr( $payload, -self::TAG_LENGTH ); $ciphertext = substr( $payload, self::IV_LENGTH, -self::TAG_LENGTH ); $plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $this->key(), OPENSSL_RAW_DATA, $iv, $tag ); if ( $plaintext === false ) { $previous = $this->previous_key(); if ( $previous ) { $plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $previous, OPENSSL_RAW_DATA, $iv, $tag ); } } return $plaintext === false ? '' : $plaintext; } /** * Whether a value carries the encrypted envelope. * * @param mixed $value * @return bool */ public function is_encrypted( $value ) { return is_string( $value ) && strpos( $value, self::PREFIX ) === 0; } /** * Deterministic keyed token for exact-match lookup (e.g. "find leads by * phone number"). Uses an HMAC-SHA256 keyed with a key derived from the * master key. The token alone cannot be reversed into the phone number, * and the same phone number always produces the same token so SQL can do * an exact index lookup instead of a scan or decrypt-all-and-filter. * * Trade-off (documented): because the token is deterministic, an attacker * with read access to the database AND the key material could brute force * the (small) phone-number space offline. The token therefore never leaves * the server and is stripped from all API responses. * * @param string $value * @return string Hex token (64 chars) or '' for empty values. */ public function lookup_token( $value ) { $value = $this->normalize_phone( $value ); if ( $value === '' ) { return ''; } if ( null === $this->lookup_key ) { $this->lookup_key = hash_hmac( 'sha256', 'rt-wa-analytics:lookup:v1', $this->key(), true ); } return hash_hmac( 'sha256', $value, $this->lookup_key ); } /** * Normalize a phone number for lookup-token generation: strip separators, * keep digits and an optional leading '+'. * * @param string $value * @return string */ public function normalize_phone( $value ) { $value = (string) $value; $value = preg_replace( '/[\s\-().\/]/', '', trim( $value ) ); if ( ! is_string( $value ) ) { return ''; } return preg_replace( '/[^\d+]/', '', $value ) ?: ''; } /** * Mask a plaintext phone number for display: keeps the country code and * the last four digits, e.g. "+20 ******1234". Operates on a value that * has already been decrypted server-side. * * @param string $value * @return string */ public function mask_phone( $value ) { $value = (string) $value; if ( $value === '' ) { return ''; } $digits = preg_replace( '/\D/', '', $value ); if ( $digits === '' ) { return '********'; } $last4 = substr( $digits, -4 ); if ( isset( $value[0] ) && $value[0] === '+' && preg_match( '/^\+(\d{1,3})/', $value, $m ) ) { return '+' . $m[1] . ' ******' . $last4; } return '******' . $last4; } /** * Whether the current user may view full (unmasked) contact data. * The dedicated capability is granted to administrators at bootstrap. * * @return bool */ public function can_manage_leads() { return current_user_can( 'zaaed_manage_leads' ); } }