Running the fork against a live WordPress found a real hole in registration, and it is the kind that only shows up when you actually try it. ECDSA public-key recovery always succeeds. Given any well-formed signature and any digest it returns a key — just not the signer's, unless the digest is the one that was signed. The auth flow leaned on that as if a wrong message would fail. It does not; it quietly yields a stranger's address. At sign-in this was harmless, because the wrong address matches no account and the attempt fails. Registration and wallet-linking were another matter: both took the recovered address and bound it to an account, so a signature over slightly different text — a challenge copied without its blank line, a wallet that rewrote the text, a login signature replayed at the registration form — created an account keyed to an address nobody could sign for. The person would see "success" and discover the truth the next time they tried to get in. Wallet-linking was worse still: it would move an existing account onto a dead address and lock its owner out of their own site. Both paths now require the address the signer claims and compare it to the recovered one, which is what verification actually means. Sign-in accepts the claim when the page sends it and uses it to turn "no account uses that wallet" into the more useful "that signature is not over the text we asked for". Also from running it: URL rewriting mangled every link on a site whose URL carries a port. The protocol-relative pass matched inside absolute URLs and gave each one a second scheme, and matching the host without its port left the port stranded as `//host:8760:8760/`. Local and staging installs would have exported a site of broken links. Plain permalinks silently collapse an entire site onto one exported file, because every post's URL is `/?p=N` and its path is `/`. The queue looks healthy the whole time. The Publishing screen now says so. Translations loaded on `plugins_loaded`, which WordPress 6.7 warns about on every request — the kind of noise that trains people to stop reading logs. And one deletion: an `is_email()` filter written on the assumption that WordPress rejects `.invalid` addresses. It does not — `is_email()` validates syntax, not whether a domain could exist — so the filter never fired. A filter that appears to relax a rule but does not is worse than no filter, because someone later reasons from it. The documentation made the same claim and has been corrected. Verification added rather than asserted: tests/live.mjs drives a real instance over HTTP (40 checks), and tests/mock-gateway.mjs answers uploads with the signature check transcribed from the gateway's own source, so the publishing path can be exercised without a registered name.
316 lines
12 KiB
PHP
316 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Signing in.
|
|
*
|
|
* The whole substitution happens in one filter. WordPress asks its
|
|
* `authenticate` chain "who is this?", and this plugin answers first: if the
|
|
* request carries a wallet signature over a challenge this site issued, the
|
|
* signer's address identifies the account, and a `WP_User` comes back. Core
|
|
* then sets its ordinary auth cookie, and from that point on every
|
|
* capability, role check, nonce and REST permission works exactly as it does
|
|
* on stock WordPress. Nothing downstream knows the login was different.
|
|
*
|
|
* The form degrades honestly. With JavaScript, a button signs the challenge
|
|
* in the page or hands it to the browser's own wallet. Without it, the
|
|
* challenge is printed in a box next to a field for the signature — which is
|
|
* precisely the "Sign message" workflow every desktop BCH wallet already has.
|
|
* That path is not a courtesy to the JavaScript-averse; it is the path for
|
|
* someone whose keys live on a machine that never touches this site.
|
|
*
|
|
* @package SiriusPress
|
|
*/
|
|
|
|
defined( 'ABSPATH' ) || exit;
|
|
|
|
final class SPA_Login {
|
|
|
|
const OPT_ALLOW_PASSWORDS = 'sirius_press_allow_password_login';
|
|
|
|
public static function hooks() {
|
|
add_filter( 'authenticate', array( __CLASS__, 'authenticate' ), 5, 3 );
|
|
add_action( 'login_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
|
add_action( 'login_form', array( __CLASS__, 'render_form' ) );
|
|
add_filter( 'login_message', array( __CLASS__, 'login_message' ) );
|
|
add_action( 'login_footer', array( __CLASS__, 'footer_config' ) );
|
|
|
|
if ( ! self::passwords_allowed() ) {
|
|
// Pull core's password checks out of the chain entirely rather
|
|
// than letting them run and fail: a site that has turned
|
|
// passwords off should not have a password oracle on its login
|
|
// page at all.
|
|
remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
|
|
remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
|
|
add_action( 'login_head', array( __CLASS__, 'hide_password_fields' ) );
|
|
}
|
|
}
|
|
|
|
/** Whether username+password sign-in is still accepted. */
|
|
public static function passwords_allowed() {
|
|
return (bool) get_option( self::OPT_ALLOW_PASSWORDS, true );
|
|
}
|
|
|
|
/**
|
|
* The substitution itself.
|
|
*
|
|
* @param null|WP_User|WP_Error $user
|
|
* @param string $username
|
|
* @param string $password
|
|
* @return null|WP_User|WP_Error
|
|
*/
|
|
public static function authenticate( $user, $username, $password ) {
|
|
if ( $user instanceof WP_User ) {
|
|
return $user;
|
|
}
|
|
// Nonce checking is not applicable here: the signed challenge *is* the
|
|
// anti-forgery token, and it is stronger than one — it is bound to a
|
|
// key, single-use, and expires.
|
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
|
if ( empty( $_POST['sirius_signature'] ) || empty( $_POST['sirius_nonce'] ) ) {
|
|
return $user;
|
|
}
|
|
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
|
|
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) );
|
|
// Optional at sign-in: the recovered address is looked up against real
|
|
// accounts, so a wrong one simply finds nothing. When the page does
|
|
// send it, checking it turns "no account uses that wallet" into the
|
|
// more useful "that signature is not over the text we asked for".
|
|
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
|
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
|
|
$limited = SPA_Challenge::check_rate_limit( 'login' );
|
|
if ( is_wp_error( $limited ) ) {
|
|
return $limited;
|
|
}
|
|
|
|
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LOGIN, $claimed );
|
|
if ( is_wp_error( $address ) ) {
|
|
return $address;
|
|
}
|
|
|
|
$found = SP_Identity::user_by_address( $address );
|
|
if ( ! $found ) {
|
|
if ( SP_Settings::open_registration() ) {
|
|
return new WP_Error(
|
|
'sirius_no_account',
|
|
sprintf(
|
|
/* translators: %s: URL of the registration page. */
|
|
__( 'No account here uses that wallet yet. <a href="%s">Create one</a> — it takes one more signature.', 'sirius-press' ),
|
|
esc_url( SPA_Register::url() )
|
|
)
|
|
);
|
|
}
|
|
return new WP_Error(
|
|
'sirius_no_account',
|
|
__( 'No account on this site uses that wallet, and registration is closed.', 'sirius-press' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fires after a wallet signature has been accepted for a user.
|
|
*
|
|
* A plugin that wants a second factor can return a WP_Error from the
|
|
* `authenticate` chain at a later priority; this is the hook that
|
|
* tells it a wallet proof already succeeded.
|
|
*
|
|
* @param WP_User $found
|
|
* @param string $address
|
|
*/
|
|
do_action( 'sirius_press_wallet_authenticated', $found, $address );
|
|
return $found;
|
|
}
|
|
|
|
// ------------------------------------------------------------------- UI
|
|
|
|
public static function enqueue() {
|
|
self::enqueue_wallet();
|
|
wp_enqueue_style(
|
|
'sirius-press-login',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/login.css',
|
|
array(),
|
|
SIRIUS_PRESS_AUTH_VERSION
|
|
);
|
|
}
|
|
|
|
/** Shared by the login screen, the registration screen and the profile. */
|
|
public static function enqueue_wallet() {
|
|
wp_enqueue_script(
|
|
'sirius-press-bip39',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/bip39-en.js',
|
|
array(),
|
|
SIRIUS_PRESS_AUTH_VERSION,
|
|
true
|
|
);
|
|
wp_enqueue_script(
|
|
'sirius-press-wallet',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/wallet.js',
|
|
array( 'sirius-press-bip39' ),
|
|
SIRIUS_PRESS_AUTH_VERSION,
|
|
true
|
|
);
|
|
wp_enqueue_script(
|
|
'sirius-press-login-js',
|
|
SIRIUS_PRESS_AUTH_URL . 'assets/login.js',
|
|
array( 'sirius-press-wallet' ),
|
|
SIRIUS_PRESS_AUTH_VERSION,
|
|
true
|
|
);
|
|
}
|
|
|
|
/** Configuration the scripts need, printed once. */
|
|
public static function footer_config() {
|
|
self::print_config();
|
|
}
|
|
|
|
public static function print_config() {
|
|
static $printed = false;
|
|
if ( $printed ) {
|
|
return;
|
|
}
|
|
$printed = true;
|
|
printf(
|
|
'<script>window.SIRIUS_PRESS = %s;</script>',
|
|
wp_json_encode(
|
|
array(
|
|
'prefix' => SP_Settings::prefix(),
|
|
'path' => SP_Settings::derivation_path(),
|
|
'network' => SP_Settings::network(),
|
|
'site' => home_url( '/' ),
|
|
'strings' => array(
|
|
'signing' => __( 'Signing…', 'sirius-press' ),
|
|
'noWallet' => __( 'No wallet found in this browser.', 'sirius-press' ),
|
|
'generated' => __( 'Write these words down. They are the only way back into this account — nobody, including this site, can reset them for you.', 'sirius-press' ),
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The wallet block on wp-login.php.
|
|
*
|
|
* Rendered by `login_form`, which puts it directly under the password
|
|
* field, so the wallet path reads as the primary one and passwords as the
|
|
* leftover they are.
|
|
*/
|
|
public static function render_form() {
|
|
$nonce = SPA_Challenge::issue();
|
|
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_LOGIN );
|
|
self::render_signing_block( $nonce, $message, SPA_Challenge::PURPOSE_LOGIN, __( 'Sign in with your wallet', 'sirius-press' ) );
|
|
}
|
|
|
|
/**
|
|
* The signing widget, shared by login and registration.
|
|
*
|
|
* @param string $nonce
|
|
* @param string $message Exact text to be signed.
|
|
* @param string $purpose
|
|
* @param string $button Label for the primary action.
|
|
*/
|
|
public static function render_signing_block( $nonce, $message, $purpose, $button ) {
|
|
?>
|
|
<div class="sirius-wallet" data-sirius-purpose="<?php echo esc_attr( $purpose ); ?>">
|
|
<input type="hidden" name="sirius_nonce" value="<?php echo esc_attr( $nonce ); ?>" />
|
|
<input type="hidden" name="sirius_purpose" value="<?php echo esc_attr( $purpose ); ?>" />
|
|
<?php
|
|
/*
|
|
* The address the signer claims. Filled in by the script after
|
|
* signing; typed by hand on the paste-a-signature path.
|
|
*
|
|
* Not decoration: recovering a key from a signature always
|
|
* succeeds, so without something to compare against, a signature
|
|
* over the wrong text yields a stranger's address instead of an
|
|
* error. See the note in class-spa-challenge.php.
|
|
*/
|
|
?>
|
|
<input type="hidden" name="sirius_address" class="sirius-wallet__address" value="" />
|
|
<textarea
|
|
class="sirius-wallet__message"
|
|
readonly
|
|
rows="8"
|
|
aria-label="<?php esc_attr_e( 'The exact text to sign', 'sirius-press' ); ?>"
|
|
><?php echo esc_textarea( $message ); ?></textarea>
|
|
|
|
<div class="sirius-wallet__actions">
|
|
<button type="button" class="button button-primary sirius-wallet__sign" hidden>
|
|
<?php echo esc_html( $button ); ?>
|
|
</button>
|
|
<button type="button" class="button sirius-wallet__external" hidden>
|
|
<?php esc_html_e( 'Use the browser wallet', 'sirius-press' ); ?>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="sirius-wallet__phrase" hidden>
|
|
<label for="sirius_phrase_<?php echo esc_attr( $purpose ); ?>">
|
|
<?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?>
|
|
</label>
|
|
<textarea
|
|
id="sirius_phrase_<?php echo esc_attr( $purpose ); ?>"
|
|
class="sirius-wallet__phrase-input"
|
|
rows="2"
|
|
autocomplete="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
></textarea>
|
|
<p class="sirius-wallet__hint">
|
|
<?php esc_html_e( 'Typed here, the phrase never leaves this page — only the signature is sent. If you would rather not type it, sign the text above in your own wallet and paste the result below.', 'sirius-press' ); ?>
|
|
</p>
|
|
</div>
|
|
|
|
<details class="sirius-wallet__manual">
|
|
<summary><?php esc_html_e( 'Paste a signature instead', 'sirius-press' ); ?></summary>
|
|
<p class="sirius-wallet__hint">
|
|
<?php esc_html_e( 'Sign the text above in any Bitcoin Cash wallet — Electron Cash calls it “Sign message” — and paste what it gives you.', 'sirius-press' ); ?>
|
|
</p>
|
|
<textarea
|
|
name="sirius_signature"
|
|
class="sirius-wallet__signature"
|
|
rows="3"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="<?php esc_attr_e( 'base64 signature', 'sirius-press' ); ?>"
|
|
></textarea>
|
|
<label class="sirius-wallet__address-label" for="sirius_address_<?php echo esc_attr( $purpose ); ?>">
|
|
<?php esc_html_e( 'The address you signed with', 'sirius-press' ); ?>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="sirius_address_<?php echo esc_attr( $purpose ); ?>"
|
|
class="sirius-wallet__address-input"
|
|
autocomplete="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
placeholder="<?php echo esc_attr( SP_Settings::prefix() ); ?>:…"
|
|
/>
|
|
<p class="sirius-wallet__hint">
|
|
<?php esc_html_e( 'Your wallet shows this next to the signature. It is checked against the signature, so a mistyped address is caught rather than quietly creating the wrong account.', 'sirius-press' ); ?>
|
|
</p>
|
|
</details>
|
|
|
|
<p class="sirius-wallet__status" role="status" aria-live="polite"></p>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
public static function login_message( $message ) {
|
|
if ( ! empty( $_GET['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
|
return $message;
|
|
}
|
|
$intro = '<p class="message sirius-intro">'
|
|
. esc_html__( 'This site has no passwords to forget and no email to confirm. Your wallet is the account.', 'sirius-press' )
|
|
. '</p>';
|
|
return $intro . $message;
|
|
}
|
|
|
|
/**
|
|
* Visually retire the password field when passwords are off.
|
|
*
|
|
* The submit button stays. It is what the paste-a-signature path — the one
|
|
* for people whose keys are on another machine — uses to send the form,
|
|
* and hiding it would leave that path with no way to submit at all.
|
|
*/
|
|
public static function hide_password_fields() {
|
|
echo '<style>'
|
|
. '#user_pass, label[for="user_pass"], .user-pass-wrap, .forgetmenot + p { display:none !important; }'
|
|
. '</style>';
|
|
}
|
|
}
|