..`. * * The tag binds the first two parts to this site's salts, so a nonce * cannot be invented elsewhere and the server does not have to remember * which ones it gave out. */ public static function issue() { $ts = (int) round( microtime( true ) * 1000 ); $rand = bin2hex( random_bytes( 8 ) ); $body = $ts . '.' . $rand; return $body . '.' . substr( hash_hmac( 'sha256', $body, self::salt() ), 0, 32 ); } /** * Check a nonce's shape, tag and age. * * @return int|WP_Error Issue time in milliseconds. */ public static function inspect( $nonce ) { $parts = explode( '.', (string) $nonce ); if ( 3 !== count( $parts ) || ! ctype_digit( $parts[0] ) || ! ctype_xdigit( $parts[1] ) ) { return new WP_Error( 'sirius_bad_nonce', __( 'That sign-in request is malformed. Reload the page and try again.', 'sirius-press' ) ); } $expected = substr( hash_hmac( 'sha256', $parts[0] . '.' . $parts[1], self::salt() ), 0, 32 ); if ( ! hash_equals( $expected, $parts[2] ) ) { return new WP_Error( 'sirius_bad_nonce', __( 'That sign-in request did not come from this site. Reload the page and try again.', 'sirius-press' ) ); } $age = ( round( microtime( true ) * 1000 ) - (int) $parts[0] ) / 1000; if ( $age > self::TTL || $age < -60 ) { return new WP_Error( 'sirius_expired', __( 'That sign-in request has expired. Reload the page and try again.', 'sirius-press' ) ); } return (int) $parts[0]; } /** * The exact text to be signed. * * Written to be readable in a wallet's approval dialog, because that is * the only place a user gets to check what they are agreeing to. Anything * in here that a wallet renders as a wall of hex is a security control the * user cannot exercise. * * @param string $nonce * @param string $purpose One of the PURPOSE_* constants. * @return string */ public static function message( $nonce, $purpose = self::PURPOSE_LOGIN ) { $issued = self::inspect( $nonce ); $when = is_wp_error( $issued ) ? 0 : (int) floor( $issued / 1000 ); $lines = array( self::headline( $purpose ), '', 'Site: ' . home_url( '/' ), 'Purpose: ' . self::purpose_label( $purpose ), 'Nonce: ' . $nonce, 'Issued: ' . gmdate( 'Y-m-d\TH:i:s\Z', $when ), '', 'Signing this proves you control this wallet. It moves no coins.', ); return implode( "\n", $lines ); } private static function headline( $purpose ) { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); switch ( $purpose ) { case self::PURPOSE_REGISTER: return sprintf( 'Create an account on %s', $site ); case self::PURPOSE_LINK: return sprintf( 'Attach this wallet to your account on %s', $site ); case self::PURPOSE_CONFIRM: return sprintf( 'Confirm an action on %s', $site ); default: return sprintf( 'Sign in to %s', $site ); } } private static function purpose_label( $purpose ) { $known = array( self::PURPOSE_LOGIN => 'sign in', self::PURPOSE_REGISTER => 'create account', self::PURPOSE_LINK => 'attach wallet', self::PURPOSE_CONFIRM => 'confirm action', ); return isset( $known[ $purpose ] ) ? $known[ $purpose ] : 'sign in'; } /** Purposes a request is allowed to name. */ public static function is_known_purpose( $purpose ) { return in_array( $purpose, array( self::PURPOSE_LOGIN, self::PURPOSE_REGISTER, self::PURPOSE_LINK, self::PURPOSE_CONFIRM ), true ); } /** * Verify a signature and return the address that made it. * * Burns the nonce on success, so a captured signature is worth one use and * that use has already happened. * * @param string $nonce * @param string $signature Base64, 65 bytes. * @param string $purpose * @param string $claimed The address the caller says signed. Required by * any caller that will bind the result to an * account; see the note on recovery above. When * given, a recovered address that differs is an * error rather than a new identity. * @return string|WP_Error Normalised CashAddress. */ public static function verify( $nonce, $signature, $purpose = self::PURPOSE_LOGIN, $claimed = '' ) { if ( ! self::is_known_purpose( $purpose ) ) { return new WP_Error( 'sirius_bad_purpose', __( 'Unknown sign-in purpose.', 'sirius-press' ) ); } $issued = self::inspect( $nonce ); if ( is_wp_error( $issued ) ) { return $issued; } if ( self::is_spent( $nonce ) ) { return new WP_Error( 'sirius_replay', __( 'That signature has already been used. Reload the page and sign again.', 'sirius-press' ) ); } $raw = base64_decode( (string) $signature, true ); if ( false === $raw || 65 !== strlen( $raw ) ) { return new WP_Error( 'sirius_bad_signature', __( 'That is not a wallet signature. It should be a short block of base64 text.', 'sirius-press' ) ); } $digest = SP_Message::bip137_digest( self::message( $nonce, $purpose ) ); $pubkey = SP_Secp256k1::recover( $raw, $digest ); if ( '' === $pubkey ) { return new WP_Error( 'sirius_bad_signature', __( 'That signature does not match the text this site asked you to sign.', 'sirius-press' ) ); } $address = SP_CashAddr::from_public_key( $pubkey, SP_Settings::prefix() ); if ( '' === $address ) { return new WP_Error( 'sirius_bad_signature', __( 'That signature could not be turned into an address.', 'sirius-press' ) ); } if ( '' !== $claimed ) { $want = SP_CashAddr::normalize( $claimed ); if ( '' === $want ) { return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) ); } if ( ! hash_equals( $want, $address ) ) { // The signature is well formed but over different bytes than // this site asked for — a copy that lost a line, a wallet that // rewrote the text, or a signature meant for something else. return new WP_Error( 'sirius_address_mismatch', __( 'That signature does not match the text this site asked you to sign. Copy the text again exactly as shown, including the blank lines, and sign it once more.', 'sirius-press' ) ); } } self::spend( $nonce ); return $address; } // ----------------------------------------------------------- single use private static function spent_key( $nonce ) { return 'sirius_spent_' . substr( hash( 'sha256', $nonce ), 0, 32 ); } private static function is_spent( $nonce ) { return (bool) get_transient( self::spent_key( $nonce ) ); } private static function spend( $nonce ) { // Outlives the challenge itself, so a nonce can never come back after // its marker expires but before the signature would have gone stale. set_transient( self::spent_key( $nonce ), 1, self::TTL + 120 ); } // ----------------------------------------------------------- rate limits /** * Throttle signature attempts per client. * * Signature recovery is the most expensive thing an unauthenticated * visitor can ask this site to do — on a BCMath host it is a few hundred * milliseconds of CPU each. Without a cap, the login endpoint is a free * denial-of-service amplifier. * * @return true|WP_Error */ public static function check_rate_limit( $bucket = 'verify', $max = 20, $window = 300 ) { $key = 'sirius_rl_' . $bucket . '_' . substr( hash( 'sha256', self::client_ip() . wp_salt() ), 0, 24 ); $count = (int) get_transient( $key ); if ( $count >= $max ) { return new WP_Error( 'sirius_rate_limited', __( 'Too many sign-in attempts from this address. Wait a few minutes and try again.', 'sirius-press' ), array( 'status' => 429 ) ); } set_transient( $key, $count + 1, $window ); return true; } /** * The client's address, as well as it can be known. * * Only proxy headers the site owner has explicitly vouched for are * believed. Trusting `X-Forwarded-For` by default would let anyone reset * their own rate limit by inventing a header. */ private static function client_ip() { $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filters whether forwarded-for headers may be believed. * * Set true only when this site is genuinely behind a proxy that * overwrites the header — the docker-compose nginx in this repo does. * * @param bool $trust */ if ( ! apply_filters( 'sirius_press_trust_proxy', defined( 'SIRIUS_PRESS_TRUST_PROXY' ) && SIRIUS_PRESS_TRUST_PROXY ) ) { return $remote; } foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR' ) as $header ) { if ( empty( $_SERVER[ $header ] ) ) { continue; } $value = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); $first = trim( explode( ',', $value )[0] ); if ( filter_var( $first, FILTER_VALIDATE_IP ) ) { return $first; } } return $remote; } }