sirius-press/plugins/sirius-press-auth/includes/class-spa-login.php
Silent Mode 5465b65756 feat(sirius-press): a WordPress where the account is a key, not a mailbox
WordPress makes two assumptions this project cannot accept: that identity
comes from an email address, and that a site lives at one server. Both are
things somebody else can take away — a mailbox is rented from a provider who
can close it or be compelled to open it, and a server is one seizure from
being gone.

Sirius Press replaces the first and hedges the second.

Signing in means signing a challenge with the key that controls a CashAddress.
The address is recovered from the signature, so nothing is typed but the
signature itself, and the result is an ordinary WordPress session cookie —
roles, capabilities, nonces and the REST API never learn the login was
different. Three ways to produce one: a wallet the browser already exposes, a
phrase used once in the page and wiped, or a signature pasted in from any
BIP-137 wallet, which needs no JavaScript and lets the key stay on a machine
that never touches the web.

There is no password reset, and the recovery page says so plainly rather than
offering a form that cannot work. A reset mechanism is by construction a way
to take an account from its owner, and it is always easier to attack than the
cryptography it bypasses.

Publishing a post also exports it as static HTML to the name's storage on Sia,
signed by the key that owns the name, so the site keeps answering when the
server does not.

Email as a feature is untouched. wp_mail() still works, SMTP still sends, and
contact forms still deliver to addresses real people typed. Only mail to the
site's own unroutable placeholder addresses is diverted to an in-app inbox.
The objection was to email as identity, not to email.

Core is pinned and patched rather than vendored. WordPress 7.1.1 is 149 MB and
5,008 files; the fork's entire core diff is 75 lines in wp-admin/install.php.
Carrying the former to express the latter would bury the patch where nobody
reviews it and make every clone of the monorepo pay for it. Upstream releases
still merge through tools/update-wordpress.sh, which reapplies the series and
says exactly which hunk needs a human.

The cryptography is implemented twice — PHP on the server, JavaScript in the
page — because the server must verify and the browser must sign. Both are
pinned against libauth, the library the Sirius portal wallet and the BNS
gateway already use, so a disagreement of one byte fails the test suite rather
than presenting as a rejected login at three in the morning.

132 checks, no framework, about a second.
2026-09-21 01:39:38 +02:00

284 lines
9.9 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'] ) );
// 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 );
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 ); ?>" />
<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>
</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>';
}
}