sirius-press/plugins/sirius-press-core/includes/class-sp-admin.php

326 lines
13 KiB
PHP
Raw Normal View History

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
<?php
/**
* The Sirius Press settings screen.
*
* One page, because there are only a handful of decisions to make and they
* are all consequential: which name this site is, which network, where the
* gateway is, and whether the server is allowed to hold the publishing key.
*
* The screen does one thing stock WordPress settings screens rarely do: it
* checks its own answers against reality. After a phrase is saved it derives
* the address, asks the gateway who currently owns the name, and says plainly
* whether those match. A mismatch caught here is a sentence of explanation; a
* mismatch caught later is a 403 in a cron log that nobody reads.
*
* @package SiriusPress
*/
defined( 'ABSPATH' ) || exit;
final class SP_Admin {
const PAGE = 'sirius-press';
public static function hooks() {
add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
add_filter( 'plugin_action_links_' . plugin_basename( SIRIUS_PRESS_CORE_FILE ), array( __CLASS__, 'action_links' ) );
}
public static function action_links( $links ) {
array_unshift(
$links,
'<a href="' . esc_url( admin_url( 'admin.php?page=' . self::PAGE ) ) . '">' . esc_html__( 'Settings', 'sirius-press' ) . '</a>'
);
return $links;
}
public static function menu() {
add_menu_page(
__( 'Sirius Press', 'sirius-press' ),
__( 'Sirius Press', 'sirius-press' ),
'manage_options',
self::PAGE,
array( __CLASS__, 'render' ),
'dashicons-admin-site-alt3',
71
);
}
public static function render() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to configure this site.', 'sirius-press' ) );
}
$errors = array();
$notes = array();
if ( isset( $_POST['sirius_press_settings'] ) && check_admin_referer( 'sirius_press_settings' ) ) {
list( $errors, $notes ) = self::save( wp_unslash( $_POST ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each field sanitised in save().
}
$name = SP_Settings::name();
$address = SP_Settings::publishing_address();
$mode = SP_Settings::mode();
$requirement = sirius_press_requirements_problem();
echo '<div class="wrap"><h1>' . esc_html__( 'Sirius Press', 'sirius-press' ) . '</h1>';
if ( '' !== $requirement ) {
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $requirement ) );
}
foreach ( $errors as $e ) {
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $e ) );
}
foreach ( $notes as $n ) {
printf( '<div class="notice notice-success"><p>%s</p></div>', wp_kses_post( $n ) );
}
echo '<form method="post">';
wp_nonce_field( 'sirius_press_settings' );
echo '<input type="hidden" name="sirius_press_settings" value="1" />';
echo '<h2>' . esc_html__( 'Identity', 'sirius-press' ) . '</h2>';
echo '<table class="form-table" role="presentation">';
self::text_row(
'sirius_name',
__( 'BCNR name', 'sirius-press' ),
$name,
__( 'The name this site publishes under, exactly as registered — for example <code>example.bch</code>. Point that name\'s <code>p</code> or <code>ip</code> record at this server so visitors reach it.', 'sirius-press' )
);
echo '<tr><th><label for="sirius_network">' . esc_html__( 'Network', 'sirius-press' ) . '</label></th><td>';
echo '<select name="sirius_network" id="sirius_network">';
foreach ( array(
'mainnet' => __( 'Mainnet (bitcoincash:…)', 'sirius-press' ),
'chipnet' => __( 'Chipnet (bchtest:…) — testing only', 'sirius-press' ),
) as $value => $label ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $value ),
selected( SP_Settings::network(), $value, false ),
esc_html( $label )
);
}
echo '</select>';
echo '<p class="description">' . esc_html__( 'Accounts on this site must use addresses from this network. Changing it after people have signed up locks them out.', 'sirius-press' ) . '</p>';
echo '</td></tr>';
self::text_row(
'sirius_gateway',
__( 'Gateway', 'sirius-press' ),
SP_Settings::gateway(),
__( 'The BNS gateway that accepts static exports. Leave as the default unless you run your own.', 'sirius-press' )
);
echo '</table>';
echo '<h2>' . esc_html__( 'Publishing', 'sirius-press' ) . '</h2>';
echo '<table class="form-table" role="presentation">';
echo '<tr><th>' . esc_html__( 'How exports are signed', 'sirius-press' ) . '</th><td><fieldset>';
printf(
'<label><input type="radio" name="sirius_mode" value="%s"%s> %s</label><p class="description" style="margin:4px 0 12px 24px">%s</p>',
esc_attr( SP_Settings::MODE_MANUAL ),
checked( $mode, SP_Settings::MODE_MANUAL, false ),
esc_html__( 'Manual — nothing is stored on this server', 'sirius-press' ),
esc_html__( 'Exports queue up and are signed from your browser when you visit the export screen. Safest, but a post published by a schedule sits in the queue until somebody signs in.', 'sirius-press' )
);
printf(
'<label><input type="radio" name="sirius_mode" value="%s"%s> %s</label><p class="description" style="margin:4px 0 0 24px">%s</p>',
esc_attr( SP_Settings::MODE_SERVER ),
checked( $mode, SP_Settings::MODE_SERVER, false ),
esc_html__( 'Automatic — this server holds the publishing key', 'sirius-press' ),
esc_html__( 'Posts export the moment they go live, with no one present. The trade is real: the recovery phrase below is the phrase that owns the name and its funds, and anyone who can read this server\'s database and its wp-config.php can take both.', 'sirius-press' )
);
echo '</fieldset></td></tr>';
echo '<tr><th><label for="sirius_phrase">' . esc_html__( 'Recovery phrase', 'sirius-press' ) . '</label></th><td>';
printf(
'<textarea name="sirius_phrase" id="sirius_phrase" rows="3" class="large-text code" autocomplete="off" spellcheck="false" placeholder="%s"></textarea>',
esc_attr__( 'twelve words, separated by spaces', 'sirius-press' )
);
if ( SP_Settings::has_phrase() ) {
echo '<p class="description">' . esc_html__( 'A phrase is stored. Leave this blank to keep it, or type a new one to replace it.', 'sirius-press' ) . '</p>';
echo '<p><label><input type="checkbox" name="sirius_forget_phrase" value="1"> ' . esc_html__( 'Forget the stored phrase', 'sirius-press' ) . '</label></p>';
} else {
echo '<p class="description">' . esc_html__( 'Only needed for automatic publishing. It is encrypted before it is written to the database — which protects a stolen database dump, and nothing more.', 'sirius-press' ) . '</p>';
}
echo '</td></tr>';
self::text_row(
'sirius_path',
__( 'Derivation path', 'sirius-press' ),
SP_Settings::derivation_path(),
__( 'Leave alone unless your wallet was created somewhere that used a different path. The default matches the Sirius portal wallet.', 'sirius-press' )
);
echo '<tr><th>' . esc_html__( 'Publish on update', 'sirius-press' ) . '</th><td><label>';
printf(
'<input type="checkbox" name="sirius_auto_publish" value="1"%s> %s',
checked( SP_Settings::auto_publish(), true, false ),
esc_html__( 'Export a page to the name\'s storage whenever it is published or edited', 'sirius-press' )
);
echo '</label></td></tr>';
echo '</table>';
echo '<h2>' . esc_html__( 'Accounts', 'sirius-press' ) . '</h2>';
echo '<table class="form-table" role="presentation">';
echo '<tr><th>' . esc_html__( 'Registration', 'sirius-press' ) . '</th><td><label>';
printf(
'<input type="checkbox" name="sirius_open_registration" value="1"%s> %s',
checked( SP_Settings::open_registration(), true, false ),
esc_html__( 'Anyone who can sign with a wallet may create an account', 'sirius-press' )
);
echo '</label></td></tr>';
self::text_row(
'sirius_stub_domain',
__( 'Placeholder mail domain', 'sirius-press' ),
SP_Settings::stub_email_domain(),
__( 'Plugins that insist on an email address get one here. It must stay unroutable — keep the <code>.invalid</code> ending, which is reserved so it can never resolve.', 'sirius-press' )
);
echo '</table>';
submit_button();
echo '</form>';
self::render_status( $name, $address );
echo '</div>';
}
/** The reality check: does the stored key actually own the name? */
private static function render_status( $name, $address ) {
echo '<h2>' . esc_html__( 'Status', 'sirius-press' ) . '</h2>';
echo '<table class="widefat striped" style="max-width:860px"><tbody>';
self::status_row( __( 'Name', 'sirius-press' ), '' !== $name ? $name : __( 'not set', 'sirius-press' ), '' !== $name );
self::status_row(
__( 'Publishing address', 'sirius-press' ),
'' !== $address ? $address : __( 'no phrase stored', 'sirius-press' ),
'' !== $address
);
if ( '' === $name ) {
echo '</tbody></table>';
return;
}
$owner = SP_Gateway::owner_of_name();
if ( '' === $owner ) {
self::status_row(
__( 'On-chain owner', 'sirius-press' ),
__( 'the gateway did not answer — it may not have indexed this name yet', 'sirius-press' ),
null
);
} elseif ( '' === $address ) {
self::status_row( __( 'On-chain owner', 'sirius-press' ), $owner, null );
} elseif ( hash_equals( $owner, $address ) ) {
self::status_row(
__( 'On-chain owner', 'sirius-press' ),
__( 'matches the stored key — this site can publish', 'sirius-press' ),
true
);
} else {
self::status_row(
__( 'On-chain owner', 'sirius-press' ),
sprintf(
/* translators: %s: the address that currently owns the name. */
__( '%s — which is NOT the stored key. Exports will be refused until the phrase here is the one that owns the name.', 'sirius-press' ),
$owner
),
false
);
}
echo '</tbody></table>';
}
private static function status_row( $label, $value, $ok ) {
$mark = true === $ok ? '✓' : ( false === $ok ? '✗' : '·' );
$col = true === $ok ? '#007017' : ( false === $ok ? '#b32d2e' : '#646970' );
printf(
'<tr><th style="width:200px;text-align:left">%s</th><td><span style="color:%s;font-weight:700">%s</span> <code>%s</code></td></tr>',
esc_html( $label ),
esc_attr( $col ),
esc_html( $mark ),
esc_html( $value )
);
}
private static function text_row( $field, $label, $value, $description ) {
printf(
'<tr><th><label for="%1$s">%2$s</label></th><td><input type="text" name="%1$s" id="%1$s" value="%3$s" class="regular-text code"><p class="description">%4$s</p></td></tr>',
esc_attr( $field ),
esc_html( $label ),
esc_attr( $value ),
wp_kses_post( $description )
);
}
/** @return array{0:string[],1:string[]} errors, notices */
private static function save( $post ) {
$errors = array();
$notes = array();
$name = strtolower( sanitize_text_field( isset( $post['sirius_name'] ) ? $post['sirius_name'] : '' ) );
if ( '' !== $name && ! preg_match( '/^[a-z0-9][a-z0-9.\-]{0,200}\.[a-z0-9\-]+$/', $name ) ) {
$errors[] = __( 'That does not look like a BCNR name. A name is a label and a TLD, such as example.bch.', 'sirius-press' );
} else {
update_option( SP_Settings::OPT_NAME, $name );
}
update_option( SP_Settings::OPT_NETWORK, 'chipnet' === ( isset( $post['sirius_network'] ) ? $post['sirius_network'] : '' ) ? 'chipnet' : 'mainnet' );
$gateway = esc_url_raw( trim( isset( $post['sirius_gateway'] ) ? $post['sirius_gateway'] : '' ) );
if ( '' !== $gateway && 0 !== strpos( $gateway, 'http' ) ) {
$errors[] = __( 'The gateway must be a full URL.', 'sirius-press' );
} else {
update_option( SP_Settings::OPT_GATEWAY, '' === $gateway ? SP_Settings::DEFAULT_GATEWAY : $gateway );
}
update_option(
SP_Settings::OPT_MODE,
SP_Settings::MODE_SERVER === ( isset( $post['sirius_mode'] ) ? $post['sirius_mode'] : '' )
? SP_Settings::MODE_SERVER
: SP_Settings::MODE_MANUAL
);
update_option( SP_Settings::OPT_AUTOPUB, ! empty( $post['sirius_auto_publish'] ) );
update_option( SP_Settings::OPT_OPEN_REG, ! empty( $post['sirius_open_registration'] ) );
$path = sanitize_text_field( isset( $post['sirius_path'] ) ? $post['sirius_path'] : '' );
if ( '' !== $path && ! preg_match( "#^m(/\d+'?)*$#", $path ) ) {
$errors[] = __( 'That is not a derivation path. It looks like m/44\'/145\'/0\'/0/0.', 'sirius-press' );
} else {
update_option( SP_Settings::OPT_PATH, $path );
}
$stub = strtolower( sanitize_text_field( isset( $post['sirius_stub_domain'] ) ? $post['sirius_stub_domain'] : '' ) );
if ( '' !== $stub && ! preg_match( '/\.invalid$/', $stub ) ) {
$errors[] = __( 'The placeholder mail domain must end in .invalid, so that mail to it can never be delivered by accident.', 'sirius-press' );
} else {
update_option( SP_Settings::OPT_STUB_MAIL, $stub );
}
if ( ! empty( $post['sirius_forget_phrase'] ) ) {
SP_Settings::set_phrase( '' );
$notes[] = __( 'The stored recovery phrase has been deleted. Automatic publishing is off until a phrase is provided again.', 'sirius-press' );
} elseif ( ! empty( $post['sirius_phrase'] ) ) {
$problem = SP_Settings::set_phrase( (string) $post['sirius_phrase'] );
if ( '' !== $problem ) {
$errors[] = $problem;
} else {
$notes[] = sprintf(
/* translators: %s: the derived CashAddress. */
__( 'Phrase stored. It publishes from <code>%s</code> — check that this is the address that owns the name.', 'sirius-press' ),
esc_html( SP_Settings::publishing_address() )
);
}
}
if ( ! $errors ) {
$notes[] = __( 'Settings saved.', 'sirius-press' );
}
return array( $errors, $notes );
}
}