258 lines
9.3 KiB
PHP
258 lines
9.3 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* The thing a user signs to prove who they are.
|
||
|
|
*
|
||
|
|
* A challenge is a short piece of text this site produced, which the user
|
||
|
|
* signs with their wallet. The signature comes back, the public key is
|
||
|
|
* recovered from it, and the address that public key controls *is* the
|
||
|
|
* identity — nothing had to be typed, remembered or emailed.
|
||
|
|
*
|
||
|
|
* **Stateless issue, stateful consume.** Handing out a challenge writes
|
||
|
|
* nothing: the nonce carries its own timestamp and an HMAC under the site's
|
||
|
|
* salts, so a forged nonce fails arithmetic rather than a database lookup,
|
||
|
|
* and a login page that is rendered and abandoned costs nothing. Only a
|
||
|
|
* *successful* verification writes — a short-lived marker that burns the
|
||
|
|
* nonce, so the same signature cannot be replayed.
|
||
|
|
*
|
||
|
|
* **The message is reconstructed, never trusted.** The client sends back the
|
||
|
|
* nonce and what it was for; this class rebuilds the exact text from those
|
||
|
|
* and verifies against the rebuilt copy. A client that lies about the purpose
|
||
|
|
* is verifying against a message the user never signed, so it fails. That is
|
||
|
|
* also why a login signature cannot be replayed to create an account: the
|
||
|
|
* purpose is inside the signed bytes.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || exit;
|
||
|
|
|
||
|
|
final class SPA_Challenge {
|
||
|
|
|
||
|
|
/** How long a challenge stays signable. */
|
||
|
|
const TTL = 600;
|
||
|
|
|
||
|
|
const PURPOSE_LOGIN = 'login';
|
||
|
|
const PURPOSE_REGISTER = 'register';
|
||
|
|
const PURPOSE_LINK = 'link';
|
||
|
|
const PURPOSE_CONFIRM = 'confirm';
|
||
|
|
|
||
|
|
private static function salt() {
|
||
|
|
return wp_salt( 'auth' ) . '|sirius-press-challenge|v1';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mint a nonce: `<issued_ms>.<random>.<tag>`.
|
||
|
|
*
|
||
|
|
* 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
|
||
|
|
* @return string|WP_Error Normalised CashAddress.
|
||
|
|
*/
|
||
|
|
public static function verify( $nonce, $signature, $purpose = self::PURPOSE_LOGIN ) {
|
||
|
|
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' ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|