* * The site secret is read only through lane A's Options accessor and is never * returned, printed, logged or echoed anywhere in this class. * * @package AdvisorPPC */ class AdvisorPPC_Signer { /** * Header names. Kept as constants so lane D and the tests agree on spelling. */ const HEADER_SITE_KEY = 'X-AdvisorPPC-Site-Key'; const HEADER_TIMESTAMP = 'X-AdvisorPPC-Timestamp'; const HEADER_SIGNATURE = 'X-AdvisorPPC-Signature'; /** * Algorithm prefix sent in the signature header value. */ const ALGO_PREFIX = 'sha256='; /** * Whether a site key and a site secret both exist. * * Nothing may be transmitted before this returns true. * * @return bool */ public static function is_configured() { return '' !== self::site_key() && '' !== self::site_secret(); } /** * Public site key. Safe to print. * * @return string */ public static function site_key() { if ( ! class_exists( 'AdvisorPPC_Options' ) ) { return ''; } $key = AdvisorPPC_Options::get( 'site_key', '' ); return is_string( $key ) ? trim( $key ) : ''; } /** * Private site secret. * * PRIVATE. Never render, log or return this outside of a signature. * Lane A stores it in the separate non-autoloaded option `advisorppc_secret`; * we ask the Options accessor first and fall back to that row directly so we * work whichever way lane A wires the accessor. * * @return string */ private static function site_secret() { $secret = ''; if ( class_exists( 'AdvisorPPC_Options' ) ) { $secret = AdvisorPPC_Options::get( 'site_secret', '' ); } if ( ! is_string( $secret ) || '' === trim( $secret ) ) { $secret = get_option( 'advisorppc_secret', '' ); } return is_string( $secret ) ? trim( $secret ) : ''; } /** * Compute the signature header value for a raw request body. * * The body MUST be the exact byte string that is transmitted. Re-encoding * the payload after signing invalidates the signature. * * @param string $raw_body Raw JSON request body. * @param int $timestamp Unix seconds, the same value sent in the timestamp header. * @param string $secret Optional explicit secret, for tests. * @return string 'sha256=' or '' when no secret is available. */ public static function signature( $raw_body, $timestamp, $secret = null ) { if ( null === $secret ) { $secret = self::site_secret(); } if ( ! is_string( $secret ) || '' === $secret ) { return ''; } $payload = (string) (int) $timestamp . '.' . (string) $raw_body; return self::ALGO_PREFIX . hash_hmac( 'sha256', $payload, $secret ); } /** * Build the full header set for a signed request. * * @param string $raw_body Raw JSON request body. * @param int|null $timestamp Optional unix seconds. Defaults to now. * @return array Header map, or an empty array when unconfigured. */ public static function headers( $raw_body, $timestamp = null ) { if ( ! self::is_configured() ) { return array(); } $timestamp = ( null === $timestamp ) ? time() : (int) $timestamp; $signature = self::signature( $raw_body, $timestamp ); if ( '' === $signature ) { return array(); } return array( 'Content-Type' => 'application/json', self::HEADER_SITE_KEY => self::site_key(), self::HEADER_TIMESTAMP => (string) $timestamp, self::HEADER_SIGNATURE => $signature, ); } /** * Constant-time verification helper. * * Not used by the plugin's outbound path. Provided so an inbound callback * (for example a future AdvisorPPC -> WordPress webhook) never hand-rolls a * `===` comparison on a signature. * * @param string $expected Signature we computed. * @param string $given Signature we received. * @return bool */ public static function equals( $expected, $given ) { if ( ! is_string( $expected ) || ! is_string( $given ) ) { return false; } if ( function_exists( 'hash_equals' ) ) { return hash_equals( $expected, $given ); } // PHP 5.6+ always has hash_equals; this branch is defensive only. if ( strlen( $expected ) !== strlen( $given ) ) { return false; } $diff = 0; $len = strlen( $expected ); for ( $i = 0; $i < $len; $i++ ) { $diff |= ord( $expected[ $i ] ) ^ ord( $given[ $i ] ); } return 0 === $diff; } }