212 lines
7.2 KiB
PHP
212 lines
7.2 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* Everything Sirius Press needs to know about the site it is running.
|
||
|
|
*
|
||
|
|
* Two things live here that ordinary WordPress has no concept of: which BCNR
|
||
|
|
* name this installation publishes under, and the key it publishes with.
|
||
|
|
*
|
||
|
|
* The key is the uncomfortable part and the docs say so plainly. The BNS
|
||
|
|
* gateway only accepts a write signed by the name's current on-chain owner,
|
||
|
|
* so unattended publishing — a post going live from wp-cron at 3am — means
|
||
|
|
* the server holds the owning key. There is no clever way around that: a key
|
||
|
|
* that can publish for the name is the key that owns the name. Sites that
|
||
|
|
* will not accept the risk run in `manual` mode instead, where the export
|
||
|
|
* queue drains from an admin's browser and the server stores no secret.
|
||
|
|
*
|
||
|
|
* At rest the phrase is AES-256-GCM encrypted under a key derived from
|
||
|
|
* SIRIUS_PRESS_KEY (or, failing that, the site's own auth salts). That
|
||
|
|
* protects against a leaked database dump, not against someone who can read
|
||
|
|
* wp-config.php — and the settings screen says exactly that rather than
|
||
|
|
* implying more.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || exit;
|
||
|
|
|
||
|
|
final class SP_Settings {
|
||
|
|
|
||
|
|
const OPT_NAME = 'sirius_press_name';
|
||
|
|
const OPT_NETWORK = 'sirius_press_network';
|
||
|
|
const OPT_GATEWAY = 'sirius_press_gateway';
|
||
|
|
const OPT_MODE = 'sirius_press_publish_mode';
|
||
|
|
const OPT_PHRASE = 'sirius_press_phrase';
|
||
|
|
const OPT_PATH = 'sirius_press_derivation_path';
|
||
|
|
const OPT_ADDRESS = 'sirius_press_publish_address';
|
||
|
|
const OPT_AUTOPUB = 'sirius_press_auto_publish';
|
||
|
|
const OPT_OPEN_REG = 'sirius_press_open_registration';
|
||
|
|
const OPT_STUB_MAIL = 'sirius_press_stub_email_domain';
|
||
|
|
|
||
|
|
const DEFAULT_GATEWAY = 'https://navigate.st';
|
||
|
|
|
||
|
|
/** Publishing modes. */
|
||
|
|
const MODE_SERVER = 'server'; // Server holds the phrase and signs by itself.
|
||
|
|
const MODE_MANUAL = 'manual'; // Nothing stored; an admin's browser signs.
|
||
|
|
|
||
|
|
public static function name() {
|
||
|
|
return (string) get_option( self::OPT_NAME, '' );
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function network() {
|
||
|
|
$n = (string) get_option( self::OPT_NETWORK, 'mainnet' );
|
||
|
|
return 'chipnet' === $n ? 'chipnet' : 'mainnet';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The CashAddress prefix this site's accounts and keys use. */
|
||
|
|
public static function prefix() {
|
||
|
|
return 'chipnet' === self::network() ? 'bchtest' : 'bitcoincash';
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function gateway() {
|
||
|
|
$g = trim( (string) get_option( self::OPT_GATEWAY, self::DEFAULT_GATEWAY ) );
|
||
|
|
return rtrim( '' === $g ? self::DEFAULT_GATEWAY : $g, '/' );
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function mode() {
|
||
|
|
return self::MODE_SERVER === get_option( self::OPT_MODE, self::MODE_MANUAL )
|
||
|
|
? self::MODE_SERVER
|
||
|
|
: self::MODE_MANUAL;
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function derivation_path() {
|
||
|
|
$p = trim( (string) get_option( self::OPT_PATH, '' ) );
|
||
|
|
return '' === $p ? SP_HD::DEFAULT_PATH : $p;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether publishing a post should trigger a static export. */
|
||
|
|
public static function auto_publish() {
|
||
|
|
return (bool) get_option( self::OPT_AUTOPUB, true );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Whether strangers may create accounts by proving an address. */
|
||
|
|
public static function open_registration() {
|
||
|
|
return (bool) get_option( self::OPT_OPEN_REG, (bool) get_option( 'users_can_register', false ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The domain used to mint placeholder addresses for plugins that insist on
|
||
|
|
* an email column. `.invalid` is reserved by RFC 2606 precisely so that it
|
||
|
|
* can never route anywhere.
|
||
|
|
*/
|
||
|
|
public static function stub_email_domain() {
|
||
|
|
$d = trim( (string) get_option( self::OPT_STUB_MAIL, '' ) );
|
||
|
|
if ( '' !== $d ) {
|
||
|
|
return $d;
|
||
|
|
}
|
||
|
|
$name = self::name();
|
||
|
|
return ( '' === $name ? 'sirius-press' : $name ) . '.invalid';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** True when the site knows its name and where to publish. */
|
||
|
|
public static function is_configured() {
|
||
|
|
return '' !== self::name() && '' !== self::gateway();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------- the publishing key
|
||
|
|
|
||
|
|
/** True when a phrase is stored, whatever mode the site is in. */
|
||
|
|
public static function has_phrase() {
|
||
|
|
return '' !== (string) get_option( self::OPT_PHRASE, '' );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Store (or clear) the publishing phrase, and cache the address it derives.
|
||
|
|
*
|
||
|
|
* @param string $mnemonic Pass '' to forget it.
|
||
|
|
* @return string '' on success, otherwise a human-readable error.
|
||
|
|
*/
|
||
|
|
public static function set_phrase( $mnemonic ) {
|
||
|
|
$mnemonic = SP_HD::normalize_mnemonic( $mnemonic );
|
||
|
|
if ( '' === $mnemonic ) {
|
||
|
|
delete_option( self::OPT_PHRASE );
|
||
|
|
delete_option( self::OPT_ADDRESS );
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$problem = SP_HD::phrase_problem( $mnemonic );
|
||
|
|
if ( '' !== $problem ) {
|
||
|
|
return $problem;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
$key = SP_HD::publishing_key( $mnemonic, self::prefix(), self::derivation_path() );
|
||
|
|
} catch ( Exception $e ) {
|
||
|
|
return 'Could not derive a key from that phrase: ' . $e->getMessage();
|
||
|
|
}
|
||
|
|
$sealed = self::seal( $mnemonic );
|
||
|
|
if ( '' === $sealed ) {
|
||
|
|
return 'This server has no working encryption (openssl), so the phrase cannot be stored safely.';
|
||
|
|
}
|
||
|
|
update_option( self::OPT_PHRASE, $sealed, false );
|
||
|
|
update_option( self::OPT_ADDRESS, $key['address'], false );
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The address the stored phrase publishes from, or ''. */
|
||
|
|
public static function publishing_address() {
|
||
|
|
return (string) get_option( self::OPT_ADDRESS, '' );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The private key for signing gateway writes.
|
||
|
|
*
|
||
|
|
* @return string 32 raw bytes, or '' when nothing is stored.
|
||
|
|
* @throws Exception When the stored phrase cannot be unsealed or derived.
|
||
|
|
*/
|
||
|
|
public static function publishing_private_key() {
|
||
|
|
$sealed = (string) get_option( self::OPT_PHRASE, '' );
|
||
|
|
if ( '' === $sealed ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$mnemonic = self::unseal( $sealed );
|
||
|
|
if ( '' === $mnemonic ) {
|
||
|
|
throw new Exception( 'The stored recovery phrase could not be decrypted. If SIRIUS_PRESS_KEY or the site salts changed, re-enter the phrase in Sirius Press settings.' );
|
||
|
|
}
|
||
|
|
$key = SP_HD::publishing_key( $mnemonic, self::prefix(), self::derivation_path() );
|
||
|
|
return $key['private'];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------ encryption
|
||
|
|
|
||
|
|
/** 32-byte key from the site's configured secret. */
|
||
|
|
private static function secret() {
|
||
|
|
$material = defined( 'SIRIUS_PRESS_KEY' ) && SIRIUS_PRESS_KEY
|
||
|
|
? SIRIUS_PRESS_KEY
|
||
|
|
: ( ( defined( 'AUTH_KEY' ) ? AUTH_KEY : '' ) . ( defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : '' ) );
|
||
|
|
return hash( 'sha256', 'sirius-press/v1/' . $material, true );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @return string base64 of iv|tag|ciphertext, or '' if unavailable. */
|
||
|
|
private static function seal( $plain ) {
|
||
|
|
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$iv = random_bytes( 12 );
|
||
|
|
$tag = '';
|
||
|
|
$ct = openssl_encrypt( $plain, 'aes-256-gcm', self::secret(), OPENSSL_RAW_DATA, $iv, $tag, 'sirius-press', 16 );
|
||
|
|
if ( false === $ct ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
return base64_encode( $iv . $tag . $ct );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @return string '' when the blob cannot be authenticated. */
|
||
|
|
private static function unseal( $sealed ) {
|
||
|
|
if ( ! function_exists( 'openssl_decrypt' ) ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$raw = base64_decode( $sealed, true );
|
||
|
|
if ( false === $raw || strlen( $raw ) < 29 ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$plain = openssl_decrypt(
|
||
|
|
substr( $raw, 28 ),
|
||
|
|
'aes-256-gcm',
|
||
|
|
self::secret(),
|
||
|
|
OPENSSL_RAW_DATA,
|
||
|
|
substr( $raw, 0, 12 ),
|
||
|
|
substr( $raw, 12, 16 ),
|
||
|
|
'sirius-press'
|
||
|
|
);
|
||
|
|
return false === $plain ? '' : $plain;
|
||
|
|
}
|
||
|
|
}
|