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.
This commit is contained in:
commit
5465b65756
61 changed files with 10386 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Build output: a patched WordPress tree, the release zip, and the download
|
||||||
|
# cache. All of it is reproducible from tools/build.sh plus tools/wordpress.lock,
|
||||||
|
# and dist/sirius-press alone is 113 MB.
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# The live configuration, which holds database passwords and SIRIUS_PRESS_KEY.
|
||||||
|
docker/.env
|
||||||
67
CHANGELOG.md
Normal file
67
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 — unreleased
|
||||||
|
|
||||||
|
First cut. Sirius Press installs, signs people in with a wallet, and publishes
|
||||||
|
static copies of its pages to a BCNR name.
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
|
||||||
|
- Sign in by signing a challenge with a Bitcoin Cash key. The address is
|
||||||
|
recovered from the signature, so nothing has to be typed but the signature
|
||||||
|
itself.
|
||||||
|
- Three ways to produce one: a wallet the browser already exposes (Theseus), a
|
||||||
|
recovery phrase used once in the page and wiped, or a signature pasted in
|
||||||
|
from any BIP-137 wallet. The last works with JavaScript disabled.
|
||||||
|
- One-step registration — the signature is the confirmation, so there is no
|
||||||
|
email round trip and no pending state.
|
||||||
|
- Password sign-in stays on by default and can be turned off once every
|
||||||
|
account has a wallet. The screen that turns it off refuses to do so while it
|
||||||
|
would lock out the person asking.
|
||||||
|
- No password reset, and the "lost password" page explains why rather than
|
||||||
|
pretending otherwise.
|
||||||
|
- `/sirius-press/v1/confirm` lets any plugin demand a fresh signature before
|
||||||
|
something irreversible.
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
- Publishing a post exports it, the home page and its archives to the name's
|
||||||
|
storage on Sia, signed BNS-SITE1.
|
||||||
|
- Two signing modes: manual, where the browser signs and the server stores
|
||||||
|
nothing, and automatic, where an encrypted phrase lets cron publish alone.
|
||||||
|
Manual is the default.
|
||||||
|
- Unchanged pages are hashed and skipped rather than re-uploaded.
|
||||||
|
- Unpublishing a post removes its file from the mirror.
|
||||||
|
- `wp sirius export` and `wp sirius status` for the command line.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- Every account carries an unroutable `.invalid` placeholder `user_email`, so
|
||||||
|
the thousands of ecosystem reads of that field keep returning a string.
|
||||||
|
- Mail to those placeholders is captured into an in-app inbox. Mail to real
|
||||||
|
addresses is passed through untouched, so SMTP works normally.
|
||||||
|
- Shims for WooCommerce, Contact Form 7 and core's admin-email machinery.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
- One patch, 75 lines, against `wp-admin/install.php`: the setup wizard asks
|
||||||
|
for a wallet address instead of an email address, and the address is
|
||||||
|
optional.
|
||||||
|
- WordPress is pinned and verified rather than vendored —
|
||||||
|
`tools/wordpress.lock` plus `patches/`. See docs/upstream-merges.md.
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
|
||||||
|
- `install.sh` for a fresh Ubuntu VPS; Docker stack with MariaDB, PHP-FPM and
|
||||||
|
nginx. Core lives in the image, so rebuilding is a real upgrade.
|
||||||
|
- `tools/build.sh --zip` for shared hosting.
|
||||||
|
- `tools/update-wordpress.sh` to move onto a new upstream release.
|
||||||
|
- `tools/publish-release.sh` to ship to both mirrors.
|
||||||
|
|
||||||
|
### Known gaps
|
||||||
|
|
||||||
|
- The plugin compatibility matrix in docs is reasoned from each plugin's setup
|
||||||
|
path, not yet confirmed against a running install.
|
||||||
|
- No instance has been stood up end to end against a live chipnet name, so the
|
||||||
|
full publish path is verified by unit and interop tests rather than by a
|
||||||
|
round trip through the gateway.
|
||||||
177
README.md
Normal file
177
README.md
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
# Sirius Press
|
||||||
|
|
||||||
|
WordPress, with the mailbox taken out of the middle of it.
|
||||||
|
|
||||||
|
An account on a Sirius Press site is a Bitcoin Cash address. Signing in means
|
||||||
|
signing a challenge with the key that controls it. There is no registration
|
||||||
|
email, no confirmation link, no password reset — and no list of your readers'
|
||||||
|
email addresses sitting in a database waiting to leak.
|
||||||
|
|
||||||
|
Everything the site publishes is also mirrored, as plain static HTML, to the
|
||||||
|
BCNR name it belongs to. When the server is down, being upgraded, or seized,
|
||||||
|
the name keeps serving the last thing it published.
|
||||||
|
|
||||||
|
It is still WordPress. Yoast, WooCommerce, Contact Form 7 and the rest of the
|
||||||
|
ecosystem install and run; roles, capabilities, nonces and the REST API are
|
||||||
|
untouched. The fork's diff against upstream core is **one file, 75 lines** —
|
||||||
|
everything else is plugins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is for
|
||||||
|
|
||||||
|
You own a BCNR name. You want a real site behind it — one with an editor, a
|
||||||
|
media library, plugins, a theme you did not have to write — without the two
|
||||||
|
things WordPress assumes and this project rejects:
|
||||||
|
|
||||||
|
1. **That identity comes from email.** It does not. A mailbox is rented from
|
||||||
|
somebody who can close it, read it, or be compelled to hand it over. A key
|
||||||
|
is not.
|
||||||
|
2. **That the site lives at one server.** Sirius Press publishes a static copy
|
||||||
|
of every page to your name's storage on Sia, signed by the key that owns the
|
||||||
|
name. Readers reach that copy through any BCNR resolver, with no DNS and no
|
||||||
|
certificate authority in the path.
|
||||||
|
|
||||||
|
Email itself still works. `wp_mail()` is not disabled — configure SMTP and your
|
||||||
|
contact forms send, your WooCommerce receipts arrive. The objection was never
|
||||||
|
to email as a feature. It was to email as an identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting it running
|
||||||
|
|
||||||
|
On a fresh Ubuntu 22.04 or 24.04 server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://silentmode.st/sirius-press/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
That installs Docker if needed, brings up MariaDB + PHP-FPM + nginx, and prints
|
||||||
|
the URL to finish setup. It takes about five minutes on a small VPS, most of it
|
||||||
|
building the PHP image.
|
||||||
|
|
||||||
|
For shared hosting, a `.zip` you can upload is on
|
||||||
|
[the releases page](https://code.silentmode.st/silentmode/sirius-press/releases).
|
||||||
|
|
||||||
|
Full instructions, including how to point your name at it:
|
||||||
|
**[docs/install.md](docs/install.md)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### Signing in
|
||||||
|
|
||||||
|
The login page issues a short, single-use challenge. You sign it, the server
|
||||||
|
recovers the public key from the signature, derives the address, and looks up
|
||||||
|
the account. Nothing is typed but the signature.
|
||||||
|
|
||||||
|
Three ways to produce it, in order of preference:
|
||||||
|
|
||||||
|
- **A wallet the browser already has.** Theseus exposes one; the key never
|
||||||
|
touches the page.
|
||||||
|
- **A recovery phrase typed into the login form.** Used once, in the page, then
|
||||||
|
wiped. It is never transmitted — only the signature is.
|
||||||
|
- **Any Bitcoin Cash wallet at all.** The challenge is a BIP-137 message, the
|
||||||
|
format Electron Cash has had a "Sign message" box for since forever. Copy the
|
||||||
|
text, sign it on a machine that never touches the web, paste the result back.
|
||||||
|
This path works with JavaScript switched off.
|
||||||
|
|
||||||
|
The signature becomes an ordinary WordPress session cookie. Everything
|
||||||
|
downstream — `current_user_can()`, nonces, REST permissions, every plugin that
|
||||||
|
checks a capability — behaves exactly as it does on stock WordPress.
|
||||||
|
|
||||||
|
**There is no password reset, because there is nothing to reset.** If you lose
|
||||||
|
your phrase, the account is gone. An administrator can point an account at a
|
||||||
|
new address from the user editor, which makes recovery a decision a human
|
||||||
|
makes about a person they recognise — not something an attacker triggers by
|
||||||
|
compromising a mailbox. [docs/accounts.md](docs/accounts.md) says this at more
|
||||||
|
length, and the fork's "lost password" page says it to your users.
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
|
||||||
|
Publish a post and the affected pages — the post, the home page, the archives
|
||||||
|
it belongs to — are queued. Draining the queue renders each page over a
|
||||||
|
loopback request, rewrites its links to be document-relative, and PUTs it to
|
||||||
|
`/api/site/<name>/<path>` on the BNS gateway, signed by the key that owns the
|
||||||
|
name.
|
||||||
|
|
||||||
|
Signing happens one of two ways, and the choice is yours:
|
||||||
|
|
||||||
|
| | Where the key lives | Unattended publishing |
|
||||||
|
|---|---|---|
|
||||||
|
| **Manual** (default) | Nowhere. You type the phrase on the Publishing screen, it signs in your browser and uploads straight to the gateway. | No — the queue waits for you. |
|
||||||
|
| **Automatic** | Encrypted in the database, under `SIRIUS_PRESS_KEY` from `wp-config.php`. | Yes — cron drains the queue. |
|
||||||
|
|
||||||
|
Automatic is genuinely convenient and genuinely risky, and the settings screen
|
||||||
|
says so in those words. The gateway only accepts writes signed by the name's
|
||||||
|
current on-chain owner, so the key that can publish for your name is the key
|
||||||
|
that owns your name and its funds. There is no middle option where the server
|
||||||
|
holds something weaker; that is a property of the protocol, not a gap in this
|
||||||
|
fork. [docs/publishing.md](docs/publishing.md) has the details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/
|
||||||
|
sirius-press-core/ wallet crypto, gateway client, settings, the inbox
|
||||||
|
sirius-press-auth/ wallet sign-in, registration, the recovery page
|
||||||
|
sirius-press-sia-export/ the static export queue and uploader
|
||||||
|
sirius-press-compat/ shims for plugins that insist on an admin email
|
||||||
|
mu-plugins/ the bits that must load before plugins do
|
||||||
|
patches/ the core diff — currently one file
|
||||||
|
docker/ compose stack: MariaDB, PHP-FPM, nginx
|
||||||
|
tools/ build, upstream update, release
|
||||||
|
tests/ 132 checks, no framework, runs in under a second
|
||||||
|
docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where WordPress itself is
|
||||||
|
|
||||||
|
Not in this repository. `tools/wordpress.lock` pins an exact upstream version
|
||||||
|
and its SHA-256; `tools/build.sh` and the Docker image each download it, verify
|
||||||
|
the hash, and apply `patches/`.
|
||||||
|
|
||||||
|
This deviates from the original plan of vendoring core as a git subtree, and
|
||||||
|
the reason is arithmetic: WordPress 7.1.1 is 149 MB and 5,008 files, and the
|
||||||
|
fork's entire core diff is 75 lines in one file. Carrying the former to express
|
||||||
|
the latter would make every clone, every `git status` and every subtree split
|
||||||
|
in the parent monorepo pay for a patch you can read in a minute. Upstream
|
||||||
|
security releases still merge cleanly — `tools/update-wordpress.sh` bumps the
|
||||||
|
pin, reapplies the series with fuzz, and tells you exactly which hunk needs a
|
||||||
|
human if one does. [docs/upstream-merges.md](docs/upstream-merges.md) covers
|
||||||
|
the procedure and how to switch to a vendored subtree if you would rather.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- PHP 7.4+ — 8.2 or newer recommended
|
||||||
|
- **GMP or BCMath.** Not optional: the wallet cryptography runs in PHP. GMP is
|
||||||
|
about twenty times faster and is what the Docker image uses.
|
||||||
|
- 64-bit PHP — address checksums need 40-bit arithmetic
|
||||||
|
- OpenSSL, for encrypting a stored publishing key
|
||||||
|
- MySQL 5.7+ / MariaDB 10.4+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
132 checks, no framework, about a second. The suite that matters most is
|
||||||
|
`tests/interop.mjs`, which pins the browser wallet against the PHP one: both
|
||||||
|
implement secp256k1, RFC 6979, BIP-32 and BIP-137 independently, and every
|
||||||
|
vector in the suite came from libauth — the library the Sirius portal wallet
|
||||||
|
and the BNS gateway both use. A signature made in a browser verifies on the
|
||||||
|
server and at the gateway, or the suite fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-2.0-or-later, inherited from WordPress.
|
||||||
39
docker/.env.example
Normal file
39
docker/.env.example
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Sirius Press — copy to .env and edit before the first `docker compose up`.
|
||||||
|
#
|
||||||
|
# install.sh writes this file for you with generated passwords. Doing it by
|
||||||
|
# hand is fine too; just do not leave the passwords below as they are.
|
||||||
|
|
||||||
|
# --- database ---------------------------------------------------------------
|
||||||
|
# These are read once, when MariaDB initialises its data directory. Changing
|
||||||
|
# them later does not change the database — it just stops the app connecting.
|
||||||
|
DB_NAME=wordpress
|
||||||
|
DB_USER=wordpress
|
||||||
|
DB_PASSWORD=change-me
|
||||||
|
DB_ROOT_PASSWORD=change-me-too
|
||||||
|
|
||||||
|
# --- the site ---------------------------------------------------------------
|
||||||
|
# The URL visitors reach this instance on. Set it once the name's `p` or `ip`
|
||||||
|
# record points here; leaving it blank lets WordPress guess from the request,
|
||||||
|
# which is fine for a first run on an IP address.
|
||||||
|
SITE_URL=
|
||||||
|
|
||||||
|
# Port nginx publishes on the host. Put 8080 here if something else already
|
||||||
|
# owns 80 and you are running a reverse proxy in front.
|
||||||
|
HTTP_PORT=80
|
||||||
|
|
||||||
|
WP_DEBUG=false
|
||||||
|
|
||||||
|
# --- the publishing key ------------------------------------------------------
|
||||||
|
# What the site's recovery phrase is encrypted with at rest. Generated on first
|
||||||
|
# start if left blank, and then kept in the config volume.
|
||||||
|
#
|
||||||
|
# Worth setting explicitly and keeping a copy somewhere safe: if this value is
|
||||||
|
# lost, the stored phrase cannot be decrypted and has to be entered again.
|
||||||
|
# It is not the phrase itself and is useless without the database.
|
||||||
|
SIRIUS_PRESS_KEY=
|
||||||
|
|
||||||
|
# --- core pin ---------------------------------------------------------------
|
||||||
|
# Mirrors tools/wordpress.lock. Change both together, never just one.
|
||||||
|
WP_VERSION=7.1.1
|
||||||
|
WP_URL=https://wordpress.org/wordpress-7.1.1.tar.gz
|
||||||
|
WP_SHA256=3996fee13448ef12e07e9f0c77db2f655ffa1b7cde71c80a4965d3bf1fb956b3
|
||||||
86
docker/Dockerfile
Normal file
86
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Sirius Press — PHP-FPM image with a verified, patched WordPress baked in.
|
||||||
|
#
|
||||||
|
# The image builds core rather than inheriting the official `wordpress` image
|
||||||
|
# on purpose. That image ships whatever WordPress version it was tagged with,
|
||||||
|
# and the patch series here is pinned to an exact one; silently applying a
|
||||||
|
# fork's patches to a different core is how a setup wizard ends up half
|
||||||
|
# rewritten. Downloading the pinned tarball and checking its hash during the
|
||||||
|
# build makes the version an explicit, verifiable property of the image.
|
||||||
|
#
|
||||||
|
# Core is installed to /opt/sirius-press/core, not to the document root. The
|
||||||
|
# entrypoint copies it into place on every start, which is what makes
|
||||||
|
# `docker compose build --pull && up -d` a real upgrade: the usual layout,
|
||||||
|
# where the document root is itself a volume, pins core to whatever version
|
||||||
|
# first created that volume and turns every security release into a manual
|
||||||
|
# migration.
|
||||||
|
#
|
||||||
|
# GMP is installed because the wallet cryptography runs in PHP. Without it the
|
||||||
|
# fork falls back to BCMath, which works and is roughly twenty times slower —
|
||||||
|
# unavoidable on a shared host, wasteful in a container we control.
|
||||||
|
|
||||||
|
FROM php:8.3-fpm-bookworm
|
||||||
|
|
||||||
|
ARG WP_VERSION
|
||||||
|
ARG WP_URL
|
||||||
|
ARG WP_SHA256
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl patch \
|
||||||
|
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libwebp-dev \
|
||||||
|
libzip-dev libgmp-dev libicu-dev \
|
||||||
|
; \
|
||||||
|
docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp; \
|
||||||
|
docker-php-ext-install -j"$(nproc)" \
|
||||||
|
bcmath gd gmp intl mysqli opcache zip exif \
|
||||||
|
; \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Settings a WordPress host wants and the PHP image does not set.
|
||||||
|
RUN { \
|
||||||
|
echo 'upload_max_filesize = 64M'; \
|
||||||
|
echo 'post_max_size = 64M'; \
|
||||||
|
echo 'memory_limit = 256M'; \
|
||||||
|
echo 'max_execution_time = 120'; \
|
||||||
|
echo 'opcache.memory_consumption = 128'; \
|
||||||
|
echo 'opcache.max_accelerated_files = 10000'; \
|
||||||
|
echo 'opcache.revalidate_freq = 2'; \
|
||||||
|
echo 'expose_php = Off'; \
|
||||||
|
} > /usr/local/etc/php/conf.d/sirius-press.ini
|
||||||
|
|
||||||
|
# --- core, verified -----------------------------------------------------------
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
curl -fsSL -o /tmp/wp.tar.gz "$WP_URL"; \
|
||||||
|
echo "$WP_SHA256 /tmp/wp.tar.gz" | sha256sum -c -; \
|
||||||
|
mkdir -p /opt/sirius-press; \
|
||||||
|
tar -xzf /tmp/wp.tar.gz -C /tmp; \
|
||||||
|
mv /tmp/wordpress /opt/sirius-press/core; \
|
||||||
|
rm -rf /tmp/wp.tar.gz
|
||||||
|
|
||||||
|
# --- the fork -----------------------------------------------------------------
|
||||||
|
|
||||||
|
WORKDIR /opt/sirius-press/core
|
||||||
|
|
||||||
|
COPY patches/ /tmp/patches/
|
||||||
|
RUN set -eux; \
|
||||||
|
for p in /tmp/patches/*.patch; do \
|
||||||
|
patch -p1 -F3 --forward --silent < "$p" \
|
||||||
|
|| { echo "patch $p did not apply to WordPress $WP_VERSION"; exit 1; }; \
|
||||||
|
done; \
|
||||||
|
rm -rf /tmp/patches; \
|
||||||
|
echo "$WP_VERSION" > /opt/sirius-press/core/.sirius-core-version
|
||||||
|
|
||||||
|
COPY plugins/ /opt/sirius-press/core/wp-content/plugins/
|
||||||
|
COPY mu-plugins/ /opt/sirius-press/core/wp-content/mu-plugins/
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/sirius-entrypoint
|
||||||
|
RUN chmod +x /usr/local/bin/sirius-entrypoint; \
|
||||||
|
mkdir -p /var/www/html /var/www/config; \
|
||||||
|
chown -R www-data:www-data /var/www/html /var/www/config
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
ENTRYPOINT ["sirius-entrypoint"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
96
docker/docker-compose.yml
Normal file
96
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# Sirius Press — a complete self-hosted stack.
|
||||||
|
#
|
||||||
|
# Three containers: MariaDB, PHP-FPM with the patched WordPress baked in, and
|
||||||
|
# nginx in front. Only nginx is published.
|
||||||
|
#
|
||||||
|
# The volume layout is the part worth reading. WordPress core lives in the
|
||||||
|
# image, not in a volume, so `docker compose build --pull && up -d` genuinely
|
||||||
|
# upgrades it — the usual arrangement, where the whole document root is a
|
||||||
|
# volume, freezes core at whatever version first created the volume and turns
|
||||||
|
# every security release into a manual migration. What people actually need to
|
||||||
|
# keep — uploads, plugins and themes they installed, and wp-config.php — is
|
||||||
|
# what gets a volume.
|
||||||
|
#
|
||||||
|
# cp .env.example .env # then edit it
|
||||||
|
# docker compose up -d
|
||||||
|
|
||||||
|
name: sirius-press
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.4
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: ${DB_NAME:-wordpress}
|
||||||
|
MARIADB_USER: ${DB_USER:-wordpress}
|
||||||
|
MARIADB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?set DB_ROOT_PASSWORD in .env}
|
||||||
|
MARIADB_AUTO_UPGRADE: "1"
|
||||||
|
command:
|
||||||
|
# utf8mb4 throughout: a post containing an emoji should not be a
|
||||||
|
# database error, and WordPress has assumed this for years.
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
volumes:
|
||||||
|
- db:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
args:
|
||||||
|
# install.sh copies these out of tools/wordpress.lock into .env.
|
||||||
|
# Defaults are pinned here too, so a plain `docker compose build`
|
||||||
|
# cannot quietly drift onto a different core.
|
||||||
|
WP_VERSION: ${WP_VERSION:-7.1.1}
|
||||||
|
WP_URL: ${WP_URL:-https://wordpress.org/wordpress-7.1.1.tar.gz}
|
||||||
|
WP_SHA256: ${WP_SHA256:-3996fee13448ef12e07e9f0c77db2f655ffa1b7cde71c80a4965d3bf1fb956b3}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
WORDPRESS_DB_HOST: db
|
||||||
|
WORDPRESS_DB_NAME: ${DB_NAME:-wordpress}
|
||||||
|
WORDPRESS_DB_USER: ${DB_USER:-wordpress}
|
||||||
|
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
|
||||||
|
WORDPRESS_SITE_URL: ${SITE_URL:-}
|
||||||
|
WORDPRESS_DEBUG: ${WP_DEBUG:-false}
|
||||||
|
SIRIUS_PRESS_KEY: ${SIRIUS_PRESS_KEY:-}
|
||||||
|
volumes:
|
||||||
|
- config:/var/www/config
|
||||||
|
# The document root is a volume so nginx can read the same files. It is
|
||||||
|
# refilled from the image on every start, so this does not pin core.
|
||||||
|
- core:/var/www/html
|
||||||
|
- uploads:/var/www/html/wp-content/uploads
|
||||||
|
- plugins:/var/www/html/wp-content/plugins
|
||||||
|
- themes:/var/www/html/wp-content/themes
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: nginx:1.27-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-80}:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# nginx serves static files directly and only proxies PHP, so it needs
|
||||||
|
# to see the same tree. Read-only: the web tier has no business writing.
|
||||||
|
- core:/var/www/html:ro
|
||||||
|
- uploads:/var/www/html/wp-content/uploads:ro
|
||||||
|
- plugins:/var/www/html/wp-content/plugins:ro
|
||||||
|
- themes:/var/www/html/wp-content/themes:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db:
|
||||||
|
config:
|
||||||
|
core:
|
||||||
|
uploads:
|
||||||
|
plugins:
|
||||||
|
themes:
|
||||||
164
docker/entrypoint.sh
Normal file
164
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Prepare the site, then hand over to PHP-FPM.
|
||||||
|
#
|
||||||
|
# Three jobs, all of which have to survive an image upgrade without destroying
|
||||||
|
# anything the site owner did:
|
||||||
|
#
|
||||||
|
# 1. WordPress core and the fork's own plugins are copied out of the image
|
||||||
|
# into the document root on every start, so rebuilding the image is a
|
||||||
|
# real upgrade. Plugins and themes installed from the dashboard live in
|
||||||
|
# their own volumes and are left alone.
|
||||||
|
#
|
||||||
|
# 2. wp-config.php is generated once, into a volume, and copied into place
|
||||||
|
# each start. Never regenerated — new salts would end every session, and
|
||||||
|
# a new SIRIUS_PRESS_KEY would make the stored publishing phrase
|
||||||
|
# undecryptable, which looks exactly like a corrupted wallet.
|
||||||
|
#
|
||||||
|
# 3. Nothing waits on the network except the database, and that with a
|
||||||
|
# bounded timeout: a site that will not start is worse than one that
|
||||||
|
# starts and says the database is unreachable.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
DOCROOT=/var/www/html
|
||||||
|
SOURCE=/opt/sirius-press/core
|
||||||
|
CONFIG_STORE=/var/www/config/wp-config.php
|
||||||
|
CONFIG=$DOCROOT/wp-config.php
|
||||||
|
|
||||||
|
random_key() {
|
||||||
|
# 64 characters of printable entropy. `tr -dc` over /dev/urandom is the one
|
||||||
|
# method available in a slim image without adding tools.
|
||||||
|
tr -dc 'A-Za-z0-9!@#%^&*()_+=-' < /dev/urandom | head -c 64
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_db() {
|
||||||
|
# Compose's healthcheck covers the container, but a first run also has to
|
||||||
|
# wait out MariaDB's own initialisation.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 60 ]; do
|
||||||
|
if php -r 'exit(@mysqli_connect(getenv("WORDPRESS_DB_HOST"), getenv("WORDPRESS_DB_USER"), getenv("WORDPRESS_DB_PASSWORD")) ? 0 : 1);' 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "sirius-press: the database did not answer within 60s; starting anyway" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- core
|
||||||
|
|
||||||
|
# Copy everything except wp-content, which has volumes of its own, then put
|
||||||
|
# back the directories the fork owns. Anything the site owner installed from
|
||||||
|
# the dashboard sits in those same volumes and is never touched.
|
||||||
|
echo "sirius-press: syncing WordPress $(cat "$SOURCE/.sirius-core-version" 2>/dev/null || echo '?') into the document root"
|
||||||
|
|
||||||
|
for entry in "$SOURCE"/*; do
|
||||||
|
name="$(basename "$entry")"
|
||||||
|
if [ "$name" = "wp-content" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -rf "${DOCROOT:?}/${name}"
|
||||||
|
cp -R "$entry" "$DOCROOT/"
|
||||||
|
done
|
||||||
|
cp "$SOURCE/.sirius-core-version" "$DOCROOT/.sirius-core-version" 2>/dev/null || true
|
||||||
|
|
||||||
|
mkdir -p "$DOCROOT/wp-content/plugins" "$DOCROOT/wp-content/themes" "$DOCROOT/wp-content/mu-plugins" "$DOCROOT/wp-content/uploads"
|
||||||
|
|
||||||
|
# Seed bundled themes only when the themes volume is empty — replacing them on
|
||||||
|
# every start would undo a site owner's edits to a bundled theme.
|
||||||
|
if [ -z "$(ls -A "$DOCROOT/wp-content/themes" 2>/dev/null)" ]; then
|
||||||
|
cp -R "$SOURCE/wp-content/themes/." "$DOCROOT/wp-content/themes/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The fork's own plugins are the image's to own, and are replaced outright.
|
||||||
|
for dir in "$SOURCE"/wp-content/plugins/sirius-press-*/; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
name="$(basename "$dir")"
|
||||||
|
rm -rf "${DOCROOT:?}/wp-content/plugins/${name}"
|
||||||
|
cp -R "$dir" "$DOCROOT/wp-content/plugins/"
|
||||||
|
done
|
||||||
|
cp "$SOURCE"/wp-content/mu-plugins/*.php "$DOCROOT/wp-content/mu-plugins/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Everything else WordPress ships in wp-content (akismet, the index.php
|
||||||
|
# guards) only needs to exist once.
|
||||||
|
for extra in akismet index.php; do
|
||||||
|
if [ ! -e "$DOCROOT/wp-content/plugins/$extra" ] && [ -e "$SOURCE/wp-content/plugins/$extra" ]; then
|
||||||
|
cp -R "$SOURCE/wp-content/plugins/$extra" "$DOCROOT/wp-content/plugins/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
chown -R www-data:www-data "$DOCROOT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- wp-config
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_STORE" ]; then
|
||||||
|
echo "sirius-press: generating wp-config.php"
|
||||||
|
|
||||||
|
: "${WORDPRESS_DB_HOST:=db}"
|
||||||
|
: "${WORDPRESS_DB_NAME:=wordpress}"
|
||||||
|
: "${WORDPRESS_DB_USER:=wordpress}"
|
||||||
|
: "${WORDPRESS_DB_PASSWORD:=wordpress}"
|
||||||
|
: "${WORDPRESS_TABLE_PREFIX:=wp_}"
|
||||||
|
if [ -z "${SIRIUS_PRESS_KEY:-}" ]; then
|
||||||
|
SIRIUS_PRESS_KEY="$(random_key)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$CONFIG_STORE")"
|
||||||
|
{
|
||||||
|
echo "<?php"
|
||||||
|
echo "// Generated by the Sirius Press container on first start."
|
||||||
|
echo "// Edit freely — this file is kept in a volume and never rewritten."
|
||||||
|
echo
|
||||||
|
echo "define( 'DB_NAME', '${WORDPRESS_DB_NAME}' );"
|
||||||
|
echo "define( 'DB_USER', '${WORDPRESS_DB_USER}' );"
|
||||||
|
echo "define( 'DB_PASSWORD', '${WORDPRESS_DB_PASSWORD}' );"
|
||||||
|
echo "define( 'DB_HOST', '${WORDPRESS_DB_HOST}' );"
|
||||||
|
echo "define( 'DB_CHARSET', 'utf8mb4' );"
|
||||||
|
echo "define( 'DB_COLLATE', '' );"
|
||||||
|
echo
|
||||||
|
for k in AUTH_KEY SECURE_AUTH_KEY LOGGED_IN_KEY NONCE_KEY \
|
||||||
|
AUTH_SALT SECURE_AUTH_SALT LOGGED_IN_SALT NONCE_SALT; do
|
||||||
|
echo "define( '${k}', '$(random_key)' );"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "// The key the publishing phrase is encrypted with. Separate from"
|
||||||
|
echo "// the auth salts so rotating sessions does not orphan the stored"
|
||||||
|
echo "// key. Lose this and the phrase must be entered again."
|
||||||
|
echo "define( 'SIRIUS_PRESS_KEY', '${SIRIUS_PRESS_KEY}' );"
|
||||||
|
echo
|
||||||
|
echo "// nginx terminates TLS in front of this container, so PHP has to"
|
||||||
|
echo "// be told. Without it WordPress builds http:// URLs and a"
|
||||||
|
echo "// redirect loop follows."
|
||||||
|
echo "if ( isset( \$_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === \$_SERVER['HTTP_X_FORWARDED_PROTO'] ) {"
|
||||||
|
echo " \$_SERVER['HTTPS'] = 'on';"
|
||||||
|
echo "}"
|
||||||
|
echo "define( 'SIRIUS_PRESS_TRUST_PROXY', true );"
|
||||||
|
echo
|
||||||
|
if [ -n "${WORDPRESS_SITE_URL:-}" ]; then
|
||||||
|
echo "define( 'WP_HOME', '${WORDPRESS_SITE_URL}' );"
|
||||||
|
echo "define( 'WP_SITEURL', '${WORDPRESS_SITE_URL}' );"
|
||||||
|
fi
|
||||||
|
echo "define( 'FS_METHOD', 'direct' );"
|
||||||
|
echo "define( 'WP_DEBUG', ${WORDPRESS_DEBUG:-false} );"
|
||||||
|
echo
|
||||||
|
echo "\$table_prefix = '${WORDPRESS_TABLE_PREFIX}';"
|
||||||
|
echo
|
||||||
|
echo "if ( ! defined( 'ABSPATH' ) ) {"
|
||||||
|
echo " define( 'ABSPATH', __DIR__ . '/' );"
|
||||||
|
echo "}"
|
||||||
|
echo "require_once ABSPATH . 'wp-settings.php';"
|
||||||
|
} > "$CONFIG_STORE"
|
||||||
|
chmod 640 "$CONFIG_STORE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$CONFIG_STORE" "$CONFIG"
|
||||||
|
chown www-data:www-data "$CONFIG"
|
||||||
|
chmod 640 "$CONFIG"
|
||||||
|
|
||||||
|
|
||||||
|
if [ "${1:-}" = "php-fpm" ]; then
|
||||||
|
wait_for_db || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
84
docker/nginx/default.conf
Normal file
84
docker/nginx/default.conf
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# nginx in front of Sirius Press.
|
||||||
|
#
|
||||||
|
# Static files are served here; only PHP is proxied. That matters more than
|
||||||
|
# usual for this fork: the wallet library and the login script are plain files,
|
||||||
|
# and routing them through PHP-FPM would put a process on the critical path of
|
||||||
|
# every sign-in for no reason.
|
||||||
|
#
|
||||||
|
# TLS is not configured here. Most people putting this on a VPS already have a
|
||||||
|
# reverse proxy, Caddy, or a Cloudflare tunnel in front; baking half a
|
||||||
|
# certificate story into this file would fight all three. docs/install.md
|
||||||
|
# covers the two common arrangements.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/html;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
client_max_body_size 64m;
|
||||||
|
|
||||||
|
# WordPress hands its own version out in a header; there is no reason to
|
||||||
|
# advertise nginx's too.
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# --- things that should never be reachable -------------------------------
|
||||||
|
|
||||||
|
# wp-config.php holds the database password and SIRIUS_PRESS_KEY, which is
|
||||||
|
# what the stored publishing phrase is encrypted under. A misconfiguration
|
||||||
|
# that serves it as text hands over the site's wallet.
|
||||||
|
location = /wp-config.php { deny all; }
|
||||||
|
location ~* /wp-config.*\.php$ { deny all; }
|
||||||
|
location ~ /\.(?!well-known) { deny all; }
|
||||||
|
location = /xmlrpc.php { deny all; }
|
||||||
|
location ~* /(?:uploads|files)/.*\.php$ { deny all; }
|
||||||
|
location ~* ^/wp-content/.*\.(?:sql|log|bak|swp)$ { deny all; }
|
||||||
|
|
||||||
|
# --- routing -------------------------------------------------------------
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass app:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param HTTPS $http_x_forwarded_proto if_not_empty;
|
||||||
|
|
||||||
|
# Signature verification on a BCMath host takes a few hundred
|
||||||
|
# milliseconds, and a full static export batch takes longer. The
|
||||||
|
# default 60s would turn a slow first login into a 504.
|
||||||
|
fastcgi_read_timeout 300;
|
||||||
|
fastcgi_buffers 16 16k;
|
||||||
|
fastcgi_buffer_size 32k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- caching -------------------------------------------------------------
|
||||||
|
|
||||||
|
location ~* \.(?:css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$ {
|
||||||
|
expires 30d;
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The login screen must never be cached anywhere: the challenge it carries
|
||||||
|
# is single-use, and a cached copy would hand every visitor a nonce that
|
||||||
|
# has already been spent.
|
||||||
|
location = /wp-login.php {
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass app:9000;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
fastcgi_param HTTPS $http_x_forwarded_proto if_not_empty;
|
||||||
|
fastcgi_read_timeout 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
178
docs/accounts.md
Normal file
178
docs/accounts.md
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
# Accounts, sign-in and recovery
|
||||||
|
|
||||||
|
## What an account is
|
||||||
|
|
||||||
|
A CashAddress, and a WordPress user row attached to it.
|
||||||
|
|
||||||
|
The address lives in user meta, not in a new column on `wp_users`. That is a
|
||||||
|
deliberate departure from the original design sketch, which proposed replacing
|
||||||
|
`user_email` with a `wallet_addr` column: a schema change to a core table makes
|
||||||
|
every future upstream merge and every `dbDelta()` run a negotiation, and it
|
||||||
|
buys nothing here. Meta lookups are indexed, and a plugin reading
|
||||||
|
`$user->user_email` keeps getting a string instead of crashing.
|
||||||
|
|
||||||
|
Everything else about the user is ordinary WordPress. Roles, capabilities,
|
||||||
|
nonces, sessions, the REST API — none of it knows the login was different.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signing in
|
||||||
|
|
||||||
|
The login page issues a challenge: a few lines of readable text containing the
|
||||||
|
site, the purpose, a nonce and a timestamp. You sign it; the server recovers
|
||||||
|
the public key from the signature, derives the address, and finds the account.
|
||||||
|
|
||||||
|
You never type your address. It comes out of the signature.
|
||||||
|
|
||||||
|
### The three ways to sign
|
||||||
|
|
||||||
|
**A wallet the browser already has.** Theseus exposes `window.bitcoincash` on
|
||||||
|
`.x` origins. The button appears when it is there. The key stays in the
|
||||||
|
browser's wallet and never touches the page.
|
||||||
|
|
||||||
|
**A phrase typed into the form.** Derived in the page, used once, wiped. The
|
||||||
|
phrase is never transmitted — only the signature is. This is the path most
|
||||||
|
people will use, and it is the one worth being clear-eyed about: you are typing
|
||||||
|
a recovery phrase into a web page. It is the same trust you place in the Sirius
|
||||||
|
portal, and less than you place in a browser extension, but it is not nothing.
|
||||||
|
|
||||||
|
**A signature made somewhere else.** The challenge is a BIP-137 message — the
|
||||||
|
format Electron Cash's "Sign message" box has produced for a decade. Copy the
|
||||||
|
text out, sign it on a machine that never touches this site, paste the result
|
||||||
|
back. **This path needs no JavaScript at all**, which is why the paste box is
|
||||||
|
in the markup from the start and the buttons are revealed by script rather than
|
||||||
|
the other way round.
|
||||||
|
|
||||||
|
### The challenge
|
||||||
|
|
||||||
|
```
|
||||||
|
Sign in to Example Site
|
||||||
|
|
||||||
|
Site: https://example.bch/
|
||||||
|
Purpose: sign in
|
||||||
|
Nonce: 1758412800000.3f9ab2c1d4e5f607.9a1b…
|
||||||
|
Issued: 2026-09-21T10:00:00Z
|
||||||
|
|
||||||
|
Signing this proves you control this wallet. It moves no coins.
|
||||||
|
```
|
||||||
|
|
||||||
|
Written to be readable in a wallet's approval dialog, because that dialog is
|
||||||
|
the only place a user gets to check what they are agreeing to. Anything a
|
||||||
|
wallet renders as a wall of hex is a security control the user cannot exercise.
|
||||||
|
|
||||||
|
Issuing one 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. Only a *successful* verification writes — a short-lived marker
|
||||||
|
that burns the nonce so the signature cannot be replayed.
|
||||||
|
|
||||||
|
The purpose is inside the signed bytes, which is what stops a login signature
|
||||||
|
being replayed to create an account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registration
|
||||||
|
|
||||||
|
One step. Sign the challenge, optionally pick a username, done — there is no
|
||||||
|
confirmation link because the signature *is* the confirmation.
|
||||||
|
|
||||||
|
No pending state, no expiring link, no way for a typo to strand an account.
|
||||||
|
|
||||||
|
Turn it on or off at **Sirius Press → Sign-in**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recovery, and the absence of it
|
||||||
|
|
||||||
|
**If you lose your phrase, the account is gone.** Nobody can reset it, and
|
||||||
|
nothing in the software is trying to.
|
||||||
|
|
||||||
|
This is not an oversight to be worked around later. A reset mechanism is, by
|
||||||
|
construction, a way to take an account from its owner — and every such
|
||||||
|
mechanism ends up being the thing attacked, because it is easier than the
|
||||||
|
cryptography it bypasses. Password reset by email is exactly that: a promise
|
||||||
|
that whoever controls a mailbox controls the account.
|
||||||
|
|
||||||
|
The fork's "lost password" page says this to your users in those terms, and
|
||||||
|
offers the two things that genuinely help: if they still have a phrase, they
|
||||||
|
are not locked out at all and should just sign in; and if they have several
|
||||||
|
phrases and do not know which is which, the page derives the address from a
|
||||||
|
phrase in the browser so they can check.
|
||||||
|
|
||||||
|
### The one route back
|
||||||
|
|
||||||
|
An administrator can point an account at a different address from **Users →
|
||||||
|
Edit**.
|
||||||
|
|
||||||
|
That is intentionally a human decision about a person somebody recognises,
|
||||||
|
rather than a capability an attacker triggers. It also means an administrator
|
||||||
|
can take any account on the site, which is true of WordPress administrators
|
||||||
|
generally and is worth knowing rather than discovering.
|
||||||
|
|
||||||
|
If nobody has an administrator account any more, nothing in the software can
|
||||||
|
help — but the database can. Attach a wallet to user 1 directly:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
|
||||||
|
VALUES (1, 'sirius_wallet_address', 'bitcoincash:qq…')
|
||||||
|
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with WP-CLI, which handles the prefix for you:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wp user meta update 1 sirius_wallet_address 'bitcoincash:qq…'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Passwords
|
||||||
|
|
||||||
|
Left on by default, and the reason is unglamorous: a fresh install has exactly
|
||||||
|
one account, created by the installer, with no wallet attached yet. Shipping
|
||||||
|
with passwords off would mean the first thing a new site does is lock out its
|
||||||
|
own administrator.
|
||||||
|
|
||||||
|
Turn them off at **Sirius Press → Sign-in** once every account that needs
|
||||||
|
access has a wallet. That screen refuses to do it until *your* account has one,
|
||||||
|
and lists every account that would be stranded.
|
||||||
|
|
||||||
|
With passwords off, core's password checks are removed from the `authenticate`
|
||||||
|
chain entirely rather than left to run and fail — a site that has turned
|
||||||
|
passwords off should not still have a password oracle on its login page.
|
||||||
|
|
||||||
|
Application passwords and WP-CLI are unaffected either way. They do not go
|
||||||
|
through the interactive login path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Asking for a fresh signature
|
||||||
|
|
||||||
|
A capability check proves what a session is allowed to do. It cannot prove the
|
||||||
|
person holding the key is still at the keyboard. For something irreversible —
|
||||||
|
transferring a name, moving money, deleting a site — a plugin can demand a new
|
||||||
|
signature:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$challenge = sirius_press_confirmation_challenge();
|
||||||
|
// Render $challenge['message'] for the user to sign, then POST
|
||||||
|
// { nonce, signature } to /wp-json/sirius-press/v1/confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
It returns 403 if the signature belongs to a different wallet than the one
|
||||||
|
signed in, so a caller that forgets to check the response body still fails
|
||||||
|
closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The REST surface
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/sirius-press/v1/challenge` | GET | none | Hands out something to sign. `?purpose=login\|register\|link\|confirm` |
|
||||||
|
| `/sirius-press/v1/login` | POST | none | `{nonce, signature}` → sets a session cookie |
|
||||||
|
| `/sirius-press/v1/register` | POST | none | `{nonce, signature, user_login?}` → creates an account |
|
||||||
|
| `/sirius-press/v1/confirm` | POST | logged in | `{nonce, signature}` → re-proves the current user's key |
|
||||||
|
|
||||||
|
Everything that does curve maths is rate limited per client, because signature
|
||||||
|
recovery is the most expensive thing a stranger can make the server do — a few
|
||||||
|
hundred milliseconds of CPU each on a BCMath host.
|
||||||
104
docs/bcnr-records.md
Normal file
104
docs/bcnr-records.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Pointing your name at your site
|
||||||
|
|
||||||
|
A BCNR name carries a small set of records on-chain. Two of them say "the site
|
||||||
|
for this name is served from here":
|
||||||
|
|
||||||
|
| Record | Holds | Use it when |
|
||||||
|
|---|---|---|
|
||||||
|
| `ip` | An IPv4 or IPv6 address | You have a server with a fixed address — the normal case for a VPS |
|
||||||
|
| `p` | A hostname | Something else already resolves to your server: a DNS name, a tunnel endpoint, a load balancer |
|
||||||
|
|
||||||
|
Either one makes a BCNR resolver — Theseus, Ariadne, or the public gateway —
|
||||||
|
send visitors to your Sirius Press instance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting the record
|
||||||
|
|
||||||
|
From the Sirius portal at [sirius.x](https://sirius.x): open your name, go to
|
||||||
|
the DNS tab, add the record, and sign. The change costs nothing and takes
|
||||||
|
effect as soon as the manifest is published, because records live in a
|
||||||
|
wallet-signed manifest rather than in a chain transaction.
|
||||||
|
|
||||||
|
You can also set them from the registrar CLI if you run one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Which one to use
|
||||||
|
|
||||||
|
**`ip`, if you can.** It is one fewer system between a reader and your site,
|
||||||
|
and it does not depend on anyone's DNS continuing to answer — which is most of
|
||||||
|
the point of having a BCNR name in the first place.
|
||||||
|
|
||||||
|
**`p`, if your address moves**, or if you are behind a tunnel, or if you want
|
||||||
|
a CDN in front. The tradeoff is real: a `p` record points at a name in somebody
|
||||||
|
else's namespace, and whoever controls that namespace can redirect your
|
||||||
|
visitors. If you are using BCNR to get out from under ICANN, pointing your name
|
||||||
|
back into it is worth doing deliberately rather than by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Telling WordPress its own address
|
||||||
|
|
||||||
|
WordPress builds absolute URLs from a value it stores, and it has to match what
|
||||||
|
visitors actually type or you get redirect loops.
|
||||||
|
|
||||||
|
In Docker, set `SITE_URL` in `docker/.env` and restart:
|
||||||
|
|
||||||
|
```
|
||||||
|
SITE_URL=https://example.bch
|
||||||
|
```
|
||||||
|
|
||||||
|
Elsewhere, set it in `wp-config.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define( 'WP_HOME', 'https://example.bch' );
|
||||||
|
define( 'WP_SITEURL', 'https://example.bch' );
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this once your name resolves, not before — WordPress will happily guess from
|
||||||
|
the request until then, which is what you want during setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The static mirror is separate
|
||||||
|
|
||||||
|
Setting `ip` or `p` points readers at your **live WordPress**. It has nothing
|
||||||
|
to do with the static export.
|
||||||
|
|
||||||
|
The export goes to your name's storage on Sia, at `bns/<name>/`, and resolvers
|
||||||
|
reach it through the name's `s3` record. That means a name can have both:
|
||||||
|
|
||||||
|
- an `ip` record, so visitors get the live site with its comments and search
|
||||||
|
- a populated bucket, so that when the server is down, or slow, or gone, the
|
||||||
|
last published copy still answers
|
||||||
|
|
||||||
|
Which one a given resolver prefers is its business, and different resolvers
|
||||||
|
choose differently. The useful way to think about it: the live site is where
|
||||||
|
your site is, and the static mirror is what survives if it stops being there.
|
||||||
|
|
||||||
|
If you want *only* the static copy served — no PHP exposed to the public at all
|
||||||
|
— leave `ip` and `p` unset and let the `s3` record do the work. Your WordPress
|
||||||
|
then runs somewhere private, as an authoring tool, and publishes outward. For
|
||||||
|
a site that is mostly reading rather than interaction, this is the strongest
|
||||||
|
arrangement available, and it is what `silentmode.st` itself does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://navigate.st/api/dns/example.bch | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
shows the verified records a resolver sees, and
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://navigate.st/api/site/example.bch | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
lists what is actually in the name's bucket — which is the quickest way to
|
||||||
|
confirm an export arrived.
|
||||||
|
|
||||||
|
The **Sirius Press → Publishing** screen shows the same thing from inside
|
||||||
|
wp-admin, along with whatever the queue is still working through.
|
||||||
169
docs/install.md
Normal file
169
docs/install.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Installing Sirius Press
|
||||||
|
|
||||||
|
Three ways in, depending on what you have. All of them end at the same place:
|
||||||
|
a WordPress site where accounts are wallets and every page is mirrored to your
|
||||||
|
BCNR name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A VPS, with Docker (recommended)
|
||||||
|
|
||||||
|
On a fresh Ubuntu 22.04 or 24.04 box:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://silentmode.st/sirius-press/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
It installs Docker from the distribution's own packages if it is missing,
|
||||||
|
clones the repository to `/opt/sirius-press`, generates database passwords and
|
||||||
|
a `SIRIUS_PRESS_KEY`, builds the image and starts the stack. Then it waits
|
||||||
|
until the site answers and prints the setup URL.
|
||||||
|
|
||||||
|
About five minutes on a 1 GB VPS, nearly all of it compiling PHP extensions.
|
||||||
|
|
||||||
|
If you would rather read the script before running it as root — a reasonable
|
||||||
|
instinct — it is [`install.sh`](../install.sh), and it is about a hundred lines.
|
||||||
|
|
||||||
|
### By hand, if you prefer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://code.silentmode.st/silentmode/sirius-press.git /opt/sirius-press
|
||||||
|
cd /opt/sirius-press/docker
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env: set DB_PASSWORD and DB_ROOT_PASSWORD at minimum
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finishing setup
|
||||||
|
|
||||||
|
Open `http://your-server/wp-admin/install.php`.
|
||||||
|
|
||||||
|
The screen is the usual WordPress one with a single change: where it asked for
|
||||||
|
your email, it asks for your **wallet address**. You can paste one or leave it
|
||||||
|
blank and attach a wallet afterwards from your profile — which is actually the
|
||||||
|
better order, because the profile screen makes you *prove* the address by
|
||||||
|
signing, and the installer cannot.
|
||||||
|
|
||||||
|
Set a password too. It is the only way into the site until a wallet is
|
||||||
|
attached, and you should turn it off afterwards (**Sirius Press → Sign-in**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared hosting
|
||||||
|
|
||||||
|
Download `sirius-press-<version>.zip` from
|
||||||
|
[the releases page](https://code.silentmode.st/silentmode/sirius-press/releases),
|
||||||
|
check it against the published `.sha256`, and upload its contents the way you
|
||||||
|
would upload WordPress — because that is what it is, patched, with the plugins
|
||||||
|
already in place.
|
||||||
|
|
||||||
|
Then create a database and run through `wp-admin/install.php`.
|
||||||
|
|
||||||
|
Before you start, confirm the host gives you **GMP or BCMath**. Without one of
|
||||||
|
them the fork cannot verify a signature and refuses to activate. Most hosts
|
||||||
|
have BCMath; ask for GMP if you can get it, since it is roughly twenty times
|
||||||
|
faster and signature verification sits on the login path.
|
||||||
|
|
||||||
|
### Building the zip yourself
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/build.sh --zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Downloads the pinned WordPress, verifies its SHA-256, applies `patches/`, adds
|
||||||
|
the plugins, and writes `dist/sirius-press-<version>.zip` alongside its
|
||||||
|
checksum.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## An existing WordPress site
|
||||||
|
|
||||||
|
Sirius Press can be added to a site you already run, without the core patch.
|
||||||
|
You lose only the setup-wizard change, which you have already been through.
|
||||||
|
|
||||||
|
1. Copy the four directories in `plugins/` into `wp-content/plugins/`.
|
||||||
|
2. Copy `mu-plugins/sirius-press-bootstrap.php` into `wp-content/mu-plugins/`
|
||||||
|
(create the directory if it does not exist).
|
||||||
|
3. Activate **Sirius Press Core** first, then Auth, Publishing and
|
||||||
|
Compatibility.
|
||||||
|
4. Attach a wallet to your own account at **Users → Profile**.
|
||||||
|
5. Turn passwords off at **Sirius Press → Sign-in** — but read the list of
|
||||||
|
accounts without wallets on that screen first. Every one of them is somebody
|
||||||
|
who will be locked out.
|
||||||
|
|
||||||
|
Existing accounts keep working with passwords until each attaches a wallet.
|
||||||
|
Nothing is migrated, deleted or rewritten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pointing your name at it
|
||||||
|
|
||||||
|
See **[bcnr-records.md](bcnr-records.md)**. The short version: set your name's
|
||||||
|
`ip` record to the server's address, or its `p` record to a hostname that
|
||||||
|
resolves there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After it is up
|
||||||
|
|
||||||
|
Three things, in this order:
|
||||||
|
|
||||||
|
1. **Sirius Press → Settings**: set the BCNR name this site publishes under,
|
||||||
|
and the network (mainnet or chipnet).
|
||||||
|
2. **Users → Profile**: attach your wallet by signing. Check the address that
|
||||||
|
appears is the one you expect.
|
||||||
|
3. **Sirius Press → Sign-in**: turn off password sign-in once every account
|
||||||
|
that needs access has a wallet.
|
||||||
|
|
||||||
|
Then decide how exports get signed — see [publishing.md](publishing.md). The
|
||||||
|
default is manual, meaning nothing sensitive is stored on the server and you
|
||||||
|
sign from your browser when you publish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/sirius-press
|
||||||
|
git pull
|
||||||
|
cd docker && docker compose build && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Core, the fork's plugins and the patch series all come from the image, so this
|
||||||
|
upgrades all three. Your uploads, your database, your other plugins and themes,
|
||||||
|
and `wp-config.php` live in volumes and are untouched.
|
||||||
|
|
||||||
|
This is why the document root is refilled from the image on every start rather
|
||||||
|
than being a volume of its own — the usual WordPress-in-Docker layout pins core
|
||||||
|
at whatever version first created the volume, and turns every security release
|
||||||
|
into a manual migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When it does not work
|
||||||
|
|
||||||
|
**The site does not answer.**
|
||||||
|
`cd /opt/sirius-press/docker && docker compose logs -f app`. The most common
|
||||||
|
cause on a first run is the database still initialising; the entrypoint waits
|
||||||
|
sixty seconds and then starts anyway, so give it another minute.
|
||||||
|
|
||||||
|
**"Sirius Press needs either the GMP or the BCMath PHP extension."**
|
||||||
|
The host has neither. Nothing in the fork works without one. On Debian or
|
||||||
|
Ubuntu outside Docker: `apt install php-gmp && systemctl restart php-fpm`.
|
||||||
|
|
||||||
|
**Signing in does nothing, and no error appears.**
|
||||||
|
JavaScript did not load, or the page is being served over plain HTTP where
|
||||||
|
WebCrypto is unavailable. Open "Paste a signature instead" and use any wallet —
|
||||||
|
that path needs no JavaScript at all.
|
||||||
|
|
||||||
|
**"That signature does not match the text this site asked you to sign."**
|
||||||
|
Usually the challenge was copied without its trailing blank line, or with a
|
||||||
|
line wrapped. Copy it straight out of the box with the copy button rather than
|
||||||
|
selecting it by hand.
|
||||||
|
|
||||||
|
**Exports fail with "the stored publishing key is not the address that
|
||||||
|
currently owns this name."**
|
||||||
|
The phrase on the server derives a different address than the one holding the
|
||||||
|
name's NFT. **Sirius Press → Settings** shows both side by side. The usual
|
||||||
|
cause is a wallet created on a different derivation path — see
|
||||||
|
[publishing.md](publishing.md#derivation-paths).
|
||||||
104
docs/plugin-compatibility.md
Normal file
104
docs/plugin-compatibility.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# Plugin compatibility
|
||||||
|
|
||||||
|
The short answer: plugins work. Sirius Press changes how people sign in, not
|
||||||
|
how WordPress works. Roles, capabilities, nonces, the options API, the REST
|
||||||
|
API, the hook system and `wp_mail()` are all untouched, so a plugin that does
|
||||||
|
not ask "what is this user's email address?" cannot tell the difference.
|
||||||
|
|
||||||
|
The rest of this page is the long answer, for the plugins that do ask.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The mechanism
|
||||||
|
|
||||||
|
Every account has a `user_email`. It is a placeholder under a domain ending in
|
||||||
|
`.invalid` — a top-level domain [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606)
|
||||||
|
reserves precisely so that it can never resolve anywhere, ever.
|
||||||
|
|
||||||
|
This matters more than it sounds. `$user->user_email` is read in thousands of
|
||||||
|
places across the ecosystem, usually without a null check. Leaving it empty
|
||||||
|
would produce warnings, blank "From" headers and outright fatals in other
|
||||||
|
people's code. Filling it with something permanently undeliverable means every
|
||||||
|
one of those reads returns a string and behaves normally.
|
||||||
|
|
||||||
|
Mail addressed to one of those placeholders never reaches a mail transport. It
|
||||||
|
is caught by `pre_wp_mail` and delivered to the **Sirius Press inbox**, where
|
||||||
|
the recipient reads it while signed in. That is where password-reset mails,
|
||||||
|
comment-moderation notices, update nags and "your plugin needs attention"
|
||||||
|
messages end up.
|
||||||
|
|
||||||
|
Mail addressed to a real domain is passed straight through. If you configured
|
||||||
|
SMTP, it sends. If you did not, it fails exactly as stock WordPress fails.
|
||||||
|
|
||||||
|
`is_email()` is left alone, except for one narrow case: addresses under **this
|
||||||
|
site's own** placeholder domain are accepted, because a setup wizard that
|
||||||
|
validates its own default and refuses to advance is a wizard you cannot get
|
||||||
|
past. Every other `.invalid` address is still correctly rejected — a contact
|
||||||
|
form that silently accepted `someone@example.invalid` from a visitor would be
|
||||||
|
collecting addresses nobody can ever reply to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The top ten
|
||||||
|
|
||||||
|
**Status: predicted, not yet verified.** The table below is derived from
|
||||||
|
reading each plugin's setup and activation paths against what this fork
|
||||||
|
changes — not from installing them. Sirius Press 0.1.0 has not been run
|
||||||
|
against a live plugin set yet, and until it has, treat every row as a claim
|
||||||
|
awaiting a test rather than a result.
|
||||||
|
|
||||||
|
The rows are written to be falsifiable: each says what should happen, so a
|
||||||
|
first real run either confirms it or produces a bug report. If you install one
|
||||||
|
of these and it behaves differently, that is worth reporting — the shim it
|
||||||
|
needs is usually three lines.
|
||||||
|
|
||||||
|
| Plugin | Installs | Activates | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Yoast SEO** | yes | yes | Works unchanged. Its XML sitemaps export to your name with everything else. Reads `admin_email` for schema output; gets the placeholder. |
|
||||||
|
| **Contact Form 7** | yes | yes | Forms build, save and submit. The default recipient is the placeholder, so submissions land in the inbox — fine for a small site, not what you want for a real contact form. Set a real address and configure SMTP. |
|
||||||
|
| **WooCommerce** | yes | yes | Store, products and checkout all work. Order emails to customers use the address the customer typed, so they send once SMTP is configured. The setup wizard's store-address field is pre-filled with a placeholder so it will advance. |
|
||||||
|
| **Elementor** | yes | yes | No email dependency. Pages built with it export normally. |
|
||||||
|
| **Wordfence** | yes | yes | Alert emails go to the inbox unless you set a real address. Its login-security features overlap with wallet auth; two-factor on top of a signature is redundant, and its "email me a code" option cannot work. |
|
||||||
|
| **WP Super Cache / W3 Total Cache** | yes | yes | Compatible, but consider whether you need them: the static export already serves cached HTML from the name, which is the harder-working cache. |
|
||||||
|
| **Akismet** | yes | yes | Needs an API key, which is obtained on akismet.com with a real address of yours. Nothing to do with site accounts. |
|
||||||
|
| **Jetpack** | yes | partly | Connection requires a WordPress.com account and works, but the modules built around subscriber email lists cannot do anything useful here. Not recommended. |
|
||||||
|
| **UpdraftPlus** | yes | yes | Backups work. Report emails go to the inbox. Back up `wp-config.php` too, or you lose `SIRIUS_PRESS_KEY` and with it the stored publishing phrase. |
|
||||||
|
| **Advanced Custom Fields** | yes | yes | No email dependency at all. |
|
||||||
|
|
||||||
|
"Activates" means the expectation is: no fatal, no failed setup wizard, and
|
||||||
|
the plugin's own status screen reporting itself healthy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What genuinely cannot work
|
||||||
|
|
||||||
|
Not because of a shim we did not write, but because the feature is the mailbox:
|
||||||
|
|
||||||
|
- **Email-based two-factor.** There is no mailbox to send a code to.
|
||||||
|
- **Newsletter plugins managing your site's own accounts.** A newsletter to a
|
||||||
|
list of real addresses people typed into a form works fine. A newsletter to
|
||||||
|
"all subscribers" does not, because subscribers have wallets, not mailboxes.
|
||||||
|
- **"Email me when someone comments" for account holders.** Goes to the inbox
|
||||||
|
instead, which the person sees next time they sign in.
|
||||||
|
- **Password reset by email.** Deliberately removed. See
|
||||||
|
[accounts.md](accounts.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Making a plugin behave
|
||||||
|
|
||||||
|
If a plugin refuses to finish its setup because of a missing address, the usual
|
||||||
|
fix is one filter:
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_filter( 'pre_option_their_email_setting', function ( $value ) {
|
||||||
|
return $value ?: 'admin@' . SP_Settings::stub_email_domain();
|
||||||
|
} );
|
||||||
|
```
|
||||||
|
|
||||||
|
`sirius-press-compat` is a short file of exactly these. If you write one for a
|
||||||
|
plugin not listed here, it is worth sending — the whole file is under a hundred
|
||||||
|
lines of real code and it grows one plugin at a time.
|
||||||
|
|
||||||
|
If a plugin sends mail to a real address and it does not arrive, that is an
|
||||||
|
SMTP problem rather than a Sirius Press one: see [smtp.md](smtp.md).
|
||||||
185
docs/publishing.md
Normal file
185
docs/publishing.md
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Publishing to your name
|
||||||
|
|
||||||
|
Every page Sirius Press serves can also exist as a static file in your name's
|
||||||
|
storage, signed by the key that owns the name. This is what makes the site
|
||||||
|
outlive its server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What gets published, and when
|
||||||
|
|
||||||
|
Publishing or editing a post queues the pages it affects:
|
||||||
|
|
||||||
|
- the post itself
|
||||||
|
- the home page
|
||||||
|
- the blog page, if you have one
|
||||||
|
- every category, tag or other public archive it belongs to
|
||||||
|
- any asset those pages reference — stylesheets, scripts, images — the first
|
||||||
|
time it is seen
|
||||||
|
|
||||||
|
Unpublishing or deleting a post removes its file and re-queues the pages that
|
||||||
|
linked to it. A static mirror that keeps serving a post the author deleted is
|
||||||
|
worse than no mirror at all, because the author believes it is gone.
|
||||||
|
|
||||||
|
**Export everything** on the Publishing screen queues the whole site: every
|
||||||
|
published post and page, every public archive, the home page.
|
||||||
|
|
||||||
|
Password-protected posts are never exported. To the exporter they render as a
|
||||||
|
password form, and publishing that form to permanent public storage would be a
|
||||||
|
strange thing to do on purpose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Who signs, and where the key lives
|
||||||
|
|
||||||
|
The BNS gateway only accepts a write signed by the name's **current on-chain
|
||||||
|
owner**. That is the whole security model, and it has a consequence there is no
|
||||||
|
way around: whatever signs your uploads holds the key that owns your name and
|
||||||
|
whatever funds sit with it. There is no weaker credential to give a server.
|
||||||
|
|
||||||
|
So the question is not *how much* of your key the server gets. It is whether it
|
||||||
|
gets it at all.
|
||||||
|
|
||||||
|
### Manual — the default
|
||||||
|
|
||||||
|
Nothing is stored. Exports queue up; when you open **Sirius Press → Publishing**
|
||||||
|
you type your recovery phrase, and the page signs each file and uploads it
|
||||||
|
straight from your browser to the gateway. The phrase is cleared from the form
|
||||||
|
as soon as it has been used and the derived key is wiped when the run ends.
|
||||||
|
|
||||||
|
- Nothing sensitive on the server. A compromised host leaks your posts, which
|
||||||
|
were public anyway.
|
||||||
|
- A post published on a schedule sits in the queue until somebody signs in.
|
||||||
|
|
||||||
|
### Automatic
|
||||||
|
|
||||||
|
Your recovery phrase is encrypted with AES-256-GCM under a key derived from
|
||||||
|
`SIRIUS_PRESS_KEY` in `wp-config.php`, and stored in the database. Cron drains
|
||||||
|
the queue by itself.
|
||||||
|
|
||||||
|
- Scheduled posts publish while you sleep.
|
||||||
|
- Anyone who can read **both** your database and `wp-config.php` has your name
|
||||||
|
and your coins. That is most people who get a shell on the box.
|
||||||
|
|
||||||
|
The encryption protects one specific thing — a stolen database dump, a leaked
|
||||||
|
backup — and the settings screen says exactly that rather than implying more.
|
||||||
|
|
||||||
|
**A reasonable middle path:** run automatic mode with a wallet that holds the
|
||||||
|
name and nothing else. Keep your coins somewhere that has never been typed into
|
||||||
|
a web server. This does not protect the name, but it bounds what a compromise
|
||||||
|
costs to "the attacker can publish to my name until I move it", which is
|
||||||
|
recoverable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Derivation paths
|
||||||
|
|
||||||
|
Sirius Press derives its publishing key at `m/44'/145'/0'/0/0` — BCH coin type,
|
||||||
|
first account, first address. Same as the Sirius portal wallet, same as most
|
||||||
|
BCH wallets.
|
||||||
|
|
||||||
|
If your name was bought with a wallet minted somewhere else, the path may
|
||||||
|
differ. The symptom is unmistakable once you know it: every upload returns 403,
|
||||||
|
and **Sirius Press → Settings** shows two different addresses side by side —
|
||||||
|
the one your phrase derives, and the one the chain says owns the name.
|
||||||
|
|
||||||
|
Change the path in Settings. Operator wallets created from the Silent Mode CLI
|
||||||
|
were derived on the BTC coin type, `m/44'/0'/0'/0/0`.
|
||||||
|
|
||||||
|
The settings screen checks this for you as soon as you save a phrase: it
|
||||||
|
derives the address, asks the gateway who owns the name, and tells you whether
|
||||||
|
they match. A mismatch caught there is a sentence of explanation. The same
|
||||||
|
mismatch caught later is a 403 in a cron log nobody reads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The queue
|
||||||
|
|
||||||
|
Rows are keyed by path, not by post — several posts changing in a minute should
|
||||||
|
not queue the home page five times.
|
||||||
|
|
||||||
|
Each attempt renders the page, hashes the result, and compares it to what was
|
||||||
|
last published at that path. Identical bytes are marked done without an upload,
|
||||||
|
which is the common case: a WordPress page re-renders byte-for-byte far more
|
||||||
|
often than it changes.
|
||||||
|
|
||||||
|
Failures retry up to five times and then stop, and the first exhausted retry
|
||||||
|
puts a message in your inbox — once an hour at most, because a misconfigured
|
||||||
|
key fails on every row and four hundred identical messages are the same as
|
||||||
|
none.
|
||||||
|
|
||||||
|
### From the command line
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wp sirius status # name, key, owner, queue depth
|
||||||
|
wp sirius export --all # queue everything, then push it
|
||||||
|
wp sirius export --limit=50 # work through part of the queue
|
||||||
|
wp sirius export --all --dry-run # build every page, upload nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
`--dry-run` is the fastest way to find a page that renders badly for the
|
||||||
|
exporter without publishing anything.
|
||||||
|
|
||||||
|
The first full export of an existing site is the one job genuinely better in a
|
||||||
|
terminal: thousands of pages, a long run, and no browser tab that must stay
|
||||||
|
open. Note that CLI publishing needs automatic mode, since there is no browser
|
||||||
|
to sign in manual mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the exported pages look like
|
||||||
|
|
||||||
|
Pages are fetched over a loopback HTTP request, not rendered in-process. That
|
||||||
|
costs a round trip and is worth it: a theme's output depends on the whole
|
||||||
|
request lifecycle, and reconstructing it by calling `get_the_content()` gives
|
||||||
|
you something that resembles the page rather than the page.
|
||||||
|
|
||||||
|
Links are rewritten to be **document-relative** — `../../about/index.html`
|
||||||
|
rather than `/about/index.html` or an absolute URL. Absolute links would drag
|
||||||
|
readers back to the origin server the export exists to make optional.
|
||||||
|
Root-relative ones break when the same bucket is browsed under
|
||||||
|
`/bns/<name>/` on the public gateway. Document-relative works in both places.
|
||||||
|
|
||||||
|
Asset URLs lose their `?ver=` query, because the bucket stores one copy of each
|
||||||
|
file and the query would only ever produce a 404.
|
||||||
|
|
||||||
|
### Things that cannot work in a static copy
|
||||||
|
|
||||||
|
Anything needing PHP at read time: the search form, the comment form, a cart, a
|
||||||
|
"related posts" widget that queries at render. They will appear and do nothing.
|
||||||
|
|
||||||
|
If your theme has such a thing, hide it during export:
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_action( 'sirius_press_rendering_export', function () {
|
||||||
|
add_filter( 'get_search_form', '__return_empty_string' );
|
||||||
|
remove_action( 'wp_footer', 'my_theme_live_widget' );
|
||||||
|
} );
|
||||||
|
```
|
||||||
|
|
||||||
|
That action fires on the live request, while the exporter is fetching — which
|
||||||
|
is the right moment, because removing something afterwards means also unpicking
|
||||||
|
the styles and spacing it left behind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Skip a post entirely.
|
||||||
|
add_filter( 'sirius_press_should_export_post', function ( $export, $post ) {
|
||||||
|
return 'private-type' === $post->post_type ? false : $export;
|
||||||
|
}, 10, 2 );
|
||||||
|
|
||||||
|
// Add to, or trim, what a full export covers.
|
||||||
|
add_filter( 'sirius_press_export_targets', function ( $targets ) {
|
||||||
|
$targets['feed/index.xml'] = array( 'url' => home_url( '/feed/' ), 'kind' => 'feed' );
|
||||||
|
return $targets;
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Change which paths a post edit invalidates.
|
||||||
|
add_filter( 'sirius_press_affected_paths', function ( $paths, $post ) { … }, 10, 2 );
|
||||||
|
|
||||||
|
// React to a successful upload.
|
||||||
|
add_action( 'sirius_press_exported', function ( $path, $bytes ) { … }, 10, 2 );
|
||||||
|
```
|
||||||
98
docs/smtp.md
Normal file
98
docs/smtp.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Sending real email
|
||||||
|
|
||||||
|
Sirius Press does not disable `wp_mail()`. Contact forms, order receipts,
|
||||||
|
newsletters and every other reason a site has to send a human an email still
|
||||||
|
work — you just have to configure a way to send them, exactly as you would on
|
||||||
|
any WordPress site that is not running on a shared host with a mail server
|
||||||
|
already attached.
|
||||||
|
|
||||||
|
What the fork removes is email as an *identity*. What it leaves alone is email
|
||||||
|
as a *feature*. Those are different things, and conflating them is how people
|
||||||
|
end up believing the fork is more restrictive than it is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What happens without SMTP
|
||||||
|
|
||||||
|
Mail to your site's own `.invalid` placeholder addresses never reaches a mail
|
||||||
|
transport at all. It goes to the Sirius Press inbox — **Inbox** in the admin
|
||||||
|
menu — where the account holder reads it while signed in. Password-reset
|
||||||
|
attempts, comment-moderation notices and update nags all land there.
|
||||||
|
|
||||||
|
Mail to a real address is handed to PHP's `mail()`. In the Docker stack there
|
||||||
|
is no mail transport in the container, so it fails. That is the same failure
|
||||||
|
stock WordPress has in the same situation, reported the same way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setting up SMTP
|
||||||
|
|
||||||
|
Any of the usual plugins work, because `wp_mail()` is untouched: WP Mail SMTP,
|
||||||
|
Post SMTP, FluentSMTP. Install one, point it at a provider, done.
|
||||||
|
|
||||||
|
Without a plugin, the `phpmailer_init` hook is enough:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// wp-content/mu-plugins/smtp.php
|
||||||
|
add_action( 'phpmailer_init', function ( $mailer ) {
|
||||||
|
$mailer->isSMTP();
|
||||||
|
$mailer->Host = 'smtp.example.net';
|
||||||
|
$mailer->Port = 587;
|
||||||
|
$mailer->SMTPAuth = true;
|
||||||
|
$mailer->SMTPSecure = 'tls';
|
||||||
|
$mailer->Username = 'postmaster@example.net';
|
||||||
|
// Keep the secret in wp-config.php, not here, so it stays out of backups
|
||||||
|
// that include wp-content.
|
||||||
|
$mailer->Password = defined( 'SMTP_PASSWORD' ) ? SMTP_PASSWORD : '';
|
||||||
|
$mailer->From = 'hello@example.net';
|
||||||
|
$mailer->FromName = get_bloginfo( 'name' );
|
||||||
|
} );
|
||||||
|
```
|
||||||
|
|
||||||
|
A must-use plugin rather than a theme function: it should survive a theme
|
||||||
|
change, and it should not be editable from the plugin editor in wp-admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing a From address
|
||||||
|
|
||||||
|
The one thing that will not work is sending *from* a `.invalid` address. Most
|
||||||
|
providers reject it outright, and the ones that do not will have every message
|
||||||
|
binned for failing SPF.
|
||||||
|
|
||||||
|
So a site that sends mail needs one real domain, for outbound only. This is
|
||||||
|
slightly awkward for a project whose point is not depending on the DNS system,
|
||||||
|
and worth being honest about: if your site sends email, you have a dependency
|
||||||
|
on somebody's DNS, and no amount of BCNR changes that. Email is a DNS protocol.
|
||||||
|
|
||||||
|
Two ways to live with it:
|
||||||
|
|
||||||
|
- **Accept it, narrowly.** Register a domain used for nothing but outbound mail.
|
||||||
|
It has no bearing on how readers reach your site, and if it disappears you
|
||||||
|
lose contact-form delivery rather than your site.
|
||||||
|
- **Avoid sending.** Use the inbox for anything addressed to an account holder,
|
||||||
|
and for contact forms use a plugin that stores submissions in the database
|
||||||
|
and shows them in wp-admin instead of mailing them. For most small sites this
|
||||||
|
is better anyway: you read submissions where you already are, and there is no
|
||||||
|
provider to pay or be cut off by.
|
||||||
|
|
||||||
|
The second is what a Sirius Press site does by default if you never configure
|
||||||
|
anything, which is deliberate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking it works
|
||||||
|
|
||||||
|
Send yourself something:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wp eval 'var_dump( wp_mail( "you@example.net", "Sirius Press test", "It sends." ) );'
|
||||||
|
```
|
||||||
|
|
||||||
|
`true` means PHPMailer accepted it, not that it arrived. If it returns `true`
|
||||||
|
and nothing turns up, the problem is between your provider and the recipient —
|
||||||
|
SPF, DKIM or a spam filter — and is no longer anything to do with this fork.
|
||||||
|
|
||||||
|
To confirm the placeholder interception is working, mail one of your own
|
||||||
|
accounts. It should return `true`, nothing should leave the server, and the
|
||||||
|
message should be sitting in that account's inbox.
|
||||||
133
docs/upstream-merges.md
Normal file
133
docs/upstream-merges.md
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Keeping up with WordPress
|
||||||
|
|
||||||
|
WordPress ships security releases, and a fork that cannot take them quickly is
|
||||||
|
a liability rather than a project. This page is how Sirius Press takes them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The arrangement
|
||||||
|
|
||||||
|
WordPress is not in this repository. Instead:
|
||||||
|
|
||||||
|
- `tools/wordpress.lock` pins an exact version, its download URL and its
|
||||||
|
SHA-256.
|
||||||
|
- `patches/` holds the fork's core diff as a patch series.
|
||||||
|
- `tools/build.sh` and the Docker image each download the pinned tarball,
|
||||||
|
verify the hash, and apply the series.
|
||||||
|
|
||||||
|
The entire core diff is currently **one file, 75 lines**: the setup wizard, at
|
||||||
|
`wp-admin/install.php`. Everything else the fork does is plugins and hooks.
|
||||||
|
|
||||||
|
### Why not a vendored subtree
|
||||||
|
|
||||||
|
The original plan was to vendor core as a `git subtree`, which is the standard
|
||||||
|
way to run a fork with a substantial diff. Two numbers argued against it.
|
||||||
|
|
||||||
|
WordPress 7.1.1 is **149 MB and 5,008 files**. The fork's core diff is **75
|
||||||
|
lines**. Carrying the first to express the second means every clone, every
|
||||||
|
`git status`, every `git subtree split` in the parent monorepo pays for a patch
|
||||||
|
you can read in a minute — and it buries that patch in a haystack where nobody
|
||||||
|
will ever review it again.
|
||||||
|
|
||||||
|
A patch series makes the opposite trade. The core change is a file you can read
|
||||||
|
in one sitting, review in a pull request, and re-read whenever it stops
|
||||||
|
applying. The cost is that upstream merges are `patch` rather than a three-way
|
||||||
|
git merge — which, for 75 lines in one file, is not much of a cost.
|
||||||
|
|
||||||
|
This holds as long as the diff stays small. If the fork ever needs to change
|
||||||
|
core substantially, vendor it properly: see "Switching to a subtree" below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Taking a new release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/update-wordpress.sh 7.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
It fetches the release, verifies its SHA-1 against what wordpress.org
|
||||||
|
publishes, records a SHA-256, applies the patch series to the new tree, and
|
||||||
|
reports what happened.
|
||||||
|
|
||||||
|
Three possible outcomes:
|
||||||
|
|
||||||
|
**Clean.** The series applied. `tools/wordpress.lock` is updated, the tests
|
||||||
|
run, and you commit the lock change. Usually thirty seconds of work.
|
||||||
|
|
||||||
|
**Applied with fuzz.** Upstream moved code near a hunk but not the hunk itself.
|
||||||
|
The script says which hunk and by how many lines. Look at the result, then
|
||||||
|
refresh the series so the next release starts from a clean base:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/update-wordpress.sh 7.1.2 --refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Failed.** Upstream rewrote the code the patch touches. This is the case that
|
||||||
|
needs a person: open the new `wp-admin/install.php`, redo the change by hand,
|
||||||
|
and regenerate the patch. The script leaves the unpacked tree in
|
||||||
|
`dist/.upstream/` so there is something to work in.
|
||||||
|
|
||||||
|
In every case the fork is **not broken while you work** — the lock file still
|
||||||
|
points at the last known-good version, and builds keep producing that until you
|
||||||
|
change it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reviewing a release before taking it
|
||||||
|
|
||||||
|
Security releases are usually small, and it is worth knowing what changed near
|
||||||
|
the code the fork patches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/update-wordpress.sh 7.1.2 --diff wp-admin/install.php
|
||||||
|
```
|
||||||
|
|
||||||
|
prints upstream's own diff for that file between the pinned version and the new
|
||||||
|
one. If it is empty — which it usually is — the patch will apply and there is
|
||||||
|
nothing to think about.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After any bump
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite does not test core, but it does test every assumption the fork makes
|
||||||
|
about it. Then build and try an actual install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/build.sh
|
||||||
|
cd docker && docker compose build && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
and walk through `wp-admin/install.php` once. The setup wizard is the only
|
||||||
|
patched file, so it is the only thing an upstream change can break in a way
|
||||||
|
the tests would miss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Switching to a subtree
|
||||||
|
|
||||||
|
If the diff ever grows past what a patch series is comfortable with, the
|
||||||
|
mechanics are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add wordpress https://github.com/WordPress/WordPress.git
|
||||||
|
git fetch --depth=1 wordpress 7.1.1
|
||||||
|
git subtree add --prefix=wordpress FETCH_HEAD --squash
|
||||||
|
```
|
||||||
|
|
||||||
|
and then, per release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch --depth=1 wordpress 7.1.2
|
||||||
|
git subtree merge --prefix=wordpress FETCH_HEAD --squash
|
||||||
|
```
|
||||||
|
|
||||||
|
with `patches/` becoming the record of what was changed rather than the
|
||||||
|
mechanism that changes it. The build script would then copy `wordpress/`
|
||||||
|
instead of downloading and patching.
|
||||||
|
|
||||||
|
Worth doing when the core diff reaches, say, a dozen files. Not before.
|
||||||
159
install.sh
Normal file
159
install.sh
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sirius Press installer.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://silentmode.st/sirius-press/install.sh | bash
|
||||||
|
#
|
||||||
|
# Brings up a working instance on a fresh Ubuntu 22.04 or 24.04 VPS: Docker if
|
||||||
|
# it is missing, the repository, generated passwords, and the stack running
|
||||||
|
# behind nginx.
|
||||||
|
#
|
||||||
|
# Two things this script will not do, both on purpose:
|
||||||
|
#
|
||||||
|
# It does not pipe anything else into a shell. Docker is installed from the
|
||||||
|
# distribution's own packages, not from get.docker.com — you are already
|
||||||
|
# trusting one curl-to-bash by running this, and chaining a second is how a
|
||||||
|
# supply chain gets long.
|
||||||
|
#
|
||||||
|
# It does not ask for, generate or store a wallet phrase. The site's
|
||||||
|
# publishing key is entered later in wp-admin, by a person, over whatever
|
||||||
|
# TLS they have arranged — not typed into a terminal session that scrolls
|
||||||
|
# into a log.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="${SIRIUS_PRESS_REPO:-https://code.silentmode.st/silentmode/sirius-press.git}"
|
||||||
|
DIR="${SIRIUS_PRESS_DIR:-/opt/sirius-press}"
|
||||||
|
PORT="${SIRIUS_PRESS_PORT:-80}"
|
||||||
|
BRANCH="${SIRIUS_PRESS_BRANCH:-master}"
|
||||||
|
|
||||||
|
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||||
|
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "run this as root, or with sudo."
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- prerequisites
|
||||||
|
|
||||||
|
say "checking prerequisites"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
say "installing Docker from the distribution repositories"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io docker-compose-v2 git curl \
|
||||||
|
|| die "could not install Docker. Install it yourself and run this again."
|
||||||
|
systemctl enable --now docker
|
||||||
|
else
|
||||||
|
command -v git >/dev/null 2>&1 || apt-get install -y -qq git
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
die "Docker is installed but Compose is not. Install docker-compose-v2."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ the code
|
||||||
|
|
||||||
|
if [ -d "$DIR/.git" ]; then
|
||||||
|
say "updating $DIR"
|
||||||
|
git -C "$DIR" fetch --quiet origin "$BRANCH"
|
||||||
|
git -C "$DIR" checkout --quiet "$BRANCH"
|
||||||
|
git -C "$DIR" pull --quiet --ff-only origin "$BRANCH" \
|
||||||
|
|| warn "could not fast-forward; leaving the checkout as it is."
|
||||||
|
else
|
||||||
|
say "cloning into $DIR"
|
||||||
|
mkdir -p "$(dirname "$DIR")"
|
||||||
|
git clone --quiet --branch "$BRANCH" --depth 1 "$REPO" "$DIR" \
|
||||||
|
|| die "could not clone $REPO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$DIR/docker"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the config
|
||||||
|
|
||||||
|
random() { head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
|
||||||
|
|
||||||
|
if [ -f .env ]; then
|
||||||
|
say "keeping the existing .env"
|
||||||
|
else
|
||||||
|
say "writing .env with generated passwords"
|
||||||
|
# shellcheck source=tools/wordpress.lock
|
||||||
|
source "$DIR/tools/wordpress.lock"
|
||||||
|
{
|
||||||
|
echo "# Written by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)."
|
||||||
|
echo "DB_NAME=wordpress"
|
||||||
|
echo "DB_USER=wordpress"
|
||||||
|
echo "DB_PASSWORD=$(random)"
|
||||||
|
echo "DB_ROOT_PASSWORD=$(random)"
|
||||||
|
echo "SITE_URL="
|
||||||
|
echo "HTTP_PORT=$PORT"
|
||||||
|
echo "WP_DEBUG=false"
|
||||||
|
echo "SIRIUS_PRESS_KEY=$(random)"
|
||||||
|
echo "WP_VERSION=$WP_VERSION"
|
||||||
|
echo "WP_URL=$WP_URL"
|
||||||
|
echo "WP_SHA256=$WP_SHA256"
|
||||||
|
} > .env
|
||||||
|
chmod 600 .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- run it
|
||||||
|
|
||||||
|
say "building the image (this is the slow part — a few minutes on a small VPS)"
|
||||||
|
$COMPOSE build --quiet 2>&1 | tail -5 || die "the image did not build."
|
||||||
|
|
||||||
|
say "starting"
|
||||||
|
$COMPOSE up -d || die "the stack did not start. Try: cd $DIR/docker && $COMPOSE logs"
|
||||||
|
|
||||||
|
# Give the app a moment, then check that something answers. A silent failure
|
||||||
|
# here is the difference between "installed" and "installed and working".
|
||||||
|
say "waiting for the site to answer"
|
||||||
|
url="http://127.0.0.1:${PORT}/wp-admin/install.php"
|
||||||
|
ok=0
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || true)"
|
||||||
|
case "$code" in
|
||||||
|
200|30[0-9]) ok=1; break ;;
|
||||||
|
esac
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
ip="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
suffix=""
|
||||||
|
[ "$PORT" = "80" ] || suffix=":$PORT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$ok" -eq 1 ]; then
|
||||||
|
bold "Sirius Press is running."
|
||||||
|
else
|
||||||
|
bold "Sirius Press started, but nothing answered on port $PORT yet."
|
||||||
|
warn "Check with: cd $DIR/docker && $COMPOSE logs -f app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Finish setup: http://${ip:-your-server}${suffix}/wp-admin/install.php
|
||||||
|
|
||||||
|
The setup screen asks for a wallet address instead of an email address.
|
||||||
|
You can leave it blank and attach one later from your profile.
|
||||||
|
|
||||||
|
Next:
|
||||||
|
|
||||||
|
1. Point your BCNR name's p or ip record at ${ip:-this server}.
|
||||||
|
See ${DIR}/docs/bcnr-records.md
|
||||||
|
2. In wp-admin, open Sirius Press and set the name this site publishes under.
|
||||||
|
3. Decide whether this server may hold your publishing key, or whether you
|
||||||
|
would rather sign exports from your browser. Manual is the default and
|
||||||
|
the safer answer. See ${DIR}/docs/publishing.md
|
||||||
|
|
||||||
|
Managing it:
|
||||||
|
|
||||||
|
cd ${DIR}/docker
|
||||||
|
${COMPOSE} logs -f app # what the site is doing
|
||||||
|
${COMPOSE} restart # after changing .env
|
||||||
|
git -C ${DIR} pull && ${COMPOSE} build && ${COMPOSE} up -d # upgrade
|
||||||
|
|
||||||
|
EOF
|
||||||
131
mu-plugins/sirius-press-bootstrap.php
Normal file
131
mu-plugins/sirius-press-bootstrap.php
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Sirius Press Bootstrap
|
||||||
|
* Description: Loads before everything else, so the parts of WordPress that run before plugins do — the installer, and core's pluggable notification functions — behave on a site with no email identity.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* Must-use plugins are loaded by `wp-settings.php` at a point where two useful
|
||||||
|
* things are still true: ordinary plugins have not loaded (so this runs during
|
||||||
|
* the installer, when they are skipped entirely), and `pluggable.php` has not
|
||||||
|
* been included yet (so functions declared here win over core's).
|
||||||
|
*
|
||||||
|
* Both properties are needed, and neither is available anywhere else:
|
||||||
|
*
|
||||||
|
* - The patched `wp-admin/install.php` calls two helpers that must exist
|
||||||
|
* before any plugin could have defined them.
|
||||||
|
* - Core's mail notifications are pluggable functions. Overriding them is the
|
||||||
|
* only way to stop the installer and the user system from handing messages
|
||||||
|
* to a mail transport that has nowhere to send them.
|
||||||
|
*
|
||||||
|
* This file is deliberately tiny and dependency-free. Anything that can wait
|
||||||
|
* for `plugins_loaded` belongs in Sirius Press Core instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'SIRIUS_PRESS_BOOTSTRAP', '0.1.0' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and normalise an address typed into the installer.
|
||||||
|
*
|
||||||
|
* @param string $address
|
||||||
|
* @return string|false Normalised address, '' when blank, false when invalid.
|
||||||
|
*/
|
||||||
|
function sirius_press_install_address( $address ) {
|
||||||
|
$address = trim( (string) $address );
|
||||||
|
if ( '' === $address ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$class = WP_PLUGIN_DIR . '/sirius-press-core/includes/class-sp-cashaddr.php';
|
||||||
|
if ( ! class_exists( 'SP_CashAddr' ) && is_readable( $class ) ) {
|
||||||
|
require_once $class;
|
||||||
|
}
|
||||||
|
if ( ! class_exists( 'SP_CashAddr' ) ) {
|
||||||
|
// Core plugin not present. Refusing here would make the site
|
||||||
|
// uninstallable; accepting an unchecked string would attach an
|
||||||
|
// account to an address nobody can prove. Reject the value and let
|
||||||
|
// the operator install without one.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$normalised = SP_CashAddr::normalize( $address );
|
||||||
|
return '' === $normalised ? false : $normalised;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A permanently unroutable address for a user, derived from their wallet.
|
||||||
|
*
|
||||||
|
* `wp_install()` and `wp_insert_user()` both want an email string. They get
|
||||||
|
* one that RFC 2606 guarantees can never resolve.
|
||||||
|
*/
|
||||||
|
function sirius_press_install_stub_email( $address ) {
|
||||||
|
$tag = is_string( $address ) && '' !== $address
|
||||||
|
? substr( hash( 'sha256', $address ), 0, 16 )
|
||||||
|
: substr( hash( 'sha256', (string) wp_rand( 0, PHP_INT_MAX ) . microtime() ), 0, 16 );
|
||||||
|
return 'noreply+' . $tag . '@sirius-press.invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Core notifications that have nowhere to go.
|
||||||
|
*
|
||||||
|
* These are declared before `pluggable.php` so core's versions never load.
|
||||||
|
* Each one exists in WordPress to email somebody about their own account;
|
||||||
|
* on a site where accounts have no mailbox, the message belongs in the
|
||||||
|
* in-app inbox, and where the inbox is not available yet (during install)
|
||||||
|
* it belongs nowhere at all.
|
||||||
|
*
|
||||||
|
* Deliberately NOT overridden: wp_mail() itself. Contact forms, order
|
||||||
|
* receipts and newsletters go to addresses real people supplied, and those
|
||||||
|
* must keep working for a site owner who has configured SMTP. Only the
|
||||||
|
* account-notification functions are replaced.
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! function_exists( 'wp_new_blog_notification' ) ) {
|
||||||
|
/**
|
||||||
|
* Core mails the new administrator their own login URL and password.
|
||||||
|
*
|
||||||
|
* Suppressed: the person triggering it is looking at the screen that
|
||||||
|
* already shows both, and the address on file cannot receive mail.
|
||||||
|
*
|
||||||
|
* @param string $blog_title
|
||||||
|
* @param string $blog_url
|
||||||
|
* @param int $user_id
|
||||||
|
* @param string $password
|
||||||
|
*/
|
||||||
|
function wp_new_blog_notification( $blog_title, $blog_url, $user_id, $password ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames
|
||||||
|
// Nothing to send, and nothing to log — the installer's success page
|
||||||
|
// is the notification.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'wp_password_change_notification' ) ) {
|
||||||
|
/**
|
||||||
|
* Core mails every administrator when a user changes their password.
|
||||||
|
*
|
||||||
|
* Routed to the in-app inbox when it exists. During install it does not,
|
||||||
|
* and no password has changed anyway.
|
||||||
|
*
|
||||||
|
* @param WP_User $user
|
||||||
|
*/
|
||||||
|
function wp_password_change_notification( $user ) {
|
||||||
|
if ( ! class_exists( 'SP_Inbox' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SP_Inbox::add(
|
||||||
|
0,
|
||||||
|
__( 'A password was changed', 'sirius-press' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: the user login whose password changed. */
|
||||||
|
esc_html__( 'The password for %s was changed. On this site passwords are a fallback, not the identity — the account is still controlled by its wallet.', 'sirius-press' ),
|
||||||
|
'<code>' . esc_html( $user->user_login ) . '</code>'
|
||||||
|
),
|
||||||
|
'core'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
patches/0001-setup-wizard-wallet-instead-of-email.patch
Normal file
100
patches/0001-setup-wizard-wallet-instead-of-email.patch
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
Subject: [PATCH] setup wizard: ask for a wallet, not a mailbox
|
||||||
|
|
||||||
|
The installer is the one place in WordPress where the fork cannot do its
|
||||||
|
work from a plugin. It runs before plugins load, and it refuses to finish
|
||||||
|
without an email address, so a site with no mail identity cannot be
|
||||||
|
installed at all.
|
||||||
|
|
||||||
|
The change is the smallest one that removes the requirement:
|
||||||
|
|
||||||
|
- "Your Email" becomes "Your wallet address", and it is optional. The
|
||||||
|
installer has nothing to verify a signature against yet — the site
|
||||||
|
does not exist — so an address typed here is taken on trust and can be
|
||||||
|
replaced from the profile screen afterwards, where it CAN be proved.
|
||||||
|
- The two is_email() gates become one address check.
|
||||||
|
- wp_install() still receives an email string, because its signature and
|
||||||
|
everything downstream of it expects one. It gets a permanently
|
||||||
|
unroutable .invalid placeholder.
|
||||||
|
|
||||||
|
The two helper functions are defined by the Sirius Press bootstrap
|
||||||
|
must-use plugin, which loads before this file on every request including
|
||||||
|
the installer. If that plugin is missing, they fall back to accepting
|
||||||
|
nothing and the installer behaves as it does on stock WordPress minus the
|
||||||
|
email field.
|
||||||
|
|
||||||
|
Applies to: WordPress 7.1.1
|
||||||
|
--- a/wp-admin/install.php
|
||||||
|
+++ b/wp-admin/install.php
|
||||||
|
@@ -101,6 +101,8 @@
|
||||||
|
$weblog_title = isset( $_POST['weblog_title'] ) ? trim( wp_unslash( $_POST['weblog_title'] ) ) : '';
|
||||||
|
$user_name = isset( $_POST['user_name'] ) ? trim( wp_unslash( $_POST['user_name'] ) ) : '';
|
||||||
|
$admin_email = isset( $_POST['admin_email'] ) ? trim( wp_unslash( $_POST['admin_email'] ) ) : '';
|
||||||
|
+ // Sirius Press: the identity field on this form is a wallet address.
|
||||||
|
+ $admin_wallet = isset( $_POST['admin_wallet'] ) ? trim( wp_unslash( $_POST['admin_wallet'] ) ) : '';
|
||||||
|
|
||||||
|
if ( ! is_null( $error ) ) {
|
||||||
|
?>
|
||||||
|
@@ -175,9 +177,9 @@
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<tr>
|
||||||
|
- <th scope="row"><label for="admin_email"><?php _e( 'Your Email' ); ?></label></th>
|
||||||
|
- <td><input name="admin_email" type="email" id="admin_email" size="25" aria-describedby="admin-email-desc" value="<?php echo esc_attr( $admin_email ); ?>" />
|
||||||
|
- <p id="admin-email-desc"><?php _e( 'Double-check your email address before continuing.' ); ?></p></td>
|
||||||
|
+ <th scope="row"><label for="admin_wallet"><?php _e( 'Your wallet address' ); ?></label></th>
|
||||||
|
+ <td><input name="admin_wallet" type="text" id="admin_wallet" size="25" class="code" aria-describedby="admin-wallet-desc" value="<?php echo esc_attr( $admin_wallet ); ?>" autocapitalize="off" autocomplete="off" spellcheck="false" />
|
||||||
|
+ <p id="admin-wallet-desc"><?php _e( 'Sirius Press: your Bitcoin Cash address is your account. Leave this blank to attach one later from your profile — until then the password above is the only way in.' ); ?></p></td>
|
||||||
|
</tr>
|
||||||
|
<?php $blog_privacy_selector_title = has_action( 'blog_privacy_selector' ) ? __( 'Site visibility' ) : __( 'Search engine visibility' ); ?>
|
||||||
|
<tr>
|
||||||
|
@@ -411,10 +413,20 @@
|
||||||
|
$user_name = isset( $_POST['user_name'] ) ? trim( wp_unslash( $_POST['user_name'] ) ) : '';
|
||||||
|
$admin_password = isset( $_POST['admin_password'] ) ? wp_unslash( $_POST['admin_password'] ) : '';
|
||||||
|
$admin_password_check = isset( $_POST['admin_password2'] ) ? wp_unslash( $_POST['admin_password2'] ) : '';
|
||||||
|
- $admin_email = isset( $_POST['admin_email'] ) ? trim( wp_unslash( $_POST['admin_email'] ) ) : '';
|
||||||
|
+ $admin_wallet = isset( $_POST['admin_wallet'] ) ? trim( wp_unslash( $_POST['admin_wallet'] ) ) : '';
|
||||||
|
$public = isset( $_POST['blog_public'] ) ? (int) $_POST['blog_public'] : 1;
|
||||||
|
|
||||||
|
- // Check email address.
|
||||||
|
+ /*
|
||||||
|
+ * Sirius Press: there is no email step. The account identity is a
|
||||||
|
+ * CashAddress, and it is optional here because the installer has nothing
|
||||||
|
+ * to verify a signature against yet — the site does not exist. An address
|
||||||
|
+ * typed now is attached below; an administrator who leaves it blank
|
||||||
|
+ * attaches one from their profile afterwards, where they can prove it.
|
||||||
|
+ */
|
||||||
|
+ $admin_wallet = sirius_press_install_address( $admin_wallet );
|
||||||
|
+ $admin_email = sirius_press_install_stub_email( $admin_wallet );
|
||||||
|
+
|
||||||
|
+ // Check the username and password.
|
||||||
|
$error = false;
|
||||||
|
if ( empty( $user_name ) ) {
|
||||||
|
// TODO: Poka-yoke.
|
||||||
|
@@ -427,19 +439,20 @@
|
||||||
|
// TODO: Poka-yoke.
|
||||||
|
display_setup_form( __( 'Your passwords do not match. Please try again.' ) );
|
||||||
|
$error = true;
|
||||||
|
- } elseif ( empty( $admin_email ) ) {
|
||||||
|
- // TODO: Poka-yoke.
|
||||||
|
- display_setup_form( __( 'You must provide an email address.' ) );
|
||||||
|
- $error = true;
|
||||||
|
- } elseif ( ! is_email( $admin_email ) ) {
|
||||||
|
- // TODO: Poka-yoke.
|
||||||
|
- display_setup_form( __( 'Sorry, that is not a valid email address. Email addresses look like <code>username@example.com</code>.' ) );
|
||||||
|
+ } elseif ( false === $admin_wallet ) {
|
||||||
|
+ display_setup_form( __( 'Sorry, that is not a valid Bitcoin Cash address. Leave the field blank if you would rather attach a wallet after installing.' ) );
|
||||||
|
$error = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( false === $error ) {
|
||||||
|
$wpdb->show_errors();
|
||||||
|
$result = wp_install( $weblog_title, $user_name, $admin_email, $public, '', wp_slash( $admin_password ), $loaded_language );
|
||||||
|
+
|
||||||
|
+ // Sirius Press: record the administrator's wallet so they can sign in
|
||||||
|
+ // with it immediately.
|
||||||
|
+ if ( '' !== $admin_wallet && ! empty( $result['user_id'] ) ) {
|
||||||
|
+ update_user_meta( (int) $result['user_id'], 'sirius_wallet_address', $admin_wallet );
|
||||||
|
+ }
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1><?php _e( 'Success!' ); ?></h1>
|
||||||
11
plugins/sirius-press-auth/assets/bip39-en.js
Normal file
11
plugins/sirius-press-auth/assets/bip39-en.js
Normal file
File diff suppressed because one or more lines are too long
159
plugins/sirius-press-auth/assets/login.css
Normal file
159
plugins/sirius-press-auth/assets/login.css
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/*
|
||||||
|
* Sign-in screen styling.
|
||||||
|
*
|
||||||
|
* Sits on top of whatever wp-login.php already looks like rather than
|
||||||
|
* replacing it, so a site using a login-branding plugin keeps its branding.
|
||||||
|
* The only strong visual claim made here is hierarchy: the wallet block reads
|
||||||
|
* as the way in, and the password field above it reads as a leftover.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.sirius-wallet {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dcdcde;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fbfbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__message {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #2c3338;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dcdcde;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__phrase {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__phrase label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__phrase-input,
|
||||||
|
.sirius-wallet__signature,
|
||||||
|
#sirius_derive_phrase {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #8c8f94;
|
||||||
|
border-radius: 4px;
|
||||||
|
/* A recovery phrase is the one secret on this page; never offer it to a
|
||||||
|
password manager or a spellchecker's network round trip. */
|
||||||
|
-webkit-text-security: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__hint,
|
||||||
|
.sirius-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #50575e;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__manual {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__manual summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2271b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__status {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
min-height: 1.4em;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-wallet__status.is-error {
|
||||||
|
color: #b32d2e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-intro {
|
||||||
|
border-left-color: #2271b1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- the recovery page ------------------------------------------------- */
|
||||||
|
|
||||||
|
.sirius-recovery {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dcdcde;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-recovery h2 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 20px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-recovery .message {
|
||||||
|
font-weight: 600;
|
||||||
|
border-left: 4px solid #dba617;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fcf9e8;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-derive {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f6f7f7;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-derive label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-derive__out {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-derive__out.is-address {
|
||||||
|
color: #007017;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sirius-derive__out.is-error {
|
||||||
|
color: #b32d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
/* wp-login.php has no dark mode of its own, so only soften what would
|
||||||
|
glare if a login-branding plugin has provided one. */
|
||||||
|
.sirius-wallet {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
plugins/sirius-press-auth/assets/login.js
Normal file
182
plugins/sirius-press-auth/assets/login.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// Wiring for the sign-in and registration screens.
|
||||||
|
//
|
||||||
|
// This file contains no cryptography — wallet.js does all of that. What it
|
||||||
|
// does is decide which of the three ways to sign is on offer and get out of
|
||||||
|
// the way of the other two:
|
||||||
|
//
|
||||||
|
// 1. A wallet the browser already has (Theseus exposes one). Best case: the
|
||||||
|
// key never comes near this page.
|
||||||
|
// 2. A recovery phrase typed here, used once, and wiped.
|
||||||
|
// 3. A signature produced somewhere else entirely and pasted in. This one
|
||||||
|
// works with JavaScript switched off, which is why the markup ships with
|
||||||
|
// the paste box present and the buttons hidden until this script decides
|
||||||
|
// they are usable.
|
||||||
|
//
|
||||||
|
// The progressive-enhancement direction matters: buttons start hidden and are
|
||||||
|
// revealed, rather than starting visible and being disabled. A script that
|
||||||
|
// fails to load leaves a page that still works.
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const CONFIG = window.SIRIUS_PRESS || {};
|
||||||
|
const strings = CONFIG.strings || {};
|
||||||
|
|
||||||
|
function setStatus(block, text, kind = "") {
|
||||||
|
const el = block.querySelector(".sirius-wallet__status");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "sirius-wallet__status" + (kind ? " is-" + kind : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submit the form the block sits in, once a signature is in place. */
|
||||||
|
function submitForm(block) {
|
||||||
|
const form = block.closest("form");
|
||||||
|
if (!form) return;
|
||||||
|
if (typeof form.requestSubmit === "function") {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSignature(block, signature) {
|
||||||
|
const field = block.querySelector(".sirius-wallet__signature");
|
||||||
|
if (field) field.value = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signWithPhrase(block) {
|
||||||
|
const input = block.querySelector(".sirius-wallet__phrase-input");
|
||||||
|
const message = block.querySelector(".sirius-wallet__message").value;
|
||||||
|
const phrase = input ? input.value : "";
|
||||||
|
|
||||||
|
const check = window.SiriusWallet.validatePhrase(phrase);
|
||||||
|
if (!check.ok) {
|
||||||
|
setStatus(block, check.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(block, strings.signing || "Signing…");
|
||||||
|
let wallet;
|
||||||
|
try {
|
||||||
|
wallet = await window.SiriusWallet.fromPhrase(phrase, {
|
||||||
|
prefix: CONFIG.prefix,
|
||||||
|
path: CONFIG.path,
|
||||||
|
});
|
||||||
|
setSignature(block, await wallet.sign(message));
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(block, err.message || String(err), "error");
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
// The phrase has done its one job. Clear it from the field and from the
|
||||||
|
// wallet object before anything else on the page runs.
|
||||||
|
if (input) input.value = "";
|
||||||
|
if (wallet && wallet.forget) wallet.forget();
|
||||||
|
}
|
||||||
|
submitForm(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signWithExternal(block) {
|
||||||
|
const external = window.SiriusWallet.external();
|
||||||
|
if (!external) {
|
||||||
|
setStatus(block, strings.noWallet || "No wallet found in this browser.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = block.querySelector(".sirius-wallet__message").value;
|
||||||
|
setStatus(block, strings.signing || "Signing…");
|
||||||
|
try {
|
||||||
|
setSignature(block, await external.sign(message));
|
||||||
|
} catch (err) {
|
||||||
|
// A user declining the wallet's approval dialog is a normal outcome, not
|
||||||
|
// an error worth shouting about.
|
||||||
|
const text = err && err.message ? err.message : String(err);
|
||||||
|
setStatus(block, /reject/i.test(text) ? "" : text, /reject/i.test(text) ? "" : "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitForm(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhance(block) {
|
||||||
|
const signButton = block.querySelector(".sirius-wallet__sign");
|
||||||
|
const externalButton = block.querySelector(".sirius-wallet__external");
|
||||||
|
const phraseArea = block.querySelector(".sirius-wallet__phrase");
|
||||||
|
|
||||||
|
if (!window.SiriusWallet || !window.crypto || !window.crypto.subtle) {
|
||||||
|
// No usable crypto — for instance an http:// origin, where WebCrypto is
|
||||||
|
// unavailable. Say so, and leave the paste path alone: it still works.
|
||||||
|
setStatus(
|
||||||
|
block,
|
||||||
|
"This browser cannot sign here (a secure https connection is required). Paste a signature from your own wallet instead.",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
const manual = block.querySelector(".sirius-wallet__manual");
|
||||||
|
if (manual) manual.open = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phraseArea) phraseArea.hidden = false;
|
||||||
|
if (signButton) {
|
||||||
|
signButton.hidden = false;
|
||||||
|
signButton.addEventListener("click", () => signWithPhrase(block));
|
||||||
|
}
|
||||||
|
if (externalButton && window.SiriusWallet.external()) {
|
||||||
|
externalButton.hidden = false;
|
||||||
|
externalButton.addEventListener("click", () => signWithExternal(block));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter in the phrase box should sign, not submit an unsigned form.
|
||||||
|
const input = block.querySelector(".sirius-wallet__phrase-input");
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
signWithPhrase(block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "which address is this phrase?" helper on the recovery page. */
|
||||||
|
function enhanceDerive() {
|
||||||
|
const button = document.getElementById("sirius_derive_go");
|
||||||
|
const input = document.getElementById("sirius_derive_phrase");
|
||||||
|
const out = document.querySelector(".sirius-derive__out");
|
||||||
|
if (!button || !input || !out) return;
|
||||||
|
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const check = window.SiriusWallet.validatePhrase(input.value);
|
||||||
|
if (!check.ok) {
|
||||||
|
out.textContent = check.error;
|
||||||
|
out.className = "sirius-derive__out is-error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.textContent = strings.signing || "Working…";
|
||||||
|
out.className = "sirius-derive__out";
|
||||||
|
try {
|
||||||
|
const wallet = await window.SiriusWallet.fromPhrase(input.value, {
|
||||||
|
prefix: CONFIG.prefix,
|
||||||
|
path: CONFIG.path,
|
||||||
|
});
|
||||||
|
out.textContent = wallet.address;
|
||||||
|
out.className = "sirius-derive__out is-address";
|
||||||
|
wallet.forget();
|
||||||
|
} catch (err) {
|
||||||
|
out.textContent = err.message || String(err);
|
||||||
|
out.className = "sirius-derive__out is-error";
|
||||||
|
} finally {
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
document.querySelectorAll(".sirius-wallet").forEach(enhance);
|
||||||
|
enhanceDerive();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
})();
|
||||||
511
plugins/sirius-press-auth/assets/wallet.js
Normal file
511
plugins/sirius-press-auth/assets/wallet.js
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
// The wallet that signs Sirius Press sign-ins, running in the visitor's page.
|
||||||
|
//
|
||||||
|
// Why this exists at all: a self-hosted Sirius Press site must be able to log
|
||||||
|
// its own users in without calling home. Redirecting to sirius.x to sign would
|
||||||
|
// make every install depend on Silent Mode's portal being up and willing —
|
||||||
|
// which is exactly the arrangement this whole project exists to get away from.
|
||||||
|
// So the signing happens here, in the browser, against a phrase the server
|
||||||
|
// never sees.
|
||||||
|
//
|
||||||
|
// No build step and no dependencies, deliberately: a wallet you cannot read is
|
||||||
|
// a wallet you cannot trust, and a site owner should be able to open this file
|
||||||
|
// and follow every line from phrase to signature. BigInt does the curve maths,
|
||||||
|
// WebCrypto does SHA-256, HMAC and PBKDF2. RIPEMD-160 is implemented below
|
||||||
|
// because WebCrypto does not offer it and address derivation needs it.
|
||||||
|
//
|
||||||
|
// The phrase never leaves the page. It is not posted, not stored in a cookie,
|
||||||
|
// and — unless the visitor asks to stay signed in — not written to disk at
|
||||||
|
// all. What crosses the wire is a signature over a challenge the server
|
||||||
|
// issued, which proves control of the key and nothing else.
|
||||||
|
//
|
||||||
|
// window.SiriusWallet = { fromPhrase, generatePhrase, validatePhrase,
|
||||||
|
// external, addressFromPublicKey }
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- secp256k1
|
||||||
|
|
||||||
|
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
|
||||||
|
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
|
||||||
|
const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
|
||||||
|
const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
|
||||||
|
const HALF_N = N >> 1n;
|
||||||
|
|
||||||
|
const mod = (a, m = P) => ((a % m) + m) % m;
|
||||||
|
|
||||||
|
function powMod(base, exp, m) {
|
||||||
|
let result = 1n;
|
||||||
|
let b = mod(base, m);
|
||||||
|
let e = exp;
|
||||||
|
while (e > 0n) {
|
||||||
|
if (e & 1n) result = (result * b) % m;
|
||||||
|
b = (b * b) % m;
|
||||||
|
e >>= 1n;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both moduli here are prime, so Fermat gives the inverse without an
|
||||||
|
// extended-Euclid routine.
|
||||||
|
const invMod = (a, m) => powMod(a, m - 2n, m);
|
||||||
|
|
||||||
|
// Points are Jacobian [X, Y, Z]; affine is X/Z^2, Y/Z^3. One inversion per
|
||||||
|
// scalar multiply instead of one per bit.
|
||||||
|
const INF = [1n, 1n, 0n];
|
||||||
|
|
||||||
|
function jDouble([x, y, z]) {
|
||||||
|
if (z === 0n || y === 0n) return INF;
|
||||||
|
const a = mod(x * x);
|
||||||
|
const b = mod(y * y);
|
||||||
|
const c = mod(b * b);
|
||||||
|
const d = mod(2n * (mod((x + b) * (x + b)) - a - c));
|
||||||
|
const e = mod(3n * a);
|
||||||
|
const f = mod(e * e);
|
||||||
|
const x3 = mod(f - 2n * d);
|
||||||
|
const y3 = mod(e * (d - x3) - 8n * c);
|
||||||
|
const z3 = mod(2n * y * z);
|
||||||
|
return [x3, y3, z3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function jAdd(p1, p2) {
|
||||||
|
if (p1[2] === 0n) return p2;
|
||||||
|
if (p2[2] === 0n) return p1;
|
||||||
|
const [x1, y1, z1] = p1;
|
||||||
|
const [x2, y2, z2] = p2;
|
||||||
|
const z1z1 = mod(z1 * z1);
|
||||||
|
const z2z2 = mod(z2 * z2);
|
||||||
|
const u1 = mod(x1 * z2z2);
|
||||||
|
const u2 = mod(x2 * z1z1);
|
||||||
|
const s1 = mod(y1 * z2 * z2z2);
|
||||||
|
const s2 = mod(y2 * z1 * z1z1);
|
||||||
|
if (u1 === u2) return s1 === s2 ? jDouble(p1) : INF;
|
||||||
|
const h = mod(u2 - u1);
|
||||||
|
const i = mod(mod(2n * h) * mod(2n * h));
|
||||||
|
const j = mod(h * i);
|
||||||
|
const r = mod(2n * (s2 - s1));
|
||||||
|
const v = mod(u1 * i);
|
||||||
|
const x3 = mod(r * r - j - 2n * v);
|
||||||
|
const y3 = mod(r * (v - x3) - 2n * s1 * j);
|
||||||
|
const z3 = mod((mod((z1 + z2) * (z1 + z2)) - z1z1 - z2z2) * h);
|
||||||
|
return [x3, y3, z3];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAffine([x, y, z]) {
|
||||||
|
if (z === 0n) return null;
|
||||||
|
const zi = invMod(z, P);
|
||||||
|
const zi2 = mod(zi * zi);
|
||||||
|
return [mod(x * zi2), mod(y * zi2 * zi)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulPoint(k, px, py) {
|
||||||
|
k = mod(k, N);
|
||||||
|
if (k === 0n) return null;
|
||||||
|
const base = [px, py, 1n];
|
||||||
|
let acc = INF;
|
||||||
|
for (const bit of k.toString(2)) {
|
||||||
|
acc = jDouble(acc);
|
||||||
|
if (bit === "1") acc = jAdd(acc, base);
|
||||||
|
}
|
||||||
|
return toAffine(acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- bytes
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const hexToBytes = (hex) =>
|
||||||
|
Uint8Array.from(hex.match(/.{1,2}/g).map((b) => parseInt(b, 16)));
|
||||||
|
const bytesToHex = (b) =>
|
||||||
|
[...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||||
|
const bytesToBig = (b) => BigInt("0x" + (bytesToHex(b) || "0"));
|
||||||
|
const bigToBytes = (n, len = 32) => hexToBytes(n.toString(16).padStart(len * 2, "0"));
|
||||||
|
const concat = (...arrays) => {
|
||||||
|
const total = arrays.reduce((n, a) => n + a.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let at = 0;
|
||||||
|
for (const a of arrays) { out.set(a, at); at += a.length; }
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const toBase64 = (b) => btoa(String.fromCharCode(...b));
|
||||||
|
|
||||||
|
const sha256 = async (bytes) =>
|
||||||
|
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||||
|
|
||||||
|
async function hmac(hash, key, data) {
|
||||||
|
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash }, false, ["sign"]);
|
||||||
|
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- RIPEMD-160
|
||||||
|
//
|
||||||
|
// WebCrypto has no RIPEMD-160 and an address cannot be derived without it.
|
||||||
|
// Straight transcription of the reference implementation.
|
||||||
|
|
||||||
|
function ripemd160(message) {
|
||||||
|
const rl = [
|
||||||
|
0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,
|
||||||
|
7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,
|
||||||
|
3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,
|
||||||
|
1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,
|
||||||
|
4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13,
|
||||||
|
];
|
||||||
|
const rr = [
|
||||||
|
5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,
|
||||||
|
6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,
|
||||||
|
15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,
|
||||||
|
8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,
|
||||||
|
12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11,
|
||||||
|
];
|
||||||
|
const sl = [
|
||||||
|
11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,
|
||||||
|
7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,
|
||||||
|
11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,
|
||||||
|
11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,
|
||||||
|
9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6,
|
||||||
|
];
|
||||||
|
const sr = [
|
||||||
|
8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,
|
||||||
|
9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,
|
||||||
|
9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,
|
||||||
|
15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,
|
||||||
|
8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11,
|
||||||
|
];
|
||||||
|
const kl = [0x00000000, 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xa953fd4e];
|
||||||
|
const kr = [0x50a28be6, 0x5c4dd124, 0x6d703ef3, 0x7a6d76e9, 0x00000000];
|
||||||
|
|
||||||
|
const rol = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0;
|
||||||
|
const f = (j, x, y, z) => {
|
||||||
|
if (j < 16) return x ^ y ^ z;
|
||||||
|
if (j < 32) return (x & y) | (~x & z);
|
||||||
|
if (j < 48) return (x | ~y) ^ z;
|
||||||
|
if (j < 64) return (x & z) | (y & ~z);
|
||||||
|
return x ^ (y | ~z);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pad to 64-byte blocks: 0x80, zeros, then a 64-bit little-endian length.
|
||||||
|
const len = message.length;
|
||||||
|
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
|
||||||
|
withPad.set(message);
|
||||||
|
withPad[len] = 0x80;
|
||||||
|
const view = new DataView(withPad.buffer);
|
||||||
|
view.setUint32(withPad.length - 8, (len << 3) >>> 0, true);
|
||||||
|
view.setUint32(withPad.length - 4, Math.floor((len * 8) / 0x100000000), true);
|
||||||
|
|
||||||
|
let h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
|
||||||
|
const x = new Array(16);
|
||||||
|
|
||||||
|
for (let block = 0; block < withPad.length; block += 64) {
|
||||||
|
for (let i = 0; i < 16; i++) x[i] = view.getUint32(block + i * 4, true);
|
||||||
|
let [al, bl, cl, dl, el] = h;
|
||||||
|
let [ar, br, cr, dr, er] = h;
|
||||||
|
for (let j = 0; j < 80; j++) {
|
||||||
|
const round = Math.floor(j / 16);
|
||||||
|
let t = (al + f(j, bl, cl, dl) + x[rl[j]] + kl[round]) >>> 0;
|
||||||
|
t = (rol(t, sl[j]) + el) >>> 0;
|
||||||
|
al = el; el = dl; dl = rol(cl, 10); cl = bl; bl = t;
|
||||||
|
t = (ar + f(79 - j, br, cr, dr) + x[rr[j]] + kr[round]) >>> 0;
|
||||||
|
t = (rol(t, sr[j]) + er) >>> 0;
|
||||||
|
ar = er; er = dr; dr = rol(cr, 10); cr = br; br = t;
|
||||||
|
}
|
||||||
|
// The new state is a rotation of the old one: h0 takes the value built
|
||||||
|
// from h1, h1 from h2, and so on, with h4 wrapping back to h0.
|
||||||
|
h = [
|
||||||
|
(h[1] + cl + dr) >>> 0,
|
||||||
|
(h[2] + dl + er) >>> 0,
|
||||||
|
(h[3] + el + ar) >>> 0,
|
||||||
|
(h[4] + al + br) >>> 0,
|
||||||
|
(h[0] + bl + cr) >>> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Uint8Array(20);
|
||||||
|
const ov = new DataView(out.buffer);
|
||||||
|
h.forEach((word, i) => ov.setUint32(i * 4, word, true));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- CashAddr
|
||||||
|
|
||||||
|
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||||
|
|
||||||
|
function polyMod(values) {
|
||||||
|
// 40-bit accumulator, split across two 32-bit halves because JavaScript's
|
||||||
|
// bitwise operators truncate to 32 bits and BigInt here would be slower
|
||||||
|
// than the arithmetic is worth.
|
||||||
|
let c = 1n;
|
||||||
|
for (const d of values) {
|
||||||
|
const c0 = c >> 35n;
|
||||||
|
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
|
||||||
|
if (c0 & 0x01n) c ^= 0x98f2bc8e61n;
|
||||||
|
if (c0 & 0x02n) c ^= 0x79b76d99e2n;
|
||||||
|
if (c0 & 0x04n) c ^= 0xf33e5fb3c4n;
|
||||||
|
if (c0 & 0x08n) c ^= 0xae2eabe2a8n;
|
||||||
|
if (c0 & 0x10n) c ^= 0x1e4f43e470n;
|
||||||
|
}
|
||||||
|
return c ^ 1n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandPrefix = (prefix) => [...prefix].map((ch) => ch.charCodeAt(0) & 0x1f).concat([0]);
|
||||||
|
|
||||||
|
function convertBits(values, from, to, pad) {
|
||||||
|
let acc = 0;
|
||||||
|
let bits = 0;
|
||||||
|
const out = [];
|
||||||
|
const max = (1 << to) - 1;
|
||||||
|
for (const v of values) {
|
||||||
|
if (v < 0 || v >> from !== 0) return null;
|
||||||
|
acc = (acc << from) | v;
|
||||||
|
bits += from;
|
||||||
|
while (bits >= to) {
|
||||||
|
bits -= to;
|
||||||
|
out.push((acc >> bits) & max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pad) {
|
||||||
|
if (bits > 0) out.push((acc << (to - bits)) & max);
|
||||||
|
} else if (bits >= from || ((acc << (to - bits)) & max)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCashAddr(prefix, hash160, type = 0) {
|
||||||
|
const payload = concat(Uint8Array.of(type << 3), hash160);
|
||||||
|
const data = convertBits([...payload], 8, 5, true);
|
||||||
|
const checksum = polyMod(expandPrefix(prefix).concat(data, [0, 0, 0, 0, 0, 0, 0, 0]));
|
||||||
|
const cs = [];
|
||||||
|
for (let i = 0; i < 8; i++) cs.push(Number((checksum >> BigInt(5 * (7 - i))) & 0x1fn));
|
||||||
|
return prefix + ":" + data.concat(cs).map((v) => CHARSET[v]).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const addressFromPublicKey = async (pub, prefix) =>
|
||||||
|
encodeCashAddr(prefix, ripemd160(await sha256(pub)), 0);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- keys
|
||||||
|
|
||||||
|
async function publicKey(priv) {
|
||||||
|
const pt = mulPoint(bytesToBig(priv), GX, GY);
|
||||||
|
if (!pt) throw new Error("that key does not produce a valid public key");
|
||||||
|
return concat(Uint8Array.of(pt[1] & 1n ? 3 : 2), bigToBytes(pt[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIP-32 CKDpriv. Hardened steps use the private key, normal steps the
|
||||||
|
// public one — which is why this has to be async all the way down.
|
||||||
|
async function deriveChild(node, index) {
|
||||||
|
const hardened = index >= 0x80000000;
|
||||||
|
const indexBytes = new Uint8Array(4);
|
||||||
|
new DataView(indexBytes.buffer).setUint32(0, index, false);
|
||||||
|
const data = hardened
|
||||||
|
? concat(Uint8Array.of(0), node.key, indexBytes)
|
||||||
|
: concat(await publicKey(node.key), indexBytes);
|
||||||
|
const i = await hmac("SHA-512", node.chain, data);
|
||||||
|
const tweak = bytesToBig(i.slice(0, 32));
|
||||||
|
if (tweak >= N) throw new Error("derivation hit an invalid tweak");
|
||||||
|
const child = mod(tweak + bytesToBig(node.key), N);
|
||||||
|
if (child === 0n) throw new Error("derivation produced a zero key");
|
||||||
|
return { key: bigToBytes(child), chain: i.slice(32) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePath(seed, path) {
|
||||||
|
const i = await hmac("SHA-512", enc.encode("Bitcoin seed"), seed);
|
||||||
|
let node = { key: i.slice(0, 32), chain: i.slice(32) };
|
||||||
|
for (const part of path.split("/").slice(1)) {
|
||||||
|
if (!part) continue;
|
||||||
|
const hardened = /['h]$/i.test(part);
|
||||||
|
const num = parseInt(part.replace(/['h]$/i, ""), 10);
|
||||||
|
if (!Number.isInteger(num) || num < 0) throw new Error("bad derivation path: " + path);
|
||||||
|
node = await deriveChild(node, hardened ? num + 0x80000000 : num);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedFromPhrase(phrase, passphrase = "") {
|
||||||
|
const key = await crypto.subtle.importKey("raw", enc.encode(phrase), "PBKDF2", false, ["deriveBits"]);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: "PBKDF2", salt: enc.encode("mnemonic" + passphrase), iterations: 2048, hash: "SHA-512" },
|
||||||
|
key,
|
||||||
|
512,
|
||||||
|
);
|
||||||
|
return new Uint8Array(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- signing
|
||||||
|
|
||||||
|
const MAGIC = "Bitcoin Signed Message:\n";
|
||||||
|
|
||||||
|
function varStr(bytes) {
|
||||||
|
if (bytes.length < 0xfd) return concat(Uint8Array.of(bytes.length), bytes);
|
||||||
|
const len = new Uint8Array(3);
|
||||||
|
len[0] = 0xfd;
|
||||||
|
new DataView(len.buffer).setUint16(1, bytes.length, true);
|
||||||
|
return concat(len, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The digest every BCH wallet signs for a text message: double SHA-256 over
|
||||||
|
// varstr(magic) || varstr(message). Sirius Press verifies against this, so a
|
||||||
|
// signature made in Electron Cash or the Theseus wallet works identically.
|
||||||
|
async function messageDigest(message) {
|
||||||
|
const payload = concat(varStr(enc.encode(MAGIC)), varStr(enc.encode(message)));
|
||||||
|
return sha256(await sha256(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 6979: the nonce comes from the key and the message, never from the
|
||||||
|
// page's random source. Two signatures of the same thing are identical, and
|
||||||
|
// a bad RNG cannot leak the key.
|
||||||
|
async function* nonces(digest, priv) {
|
||||||
|
const h1 = bigToBytes(mod(bytesToBig(digest), N));
|
||||||
|
let v = new Uint8Array(32).fill(1);
|
||||||
|
let k = new Uint8Array(32).fill(0);
|
||||||
|
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(0), priv, h1));
|
||||||
|
v = await hmac("SHA-256", k, v);
|
||||||
|
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(1), priv, h1));
|
||||||
|
v = await hmac("SHA-256", k, v);
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
v = await hmac("SHA-256", k, v);
|
||||||
|
const candidate = bytesToBig(v);
|
||||||
|
if (candidate > 0n && candidate < N) yield candidate;
|
||||||
|
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(0)));
|
||||||
|
v = await hmac("SHA-256", k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signDigest(digest, priv) {
|
||||||
|
const z = mod(bytesToBig(digest), N);
|
||||||
|
const d = bytesToBig(priv);
|
||||||
|
for await (const k of nonces(digest, priv)) {
|
||||||
|
const pt = mulPoint(k, GX, GY);
|
||||||
|
if (!pt) continue;
|
||||||
|
const r = mod(pt[0], N);
|
||||||
|
if (r === 0n) continue;
|
||||||
|
let s = mod(invMod(k, N) * (z + r * d), N);
|
||||||
|
if (s === 0n) continue;
|
||||||
|
let recid = (pt[1] & 1n ? 1 : 0) | (pt[0] >= N ? 2 : 0);
|
||||||
|
if (s > HALF_N) {
|
||||||
|
s = N - s;
|
||||||
|
recid ^= 1;
|
||||||
|
}
|
||||||
|
return concat(Uint8Array.of(27 + 4 + recid), bigToBytes(r), bigToBytes(s));
|
||||||
|
}
|
||||||
|
throw new Error("could not produce a signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- public API
|
||||||
|
|
||||||
|
const normalizePhrase = (p) => String(p || "").trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
function validatePhrase(phrase) {
|
||||||
|
const clean = normalizePhrase(phrase);
|
||||||
|
if (!clean) return { ok: false, error: "Enter your recovery phrase." };
|
||||||
|
const words = clean.split(" ");
|
||||||
|
if (![12, 15, 18, 21, 24].includes(words.length)) {
|
||||||
|
return { ok: false, error: `A recovery phrase has 12, 15, 18, 21 or 24 words — this has ${words.length}.` };
|
||||||
|
}
|
||||||
|
const list = window.SIRIUS_BIP39_EN;
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
if (!list.includes(words[i])) {
|
||||||
|
return { ok: false, error: `Word ${i + 1}, “${words[i]}”, is not a recovery-phrase word.` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, error: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh phrase with a valid BIP-39 checksum, from the browser's CSPRNG. */
|
||||||
|
async function generatePhrase(wordCount = 12) {
|
||||||
|
const list = window.SIRIUS_BIP39_EN;
|
||||||
|
if (!Array.isArray(list) || list.length !== 2048) {
|
||||||
|
throw new Error("the word list has not loaded, so a phrase cannot be generated safely");
|
||||||
|
}
|
||||||
|
const entropyBits = (wordCount / 3) * 32;
|
||||||
|
const entropy = crypto.getRandomValues(new Uint8Array(entropyBits / 8));
|
||||||
|
const hash = await sha256(entropy);
|
||||||
|
let bits = [...entropy].map((b) => b.toString(2).padStart(8, "0")).join("");
|
||||||
|
bits += hash[0].toString(2).padStart(8, "0").slice(0, entropyBits / 32);
|
||||||
|
const words = [];
|
||||||
|
for (let i = 0; i < bits.length / 11; i++) {
|
||||||
|
words.push(list[parseInt(bits.slice(i * 11, i * 11 + 11), 2)]);
|
||||||
|
}
|
||||||
|
return words.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a wallet from a phrase.
|
||||||
|
*
|
||||||
|
* @returns {{address: string, sign: (message: string) => Promise<string>}}
|
||||||
|
*/
|
||||||
|
async function fromPhrase(phrase, { prefix = "bitcoincash", path = "m/44'/145'/0'/0/0" } = {}) {
|
||||||
|
const clean = normalizePhrase(phrase);
|
||||||
|
const check = validatePhrase(clean);
|
||||||
|
if (!check.ok) throw new Error(check.error);
|
||||||
|
const node = await derivePath(await seedFromPhrase(clean), path);
|
||||||
|
const pub = await publicKey(node.key);
|
||||||
|
const address = await addressFromPublicKey(pub, prefix);
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
async sign(message) {
|
||||||
|
return toBase64(await signDigest(await messageDigest(message), node.key));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Sign an already-computed 32-byte digest.
|
||||||
|
*
|
||||||
|
* The gateway's upload envelope (BNS-SITE1) is a single SHA-256 over a
|
||||||
|
* fixed line format, not the BIP-137 message scheme — so the static
|
||||||
|
* exporter, signing from the browser, needs this rather than sign().
|
||||||
|
* Handing a wallet a bare digest is a sharp tool: nothing about the
|
||||||
|
* bytes is legible to the person approving it, which is exactly why
|
||||||
|
* the interactive login path does not use it.
|
||||||
|
*/
|
||||||
|
async signRaw(digest) {
|
||||||
|
if (!(digest instanceof Uint8Array) || digest.length !== 32) {
|
||||||
|
throw new Error("signRaw expects a 32-byte digest");
|
||||||
|
}
|
||||||
|
return toBase64(await signDigest(digest, node.key));
|
||||||
|
},
|
||||||
|
/** sha256 of arbitrary bytes, so callers need no second hash library. */
|
||||||
|
sha256,
|
||||||
|
/** Drop the key material once the page is done with it. */
|
||||||
|
forget() {
|
||||||
|
node.key.fill(0);
|
||||||
|
node.chain.fill(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wallet the browser already provides, if there is one.
|
||||||
|
*
|
||||||
|
* Theseus exposes `window.bitcoincash` on `.x` origins, which signs with the
|
||||||
|
* same BIP-137 scheme. Using it means the key stays in the browser's own
|
||||||
|
* wallet and never touches this page — strictly better than asking for a
|
||||||
|
* phrase, so the login screen offers it first when it is there.
|
||||||
|
*/
|
||||||
|
function external() {
|
||||||
|
const bridge = window.bitcoincash;
|
||||||
|
if (!bridge || typeof bridge.signMessage !== "function") return null;
|
||||||
|
return {
|
||||||
|
name: "Theseus",
|
||||||
|
async address() {
|
||||||
|
const a = await bridge.getAddress();
|
||||||
|
return typeof a === "string" ? a : a && a.address ? a.address : "";
|
||||||
|
},
|
||||||
|
async sign(message) {
|
||||||
|
const r = await bridge.signMessage(message);
|
||||||
|
if (typeof r === "string") return r;
|
||||||
|
if (r && r.signature) return r.signature;
|
||||||
|
throw new Error("the wallet returned a signature this page did not understand");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.SiriusWallet = {
|
||||||
|
fromPhrase,
|
||||||
|
generatePhrase,
|
||||||
|
validatePhrase,
|
||||||
|
normalizePhrase,
|
||||||
|
external,
|
||||||
|
addressFromPublicKey,
|
||||||
|
// Exposed for the test page in tests/browser/, not used by the UI.
|
||||||
|
_internals: { signDigest, messageDigest, publicKey, ripemd160, encodeCashAddr },
|
||||||
|
};
|
||||||
|
})();
|
||||||
257
plugins/sirius-press-auth/includes/class-spa-challenge.php
Normal file
257
plugins/sirius-press-auth/includes/class-spa-challenge.php
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
284
plugins/sirius-press-auth/includes/class-spa-login.php
Normal file
284
plugins/sirius-press-auth/includes/class-spa-login.php
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
<?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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
155
plugins/sirius-press-auth/includes/class-spa-profile.php
Normal file
155
plugins/sirius-press-auth/includes/class-spa-profile.php
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Attaching a wallet to an account that does not have one.
|
||||||
|
*
|
||||||
|
* Two kinds of account arrive without an address: the one the installer made
|
||||||
|
* before anybody had signed anything, and any account created by a plugin
|
||||||
|
* that called `wp_insert_user()` directly. Both can sign in with a password —
|
||||||
|
* and then have no way to sign in once passwords are turned off. This screen
|
||||||
|
* is the bridge.
|
||||||
|
*
|
||||||
|
* It demands a signature rather than accepting a typed address. Typing an
|
||||||
|
* address proves nothing: a typo, or a copy-paste of an address belonging to
|
||||||
|
* somebody else, would attach an account to a key its owner cannot use, and
|
||||||
|
* the failure would only show up later at a login screen with no way back.
|
||||||
|
* Signing proves the key exists and is held, right now, by the person at the
|
||||||
|
* keyboard.
|
||||||
|
*
|
||||||
|
* Administrators can still set an address directly from the user editor,
|
||||||
|
* because account recovery has to be possible for someone. That path is in
|
||||||
|
* SP_Identity and is deliberately capability-gated.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPA_Profile {
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'show_user_profile', array( __CLASS__, 'render' ), 5 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
||||||
|
add_action( 'personal_options_update', array( __CLASS__, 'save' ), 5 );
|
||||||
|
add_action( 'admin_notices', array( __CLASS__, 'nag' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enqueue( $hook ) {
|
||||||
|
if ( 'profile.php' !== $hook ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SPA_Login::enqueue_wallet();
|
||||||
|
wp_enqueue_style(
|
||||||
|
'sirius-press-login',
|
||||||
|
SIRIUS_PRESS_AUTH_URL . 'assets/login.css',
|
||||||
|
array(),
|
||||||
|
SIRIUS_PRESS_AUTH_VERSION
|
||||||
|
);
|
||||||
|
add_action( 'admin_footer', array( 'SPA_Login', 'print_config' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn an administrator who cannot yet sign in the way this site works.
|
||||||
|
*
|
||||||
|
* Shown before they turn passwords off, not after — after is too late.
|
||||||
|
*/
|
||||||
|
public static function nag() {
|
||||||
|
if ( ! is_user_logged_in() || '' !== sirius_press_address() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
|
||||||
|
if ( $screen && 'profile' === $screen->id ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
|
||||||
|
esc_html__( 'This account has no wallet attached, so it can only sign in with a password. If passwords are turned off, it will be locked out.', 'sirius-press' ),
|
||||||
|
esc_url( admin_url( 'profile.php#sirius-wallet-link' ) ),
|
||||||
|
esc_html__( 'Attach a wallet', 'sirius-press' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render( $user ) {
|
||||||
|
$current = SP_Identity::address_of( $user->ID );
|
||||||
|
$nonce = SPA_Challenge::issue();
|
||||||
|
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_LINK );
|
||||||
|
?>
|
||||||
|
<h2 id="sirius-wallet-link"><?php esc_html_e( 'Wallet sign-in', 'sirius-press' ); ?></h2>
|
||||||
|
<table class="form-table" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Current wallet', 'sirius-press' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php if ( '' !== $current ) : ?>
|
||||||
|
<code><?php echo esc_html( $current ); ?></code>
|
||||||
|
<p class="description"><?php esc_html_e( 'Signing with the key for this address signs you in.', 'sirius-press' ); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<p><strong><?php esc_html_e( 'None yet.', 'sirius-press' ); ?></strong>
|
||||||
|
<?php esc_html_e( 'This account can only sign in with a password until a wallet is attached.', 'sirius-press' ); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo '' !== $current ? esc_html__( 'Change wallet', 'sirius-press' ) : esc_html__( 'Attach a wallet', 'sirius-press' ); ?></th>
|
||||||
|
<td>
|
||||||
|
<?php if ( '' !== $current ) : ?>
|
||||||
|
<p class="description" style="margin-bottom:10px">
|
||||||
|
<?php esc_html_e( 'Signing with a different key moves this account to that key. The old one stops working immediately.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php
|
||||||
|
SPA_Login::render_signing_block(
|
||||||
|
$nonce,
|
||||||
|
$message,
|
||||||
|
SPA_Challenge::PURPOSE_LINK,
|
||||||
|
'' !== $current ? __( 'Sign with the new wallet', 'sirius-press' ) : __( 'Sign to attach', 'sirius-press' )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Signing fills the box above; saving this page applies it.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function save( $user_id ) {
|
||||||
|
// WordPress has already run check_admin_referer( 'update-user_' . $id )
|
||||||
|
// before this action fires.
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
|
if ( empty( $_POST['sirius_signature'] ) || empty( $_POST['sirius_nonce'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
|
||||||
|
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) );
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|
||||||
|
if ( get_current_user_id() !== (int) $user_id ) {
|
||||||
|
return; // Only the account holder attaches their own key here.
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LINK );
|
||||||
|
if ( is_wp_error( $address ) ) {
|
||||||
|
SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $address->get_error_message(), 'auth' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = SP_Identity::address_of( $user_id );
|
||||||
|
$result = SP_Identity::set_address( $user_id, $address );
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $result->get_error_message(), 'auth' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SP_Inbox::add(
|
||||||
|
$user_id,
|
||||||
|
__( 'Wallet attached', 'sirius-press' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: new address, 2: previous address or a note that there was none. */
|
||||||
|
esc_html__( 'This account now signs in with %1$s. Previously: %2$s.', 'sirius-press' ),
|
||||||
|
'<code>' . esc_html( $address ) . '</code>',
|
||||||
|
'' !== $previous ? '<code>' . esc_html( $previous ) . '</code>' : esc_html__( 'no wallet', 'sirius-press' )
|
||||||
|
),
|
||||||
|
'auth'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
plugins/sirius-press-auth/includes/class-spa-recovery.php
Normal file
104
plugins/sirius-press-auth/includes/class-spa-recovery.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* What happens when someone clicks "Lost your password?".
|
||||||
|
*
|
||||||
|
* On stock WordPress that link starts a mail round-trip: the server sends a
|
||||||
|
* reset token to an address it has on file, and whoever reads that mailbox
|
||||||
|
* gets the account. There is no equivalent here, and pretending otherwise
|
||||||
|
* would be the worst thing this plugin could do.
|
||||||
|
*
|
||||||
|
* So the page is honest. It explains that the site cannot reset anything,
|
||||||
|
* because it never held anything to reset — no mailbox, no password, no
|
||||||
|
* recovery key. What it *can* do is help: if the visitor still has their
|
||||||
|
* recovery phrase, the in-page wallet derives the address and they can sign
|
||||||
|
* straight back in. If they do not, the account is gone, and saying so
|
||||||
|
* plainly is better than a reset form that quietly does nothing.
|
||||||
|
*
|
||||||
|
* The one recovery route that does exist is social, not technical: an
|
||||||
|
* administrator can change the address on an account from the user editor.
|
||||||
|
* That is deliberate — it means account recovery on a Sirius Press site is a
|
||||||
|
* decision a human makes, not a capability an attacker can trigger by
|
||||||
|
* compromising a mailbox.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPA_Recovery {
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'login_form_lostpassword', array( __CLASS__, 'render' ) );
|
||||||
|
add_action( 'login_form_retrievepassword', array( __CLASS__, 'render' ) );
|
||||||
|
// `resetpass` / `rp` only ever arrive from a link this site can no
|
||||||
|
// longer send; treat them as the dead ends they are.
|
||||||
|
add_action( 'login_form_resetpass', array( __CLASS__, 'render' ) );
|
||||||
|
add_action( 'login_form_rp', array( __CLASS__, 'render' ) );
|
||||||
|
add_filter( 'lostpassword_url', array( __CLASS__, 'url' ), 10, 0 );
|
||||||
|
add_filter( 'allow_password_reset', '__return_false' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function url() {
|
||||||
|
return add_query_arg( 'action', 'lostpassword', wp_login_url() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render() {
|
||||||
|
if ( SPA_Login::passwords_allowed() && ! empty( $_GET['sirius_force'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
login_header( __( 'Recovering an account', 'sirius-press' ) );
|
||||||
|
SPA_Login::print_config();
|
||||||
|
?>
|
||||||
|
<div class="sirius-recovery">
|
||||||
|
<p class="message">
|
||||||
|
<?php esc_html_e( 'This site cannot reset your account, and that is on purpose.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<?php esc_html_e( 'There is no password on file to replace and no email address to send a link to. Your account is a wallet — whoever can sign with its key is the account holder, and this site has never been able to sign on your behalf.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'If you still have your recovery phrase', 'sirius-press' ); ?></h2>
|
||||||
|
<p>
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %s: sign-in URL. */
|
||||||
|
wp_kses_post( __( 'You are not locked out at all. Go to <a href="%s">the sign-in page</a>, enter your phrase there, and sign the challenge. Nothing needs recovering.', 'sirius-press' ) ),
|
||||||
|
esc_url( wp_login_url() )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'If you have lost the phrase', 'sirius-press' ); ?></h2>
|
||||||
|
<p>
|
||||||
|
<?php esc_html_e( 'Then the key is gone, and so is the account — no part of this site can bring it back. The only route left is to ask an administrator to point your account at a new wallet address, which is a decision they make about a person they recognise, not something a form can do.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<?php esc_html_e( 'If you can still sign in with a different wallet, create a new account and ask an administrator to move your content across.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Checking which address a phrase belongs to', 'sirius-press' ); ?></h2>
|
||||||
|
<p class="sirius-hint">
|
||||||
|
<?php esc_html_e( 'If you have several phrases and are not sure which one this account uses, work it out here. The phrase is not sent anywhere — the address is computed in this page.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
<div class="sirius-derive">
|
||||||
|
<label for="sirius_derive_phrase"><?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?></label>
|
||||||
|
<textarea id="sirius_derive_phrase" rows="3" autocomplete="off" spellcheck="false"></textarea>
|
||||||
|
<p>
|
||||||
|
<button type="button" class="button" id="sirius_derive_go">
|
||||||
|
<?php esc_html_e( 'Show the address', 'sirius-press' ); ?>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="sirius-derive__out" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="nav">
|
||||||
|
<a href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( '← Sign in', 'sirius-press' ); ?></a>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
login_footer();
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
163
plugins/sirius-press-auth/includes/class-spa-register.php
Normal file
163
plugins/sirius-press-auth/includes/class-spa-register.php
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Creating an account without an email address.
|
||||||
|
*
|
||||||
|
* Stock WordPress registration is a two-step dance around a mailbox: you hand
|
||||||
|
* over an address, it sends you a link, you click it, and the click is the
|
||||||
|
* proof. The proof here is a signature, which arrives in the same request —
|
||||||
|
* so registration is one step, and there is no pending state, no confirmation
|
||||||
|
* link to expire, and no way for a typo'd address to strand an account.
|
||||||
|
*
|
||||||
|
* This lives at `wp-login.php?action=sirius_register` rather than replacing
|
||||||
|
* core's `action=register`, so the plugins work unmodified on a stock
|
||||||
|
* WordPress too. The core patch in `patches/` is then a two-line redirect
|
||||||
|
* instead of a rewritten form — which is the whole reason the fork's diff
|
||||||
|
* against upstream stays small enough to merge security releases into.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPA_Register {
|
||||||
|
|
||||||
|
const ACTION = 'sirius_register';
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'login_form_' . self::ACTION, array( __CLASS__, 'handle' ) );
|
||||||
|
add_filter( 'register_url', array( __CLASS__, 'filter_register_url' ) );
|
||||||
|
// Core's own registration form cannot work here — it requires an email
|
||||||
|
// address — so send anyone who reaches it to the wallet flow.
|
||||||
|
add_action( 'login_form_register', array( __CLASS__, 'redirect_core_register' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function url() {
|
||||||
|
return add_query_arg( 'action', self::ACTION, wp_login_url() );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function filter_register_url( $url ) {
|
||||||
|
return self::url();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redirect_core_register() {
|
||||||
|
wp_safe_redirect( self::url() );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render and process the registration screen. */
|
||||||
|
public static function handle() {
|
||||||
|
if ( is_user_logged_in() ) {
|
||||||
|
wp_safe_redirect( admin_url() );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = new WP_Error();
|
||||||
|
|
||||||
|
if ( 'POST' === strtoupper( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET' ) ) {
|
||||||
|
$result = self::process();
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
$errors = $result;
|
||||||
|
} else {
|
||||||
|
// Straight in. There is nothing left to confirm, so making
|
||||||
|
// someone sign a second time to start their session would be
|
||||||
|
// ceremony, not security.
|
||||||
|
wp_set_current_user( $result );
|
||||||
|
wp_set_auth_cookie( $result, false );
|
||||||
|
do_action( 'wp_login', get_userdata( $result )->user_login, get_userdata( $result ) );
|
||||||
|
wp_safe_redirect( apply_filters( 'sirius_press_after_register_redirect', admin_url(), $result ) );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::render( $errors );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int|WP_Error User ID.
|
||||||
|
*/
|
||||||
|
private static function process() {
|
||||||
|
if ( ! SP_Settings::open_registration() ) {
|
||||||
|
return new WP_Error( 'sirius_closed', __( 'This site is not accepting new accounts.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$limited = SPA_Challenge::check_rate_limit( 'register', 10, 900 );
|
||||||
|
if ( is_wp_error( $limited ) ) {
|
||||||
|
return $limited;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Missing -- the signed challenge is the anti-forgery proof.
|
||||||
|
$nonce = isset( $_POST['sirius_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) ) : '';
|
||||||
|
$signature = isset( $_POST['sirius_signature'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) ) : '';
|
||||||
|
$login = isset( $_POST['user_login'] ) ? sanitize_user( wp_unslash( $_POST['user_login'] ), true ) : '';
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
|
||||||
|
if ( '' === $signature || '' === $nonce ) {
|
||||||
|
return new WP_Error( 'sirius_missing', __( 'Sign the text above before submitting.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_REGISTER );
|
||||||
|
if ( is_wp_error( $address ) ) {
|
||||||
|
return $address;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = SP_Identity::user_by_address( $address );
|
||||||
|
if ( $existing ) {
|
||||||
|
return new WP_Error(
|
||||||
|
'sirius_exists',
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: login URL. */
|
||||||
|
__( 'That wallet already has an account here. <a href="%s">Sign in</a> instead.', 'sirius-press' ),
|
||||||
|
esc_url( wp_login_url() )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( '' !== $login && username_exists( $login ) ) {
|
||||||
|
return new WP_Error( 'sirius_username_taken', __( 'That username is taken. Pick another.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
return SP_Identity::create_user( $address, $login );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function render( WP_Error $errors ) {
|
||||||
|
$nonce = SPA_Challenge::issue();
|
||||||
|
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_REGISTER );
|
||||||
|
$open = SP_Settings::open_registration();
|
||||||
|
|
||||||
|
login_header(
|
||||||
|
__( 'Create an account', 'sirius-press' ),
|
||||||
|
'',
|
||||||
|
$errors
|
||||||
|
);
|
||||||
|
SPA_Login::print_config();
|
||||||
|
?>
|
||||||
|
<form name="siriusregisterform" id="siriusregisterform" action="<?php echo esc_url( self::url() ); ?>" method="post">
|
||||||
|
<?php if ( ! $open ) : ?>
|
||||||
|
<p><?php esc_html_e( 'This site is not accepting new accounts right now.', 'sirius-press' ); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<p>
|
||||||
|
<label for="user_login"><?php esc_html_e( 'Username (optional)', 'sirius-press' ); ?></label>
|
||||||
|
<input type="text" name="user_login" id="user_login" class="input" value="" size="20"
|
||||||
|
autocapitalize="off" autocomplete="username" />
|
||||||
|
</p>
|
||||||
|
<p class="description sirius-hint">
|
||||||
|
<?php esc_html_e( 'Leave blank and one is made from your address. There is no email field, and there will not be a confirmation link — the signature below is the confirmation.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php SPA_Login::render_signing_block( $nonce, $message, SPA_Challenge::PURPOSE_REGISTER, __( 'Create account', 'sirius-press' ) ); ?>
|
||||||
|
|
||||||
|
<p class="submit">
|
||||||
|
<input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large"
|
||||||
|
value="<?php esc_attr_e( 'Create account', 'sirius-press' ); ?>" />
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p id="nav">
|
||||||
|
<a href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( '← Sign in', 'sirius-press' ); ?></a>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
login_footer();
|
||||||
|
}
|
||||||
|
}
|
||||||
248
plugins/sirius-press-auth/includes/class-spa-rest.php
Normal file
248
plugins/sirius-press-auth/includes/class-spa-rest.php
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The wallet-auth REST surface.
|
||||||
|
*
|
||||||
|
* Two audiences. Themes and front-end code that want a sign-in form somewhere
|
||||||
|
* other than `wp-login.php`, and other plugins that want to demand a fresh
|
||||||
|
* signature before doing something irreversible — deleting a site, moving
|
||||||
|
* money, transferring a name. The second is the reason `confirm` exists: a
|
||||||
|
* capability check proves what a session is allowed to do, but it cannot
|
||||||
|
* prove that the person holding the key is still at the keyboard. A fresh
|
||||||
|
* signature can.
|
||||||
|
*
|
||||||
|
* Everything here is unauthenticated by design except `confirm`, and every
|
||||||
|
* endpoint that does curve maths is rate limited, because signature recovery
|
||||||
|
* is the most expensive thing a stranger can make this server do.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPA_REST {
|
||||||
|
|
||||||
|
const NS = 'sirius-press/v1';
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register_routes() {
|
||||||
|
register_rest_route(
|
||||||
|
self::NS,
|
||||||
|
'/challenge',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'callback' => array( __CLASS__, 'challenge' ),
|
||||||
|
'args' => array(
|
||||||
|
'purpose' => array(
|
||||||
|
'type' => 'string',
|
||||||
|
'default' => SPA_Challenge::PURPOSE_LOGIN,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
register_rest_route(
|
||||||
|
self::NS,
|
||||||
|
'/login',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'callback' => array( __CLASS__, 'login' ),
|
||||||
|
'args' => self::signature_args(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
register_rest_route(
|
||||||
|
self::NS,
|
||||||
|
'/register',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'callback' => array( __CLASS__, 'register' ),
|
||||||
|
'args' => self::signature_args(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
register_rest_route(
|
||||||
|
self::NS,
|
||||||
|
'/confirm',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return is_user_logged_in();
|
||||||
|
},
|
||||||
|
'callback' => array( __CLASS__, 'confirm' ),
|
||||||
|
'args' => self::signature_args(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function signature_args() {
|
||||||
|
return array(
|
||||||
|
'nonce' => array(
|
||||||
|
'type' => 'string',
|
||||||
|
'required' => true,
|
||||||
|
),
|
||||||
|
'signature' => array(
|
||||||
|
'type' => 'string',
|
||||||
|
'required' => true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand out something to sign. */
|
||||||
|
public static function challenge( WP_REST_Request $request ) {
|
||||||
|
$limited = SPA_Challenge::check_rate_limit( 'challenge', 60, 300 );
|
||||||
|
if ( is_wp_error( $limited ) ) {
|
||||||
|
return $limited;
|
||||||
|
}
|
||||||
|
$purpose = (string) $request->get_param( 'purpose' );
|
||||||
|
if ( ! SPA_Challenge::is_known_purpose( $purpose ) ) {
|
||||||
|
return new WP_Error( 'sirius_bad_purpose', __( 'Unknown purpose.', 'sirius-press' ), array( 'status' => 400 ) );
|
||||||
|
}
|
||||||
|
if ( SPA_Challenge::PURPOSE_REGISTER === $purpose && ! SP_Settings::open_registration() ) {
|
||||||
|
return new WP_Error( 'sirius_closed', __( 'This site is not accepting new accounts.', 'sirius-press' ), array( 'status' => 403 ) );
|
||||||
|
}
|
||||||
|
$nonce = SPA_Challenge::issue();
|
||||||
|
return rest_ensure_response(
|
||||||
|
array(
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'message' => SPA_Challenge::message( $nonce, $purpose ),
|
||||||
|
'purpose' => $purpose,
|
||||||
|
'expires_in' => SPA_Challenge::TTL,
|
||||||
|
'prefix' => SP_Settings::prefix(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function login( WP_REST_Request $request ) {
|
||||||
|
$limited = SPA_Challenge::check_rate_limit( 'login' );
|
||||||
|
if ( is_wp_error( $limited ) ) {
|
||||||
|
return $limited;
|
||||||
|
}
|
||||||
|
$address = SPA_Challenge::verify(
|
||||||
|
(string) $request->get_param( 'nonce' ),
|
||||||
|
(string) $request->get_param( 'signature' ),
|
||||||
|
SPA_Challenge::PURPOSE_LOGIN
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $address ) ) {
|
||||||
|
return self::with_status( $address, 401 );
|
||||||
|
}
|
||||||
|
$user = SP_Identity::user_by_address( $address );
|
||||||
|
if ( ! $user ) {
|
||||||
|
return new WP_Error(
|
||||||
|
'sirius_no_account',
|
||||||
|
__( 'No account on this site uses that wallet.', 'sirius-press' ),
|
||||||
|
array(
|
||||||
|
'status' => 404,
|
||||||
|
'address' => $address,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$remember = (bool) $request->get_param( 'remember' );
|
||||||
|
wp_set_current_user( $user->ID );
|
||||||
|
wp_set_auth_cookie( $user->ID, $remember );
|
||||||
|
do_action( 'wp_login', $user->user_login, $user );
|
||||||
|
do_action( 'sirius_press_wallet_authenticated', $user, $address );
|
||||||
|
|
||||||
|
return rest_ensure_response(
|
||||||
|
array(
|
||||||
|
'ok' => true,
|
||||||
|
'user_id' => (int) $user->ID,
|
||||||
|
'address' => $address,
|
||||||
|
'redirect' => user_can( $user, 'read' ) ? admin_url() : home_url( '/' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function register( WP_REST_Request $request ) {
|
||||||
|
if ( ! SP_Settings::open_registration() ) {
|
||||||
|
return new WP_Error( 'sirius_closed', __( 'This site is not accepting new accounts.', 'sirius-press' ), array( 'status' => 403 ) );
|
||||||
|
}
|
||||||
|
$limited = SPA_Challenge::check_rate_limit( 'register', 10, 900 );
|
||||||
|
if ( is_wp_error( $limited ) ) {
|
||||||
|
return $limited;
|
||||||
|
}
|
||||||
|
$address = SPA_Challenge::verify(
|
||||||
|
(string) $request->get_param( 'nonce' ),
|
||||||
|
(string) $request->get_param( 'signature' ),
|
||||||
|
SPA_Challenge::PURPOSE_REGISTER
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $address ) ) {
|
||||||
|
return self::with_status( $address, 400 );
|
||||||
|
}
|
||||||
|
$user_id = SP_Identity::create_user( $address, (string) $request->get_param( 'user_login' ) );
|
||||||
|
if ( is_wp_error( $user_id ) ) {
|
||||||
|
return self::with_status( $user_id, 409 );
|
||||||
|
}
|
||||||
|
wp_set_current_user( $user_id );
|
||||||
|
wp_set_auth_cookie( $user_id, false );
|
||||||
|
return rest_ensure_response(
|
||||||
|
array(
|
||||||
|
'ok' => true,
|
||||||
|
'user_id' => (int) $user_id,
|
||||||
|
'address' => $address,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-prove the current user's key.
|
||||||
|
*
|
||||||
|
* Returns 403 rather than 200-with-false when the signature belongs to
|
||||||
|
* somebody else's wallet, so a caller that forgets to check the body still
|
||||||
|
* fails closed.
|
||||||
|
*/
|
||||||
|
public static function confirm( WP_REST_Request $request ) {
|
||||||
|
$limited = SPA_Challenge::check_rate_limit( 'confirm', 30, 300 );
|
||||||
|
if ( is_wp_error( $limited ) ) {
|
||||||
|
return $limited;
|
||||||
|
}
|
||||||
|
$address = SPA_Challenge::verify(
|
||||||
|
(string) $request->get_param( 'nonce' ),
|
||||||
|
(string) $request->get_param( 'signature' ),
|
||||||
|
SPA_Challenge::PURPOSE_CONFIRM
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $address ) ) {
|
||||||
|
return self::with_status( $address, 400 );
|
||||||
|
}
|
||||||
|
$mine = SP_Identity::address_of( get_current_user_id() );
|
||||||
|
if ( '' === $mine || ! hash_equals( $mine, $address ) ) {
|
||||||
|
return new WP_Error(
|
||||||
|
'sirius_wrong_wallet',
|
||||||
|
__( 'That signature is from a different wallet than the one signed in.', 'sirius-press' ),
|
||||||
|
array( 'status' => 403 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fires when a signed-in user re-proves their key.
|
||||||
|
*
|
||||||
|
* @param int $user_id
|
||||||
|
* @param string $address
|
||||||
|
*/
|
||||||
|
do_action( 'sirius_press_wallet_confirmed', get_current_user_id(), $address );
|
||||||
|
return rest_ensure_response(
|
||||||
|
array(
|
||||||
|
'ok' => true,
|
||||||
|
'address' => $address,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach an HTTP status to a WP_Error that was created without one. */
|
||||||
|
private static function with_status( WP_Error $error, $status ) {
|
||||||
|
$data = $error->get_error_data();
|
||||||
|
if ( ! is_array( $data ) ) {
|
||||||
|
$data = array();
|
||||||
|
}
|
||||||
|
if ( empty( $data['status'] ) ) {
|
||||||
|
$data['status'] = $status;
|
||||||
|
}
|
||||||
|
$error->add_data( $data, $error->get_error_code() );
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
plugins/sirius-press-auth/includes/class-spa-settings.php
Normal file
143
plugins/sirius-press-auth/includes/class-spa-settings.php
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The one auth decision a site owner has to make.
|
||||||
|
*
|
||||||
|
* Passwords stay on by default, and that is not timidity — a fresh install
|
||||||
|
* has exactly one account, created by the installer, with no wallet attached
|
||||||
|
* yet. Shipping with passwords off would mean the first thing a new site does
|
||||||
|
* is lock out its own administrator.
|
||||||
|
*
|
||||||
|
* The screen therefore refuses to turn passwords off until the person doing
|
||||||
|
* it has a wallet on their own account. That check is the difference between
|
||||||
|
* a setting and a trapdoor.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPA_Settings {
|
||||||
|
|
||||||
|
const PAGE = 'sirius-press-auth';
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'admin_menu', array( __CLASS__, 'menu' ), 20 );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function menu() {
|
||||||
|
add_submenu_page(
|
||||||
|
'sirius-press',
|
||||||
|
__( 'Sign-in', 'sirius-press' ),
|
||||||
|
__( 'Sign-in', 'sirius-press' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE,
|
||||||
|
array( __CLASS__, 'render' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_auth_settings'] ) && check_admin_referer( 'sirius_auth_settings' ) ) {
|
||||||
|
$want_passwords = ! empty( $_POST['sirius_allow_passwords'] );
|
||||||
|
|
||||||
|
if ( ! $want_passwords && '' === sirius_press_address() ) {
|
||||||
|
$errors[] = __( 'Attach a wallet to your own account first. Turning passwords off right now would lock you out of this site with no way back in.', 'sirius-press' );
|
||||||
|
} else {
|
||||||
|
update_option( SPA_Login::OPT_ALLOW_PASSWORDS, $want_passwords );
|
||||||
|
$notes[] = $want_passwords
|
||||||
|
? __( 'Password sign-in is on.', 'sirius-press' )
|
||||||
|
: __( 'Password sign-in is off. Only wallet signatures are accepted from now on.', 'sirius-press' );
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option( SP_Settings::OPT_OPEN_REG, ! empty( $_POST['sirius_open_registration'] ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$stranded = self::accounts_without_wallets();
|
||||||
|
|
||||||
|
echo '<div class="wrap"><h1>' . esc_html__( 'Sign-in', 'sirius-press' ) . '</h1>';
|
||||||
|
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>', esc_html( $n ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<form method="post">';
|
||||||
|
wp_nonce_field( 'sirius_auth_settings' );
|
||||||
|
echo '<input type="hidden" name="sirius_auth_settings" value="1">';
|
||||||
|
echo '<table class="form-table" role="presentation">';
|
||||||
|
|
||||||
|
echo '<tr><th>' . esc_html__( 'Passwords', 'sirius-press' ) . '</th><td><label>';
|
||||||
|
printf(
|
||||||
|
'<input type="checkbox" name="sirius_allow_passwords" value="1"%s> %s',
|
||||||
|
checked( SPA_Login::passwords_allowed(), true, false ),
|
||||||
|
esc_html__( 'Also accept username and password sign-in', 'sirius-press' )
|
||||||
|
);
|
||||||
|
echo '</label><p class="description">';
|
||||||
|
esc_html_e( 'Leave this on until every account that needs access has a wallet attached. With it off, a wallet signature is the only way in — which is the point of this fork, but it is also final.', 'sirius-press' );
|
||||||
|
echo '</p></td></tr>';
|
||||||
|
|
||||||
|
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>';
|
||||||
|
|
||||||
|
echo '</table>';
|
||||||
|
submit_button();
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
echo '<h2>' . esc_html__( 'Accounts without a wallet', 'sirius-press' ) . '</h2>';
|
||||||
|
if ( ! $stranded ) {
|
||||||
|
echo '<p>' . esc_html__( 'Every account here can sign in with a wallet.', 'sirius-press' ) . '</p>';
|
||||||
|
} else {
|
||||||
|
echo '<p>' . esc_html__( 'These accounts can only sign in with a password. Turning passwords off locks them out.', 'sirius-press' ) . '</p>';
|
||||||
|
echo '<table class="widefat striped" style="max-width:640px"><thead><tr><th>'
|
||||||
|
. esc_html__( 'Account', 'sirius-press' ) . '</th><th>'
|
||||||
|
. esc_html__( 'Role', 'sirius-press' ) . '</th></tr></thead><tbody>';
|
||||||
|
foreach ( $stranded as $user ) {
|
||||||
|
printf(
|
||||||
|
'<tr><td><a href="%s">%s</a></td><td>%s</td></tr>',
|
||||||
|
esc_url( get_edit_user_link( $user->ID ) ),
|
||||||
|
esc_html( $user->user_login ),
|
||||||
|
esc_html( implode( ', ', $user->roles ) )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<h2>' . esc_html__( 'For other plugins', 'sirius-press' ) . '</h2>';
|
||||||
|
echo '<p>' . esc_html__( 'A plugin that wants a fresh signature before something irreversible can call sirius_press_confirmation_challenge() and post the answer to:', 'sirius-press' ) . '</p>';
|
||||||
|
echo '<p><code>' . esc_html( rest_url( 'sirius-press/v1/confirm' ) ) . '</code></p>';
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accounts that would be locked out if passwords were turned off. */
|
||||||
|
private static function accounts_without_wallets() {
|
||||||
|
return get_users(
|
||||||
|
array(
|
||||||
|
'meta_query' => array(
|
||||||
|
'relation' => 'OR',
|
||||||
|
array(
|
||||||
|
'key' => SP_Identity::META_ADDRESS,
|
||||||
|
'compare' => 'NOT EXISTS',
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'key' => SP_Identity::META_ADDRESS,
|
||||||
|
'value' => '',
|
||||||
|
'compare' => '=',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'number' => 50,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
plugins/sirius-press-auth/sirius-press-auth.php
Normal file
75
plugins/sirius-press-auth/sirius-press-auth.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Sirius Press Auth
|
||||||
|
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||||
|
* Description: Sign in by signing a challenge with a Bitcoin Cash wallet. Replaces email as the account identity; leaves roles, capabilities and sessions exactly as WordPress made them.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Requires at least: 6.5
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Requires Plugins: sirius-press-core
|
||||||
|
* Author: Silent Mode
|
||||||
|
* Author URI: https://silentmode.st
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
* Text Domain: sirius-press
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'SIRIUS_PRESS_AUTH_VERSION', '0.1.0' );
|
||||||
|
define( 'SIRIUS_PRESS_AUTH_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'SIRIUS_PRESS_AUTH_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'plugins_loaded',
|
||||||
|
function () {
|
||||||
|
// `Requires Plugins` keeps this from being activated without the core
|
||||||
|
// plugin, but an installation can still be mid-upgrade or have core
|
||||||
|
// deactivated by hand. Failing with a notice beats a fatal on the
|
||||||
|
// login screen, which would lock everyone out of the site.
|
||||||
|
if ( ! class_exists( 'SP_Message' ) ) {
|
||||||
|
add_action(
|
||||||
|
'admin_notices',
|
||||||
|
function () {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-error"><p>%s</p></div>',
|
||||||
|
esc_html__( 'Sirius Press Auth needs Sirius Press Core to be active. Wallet sign-in is off until it is.', 'sirius-press' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array( 'challenge', 'login', 'register', 'recovery', 'rest', 'profile', 'settings' ) as $part ) {
|
||||||
|
require_once SIRIUS_PRESS_AUTH_DIR . 'includes/class-spa-' . $part . '.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
SPA_Login::hooks();
|
||||||
|
SPA_Register::hooks();
|
||||||
|
SPA_Recovery::hooks();
|
||||||
|
SPA_REST::hooks();
|
||||||
|
SPA_Profile::hooks();
|
||||||
|
SPA_Settings::hooks();
|
||||||
|
},
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the current user to re-prove their key before something irreversible.
|
||||||
|
*
|
||||||
|
* Intended for other plugins: hand it a purpose string and it returns the
|
||||||
|
* challenge to render; verify the answer with the `/sirius-press/v1/confirm`
|
||||||
|
* endpoint. Exposed as a plain function so a plugin does not have to know
|
||||||
|
* this fork's class names to use it.
|
||||||
|
*
|
||||||
|
* @return array{nonce:string,message:string}
|
||||||
|
*/
|
||||||
|
function sirius_press_confirmation_challenge() {
|
||||||
|
$nonce = SPA_Challenge::issue();
|
||||||
|
return array(
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'message' => SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_CONFIRM ),
|
||||||
|
);
|
||||||
|
}
|
||||||
192
plugins/sirius-press-compat/sirius-press-compat.php
Normal file
192
plugins/sirius-press-compat/sirius-press-compat.php
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Sirius Press Compatibility
|
||||||
|
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||||
|
* Description: Keeps plugins that hard-require an administrator email address installable and activatable on a site that has none.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Requires at least: 6.5
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Requires Plugins: sirius-press-core
|
||||||
|
* Author: Silent Mode
|
||||||
|
* Author URI: https://silentmode.st
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
* Text Domain: sirius-press
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* A large part of the WordPress ecosystem assumes `get_option('admin_email')`
|
||||||
|
* returns something, and a smaller but important part refuses to finish its
|
||||||
|
* setup wizard if it does not. WooCommerce wants a store address for order
|
||||||
|
* emails. Contact Form 7 defaults a form's "To" field to it. Yoast reads it
|
||||||
|
* while building schema. None of these are wrong to do so — they were written
|
||||||
|
* for a WordPress where that value always exists.
|
||||||
|
*
|
||||||
|
* Rather than patch each of them, this plugin makes the assumption true: the
|
||||||
|
* site always has an admin email, and it is permanently undeliverable. The
|
||||||
|
* `.invalid` top-level domain is reserved by RFC 2606 for exactly this — it
|
||||||
|
* is guaranteed never to resolve, anywhere, ever. So a plugin that stores it,
|
||||||
|
* validates it or puts it in a "From" header behaves normally, and a plugin
|
||||||
|
* that tries to *send* to it has its message caught by the Sirius Press inbox
|
||||||
|
* before it reaches a mail transport.
|
||||||
|
*
|
||||||
|
* What this plugin deliberately does NOT do is disable `wp_mail()`. A site
|
||||||
|
* owner who configures SMTP gets working contact forms and working order
|
||||||
|
* receipts, because those go to real addresses that real people gave. The
|
||||||
|
* fork's objection was never to email as a feature — only to email as an
|
||||||
|
* identity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'SIRIUS_PRESS_COMPAT_VERSION', '0.1.0' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supply a placeholder wherever WordPress or a plugin reads the admin email.
|
||||||
|
*
|
||||||
|
* Filtered rather than written to the database so that a site owner who does
|
||||||
|
* set a real address keeps it: the filter defers to any non-empty stored
|
||||||
|
* value and only fills a gap.
|
||||||
|
*/
|
||||||
|
function sirius_press_admin_email( $value ) {
|
||||||
|
if ( is_string( $value ) && '' !== trim( $value ) ) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return 'admin@' . SP_Settings::stub_email_domain();
|
||||||
|
}
|
||||||
|
add_filter( 'option_admin_email', 'sirius_press_admin_email' );
|
||||||
|
add_filter( 'default_option_admin_email', 'sirius_press_admin_email' );
|
||||||
|
add_filter( 'pre_option_new_admin_email', '__return_empty_string' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill a blank `user_email` on save rather than letting one persist.
|
||||||
|
*
|
||||||
|
* `$user->user_email` is read in thousands of places across the ecosystem,
|
||||||
|
* usually without a null check, so an account with an empty one leaves
|
||||||
|
* warnings and blank "From" headers scattered through other people's code.
|
||||||
|
* An account created before this fork was installed, or by a plugin calling
|
||||||
|
* `wp_insert_user()` directly, can have exactly that.
|
||||||
|
*
|
||||||
|
* Runs late so a plugin that supplies a real address wins.
|
||||||
|
*/
|
||||||
|
add_filter(
|
||||||
|
'wp_pre_insert_user_data',
|
||||||
|
function ( $data, $update, $user_id ) {
|
||||||
|
if ( ! class_exists( 'SP_Identity' ) ) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
if ( ! empty( $data['user_email'] ) ) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
$address = $user_id ? SP_Identity::address_of( (int) $user_id ) : '';
|
||||||
|
$data['user_email'] = SP_Identity::stub_email( $address );
|
||||||
|
return $data;
|
||||||
|
},
|
||||||
|
20,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop core nagging about an unconfirmed administrator email change.
|
||||||
|
*
|
||||||
|
* The "Your admin email is still <x>, please confirm" prompt sends a
|
||||||
|
* confirmation link to an address that cannot receive it, which makes the
|
||||||
|
* prompt permanent and unactionable.
|
||||||
|
*/
|
||||||
|
add_filter( 'admin_email_check_interval', '__return_zero' );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WooCommerce.
|
||||||
|
*
|
||||||
|
* Its setup wizard and its system-status report both read the store address
|
||||||
|
* out of `woocommerce_email_from_address`, and the onboarding profiler posts
|
||||||
|
* to a remote service with it. Supplying the placeholder lets the wizard
|
||||||
|
* finish; order emails to real customers are unaffected, because those use
|
||||||
|
* the address the customer typed at checkout.
|
||||||
|
*/
|
||||||
|
add_filter(
|
||||||
|
'pre_option_woocommerce_email_from_address',
|
||||||
|
function ( $value ) {
|
||||||
|
if ( ! empty( $value ) || ! class_exists( 'SP_Settings' ) ) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
return 'shop@' . SP_Settings::stub_email_domain();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact Form 7.
|
||||||
|
*
|
||||||
|
* CF7 validates a form's mail template on save and marks the form as
|
||||||
|
* misconfigured if the "To" address is empty, which it will be on a site with
|
||||||
|
* no admin email. The placeholder makes new forms save cleanly; a site owner
|
||||||
|
* who wants the form to actually deliver replaces it with a real address and
|
||||||
|
* configures SMTP, which is documented in docs/smtp.md.
|
||||||
|
*/
|
||||||
|
add_filter(
|
||||||
|
'wpcf7_default_template',
|
||||||
|
function ( $template, $prop ) {
|
||||||
|
if ( 'mail' !== $prop || ! is_array( $template ) || ! class_exists( 'SP_Settings' ) ) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
if ( empty( $template['recipient'] ) ) {
|
||||||
|
$template['recipient'] = 'admin@' . SP_Settings::stub_email_domain();
|
||||||
|
}
|
||||||
|
return $template;
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugins that check "is this a real email?" during their own setup.
|
||||||
|
*
|
||||||
|
* `is_email()` correctly rejects a `.invalid` address — the domain is
|
||||||
|
* reserved precisely so that it fails. That correctness is a problem for a
|
||||||
|
* setup wizard that refuses to advance, so `.invalid` addresses under *this
|
||||||
|
* site's own* placeholder domain are allowed through, and nothing else is.
|
||||||
|
* Narrowing it to our own domain matters: a contact form that accepts
|
||||||
|
* `someone@example.invalid` from a visitor would silently collect addresses
|
||||||
|
* that can never be replied to.
|
||||||
|
*/
|
||||||
|
add_filter(
|
||||||
|
'is_email',
|
||||||
|
function ( $is_email, $email ) {
|
||||||
|
if ( $is_email || ! class_exists( 'SP_Settings' ) ) {
|
||||||
|
return $is_email;
|
||||||
|
}
|
||||||
|
$domain = '@' . strtolower( SP_Settings::stub_email_domain() );
|
||||||
|
if ( substr( strtolower( (string) $email ), -strlen( $domain ) ) !== $domain ) {
|
||||||
|
return $is_email;
|
||||||
|
}
|
||||||
|
// Same local-part rules core applies, minus the domain check it fails.
|
||||||
|
$local = substr( (string) $email, 0, strlen( (string) $email ) - strlen( $domain ) );
|
||||||
|
return (bool) preg_match( '/^[a-zA-Z0-9!#$%&\'*+\/=?^_`{|}~\.\-]+$/', $local );
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short explanation on the plugins screen, next to anything known to want
|
||||||
|
* an address. Better a sentence here than a confused hour later.
|
||||||
|
*/
|
||||||
|
add_action(
|
||||||
|
'after_plugin_row',
|
||||||
|
function ( $plugin_file ) {
|
||||||
|
static $notes = array(
|
||||||
|
'woocommerce/woocommerce.php' => 'WooCommerce will work, including checkout. Order emails need SMTP — see docs/smtp.md.',
|
||||||
|
'contact-form-7/wp-contact-form-7.php' => 'Forms save and submit. Delivery needs SMTP and a real recipient address — see docs/smtp.md.',
|
||||||
|
'wordpress-seo/wp-seo.php' => 'Yoast works unchanged. Its sitemaps are exported to your name along with everything else.',
|
||||||
|
);
|
||||||
|
if ( ! isset( $notes[ $plugin_file ] ) || ! is_plugin_active( $plugin_file ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
'<tr class="plugin-update-tr active"><td colspan="4" class="plugin-update"><div class="update-message notice inline notice-info notice-alt"><p>%s</p></div></td></tr>',
|
||||||
|
esc_html( $notes[ $plugin_file ] )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
325
plugins/sirius-press-core/includes/class-sp-admin.php
Normal file
325
plugins/sirius-press-core/includes/class-sp-admin.php
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
<?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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
182
plugins/sirius-press-core/includes/class-sp-bn.php
Normal file
182
plugins/sirius-press-core/includes/class-sp-bn.php
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Arbitrary-precision integers, on whatever the host happens to have.
|
||||||
|
*
|
||||||
|
* Sirius Press has to do secp256k1 arithmetic inside PHP because the whole
|
||||||
|
* identity model is "prove you hold the key". Shared hosts are wildly
|
||||||
|
* inconsistent about which big-number extension they ship: some have GMP,
|
||||||
|
* many only have BCMath, a few have both. Rather than make the fork refuse to
|
||||||
|
* install on half the hosts it targets, every curve operation goes through
|
||||||
|
* this shim and picks a backend at load time.
|
||||||
|
*
|
||||||
|
* GMP is roughly 20x faster and is preferred. BCMath is the fallback; a login
|
||||||
|
* verification costs a few hundred milliseconds there, which is survivable
|
||||||
|
* because it happens once per session, not once per page.
|
||||||
|
*
|
||||||
|
* Numbers are opaque handles: GMP objects on the GMP backend, decimal strings
|
||||||
|
* on the BCMath one. Never do arithmetic on them directly — always via here.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||||
|
|
||||||
|
final class SP_BN {
|
||||||
|
|
||||||
|
const BACKEND_GMP = 'gmp';
|
||||||
|
const BACKEND_BC = 'bc';
|
||||||
|
|
||||||
|
/** @var string|null One of the BACKEND_* constants, or null if neither exists. */
|
||||||
|
private static $backend = null;
|
||||||
|
|
||||||
|
/** Resolve the backend once. Returns null when the host has neither extension. */
|
||||||
|
public static function backend() {
|
||||||
|
if ( null === self::$backend ) {
|
||||||
|
if ( extension_loaded( 'gmp' ) ) {
|
||||||
|
self::$backend = self::BACKEND_GMP;
|
||||||
|
} elseif ( extension_loaded( 'bcmath' ) ) {
|
||||||
|
self::$backend = self::BACKEND_BC;
|
||||||
|
bcscale( 0 );
|
||||||
|
} else {
|
||||||
|
self::$backend = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '' === self::$backend ? null : self::$backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this host can do wallet crypto at all. */
|
||||||
|
public static function available() {
|
||||||
|
return null !== self::backend();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- input
|
||||||
|
|
||||||
|
/** @param string $hex Unprefixed hex, any length, case-insensitive. */
|
||||||
|
public static function from_hex( $hex ) {
|
||||||
|
$hex = ltrim( strtolower( (string) $hex ), '0' );
|
||||||
|
if ( '' === $hex ) {
|
||||||
|
return self::from_int( 0 );
|
||||||
|
}
|
||||||
|
if ( self::BACKEND_GMP === self::backend() ) {
|
||||||
|
return gmp_init( $hex, 16 );
|
||||||
|
}
|
||||||
|
// BCMath has no base conversion, so fold the hex in one nibble at a
|
||||||
|
// time. 64 iterations of bcmul/bcadd — negligible next to the curve.
|
||||||
|
$out = '0';
|
||||||
|
$len = strlen( $hex );
|
||||||
|
for ( $i = 0; $i < $len; $i++ ) {
|
||||||
|
$out = bcadd( bcmul( $out, '16' ), (string) hexdec( $hex[ $i ] ) );
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param string $bin Raw bytes, big-endian. */
|
||||||
|
public static function from_bin( $bin ) {
|
||||||
|
return self::from_hex( bin2hex( $bin ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function from_int( $n ) {
|
||||||
|
return self::BACKEND_GMP === self::backend() ? gmp_init( (string) $n, 10 ) : (string) $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- output
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $a
|
||||||
|
* @param int $pad Zero-pad to this many hex characters (0 = no padding).
|
||||||
|
*/
|
||||||
|
public static function to_hex( $a, $pad = 64 ) {
|
||||||
|
if ( self::BACKEND_GMP === self::backend() ) {
|
||||||
|
$hex = gmp_strval( $a, 16 );
|
||||||
|
} else {
|
||||||
|
$hex = '';
|
||||||
|
$n = $a;
|
||||||
|
if ( 0 === bccomp( $n, '0' ) ) {
|
||||||
|
$hex = '0';
|
||||||
|
}
|
||||||
|
while ( bccomp( $n, '0' ) > 0 ) {
|
||||||
|
$hex = dechex( (int) bcmod( $n, '16' ) ) . $hex;
|
||||||
|
$n = bcdiv( $n, '16' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $pad > 0 && strlen( $hex ) < $pad ) {
|
||||||
|
$hex = str_repeat( '0', $pad - strlen( $hex ) ) . $hex;
|
||||||
|
}
|
||||||
|
return $hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed-width big-endian bytes. */
|
||||||
|
public static function to_bin( $a, $bytes = 32 ) {
|
||||||
|
return hex2bin( self::to_hex( $a, $bytes * 2 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ arithmetic
|
||||||
|
|
||||||
|
public static function add( $a, $b ) {
|
||||||
|
return self::BACKEND_GMP === self::backend() ? gmp_add( $a, $b ) : bcadd( $a, $b );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sub( $a, $b ) {
|
||||||
|
return self::BACKEND_GMP === self::backend() ? gmp_sub( $a, $b ) : bcsub( $a, $b );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mul( $a, $b ) {
|
||||||
|
return self::BACKEND_GMP === self::backend() ? gmp_mul( $a, $b ) : bcmul( $a, $b );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always returns a non-negative residue, matching gmp_mod's sign rule. */
|
||||||
|
public static function mod( $a, $m ) {
|
||||||
|
if ( self::BACKEND_GMP === self::backend() ) {
|
||||||
|
return gmp_mod( $a, $m );
|
||||||
|
}
|
||||||
|
$r = bcmod( $a, $m );
|
||||||
|
return bccomp( $r, '0' ) < 0 ? bcadd( $r, $m ) : $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pow_mod( $a, $e, $m ) {
|
||||||
|
return self::BACKEND_GMP === self::backend() ? gmp_powm( $a, $e, $m ) : bcpowmod( $a, $e, $m );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -1, 0 or 1. */
|
||||||
|
public static function cmp( $a, $b ) {
|
||||||
|
return self::BACKEND_GMP === self::backend() ? gmp_cmp( $a, $b ) : bccomp( $a, $b );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is_zero( $a ) {
|
||||||
|
return 0 === self::cmp( $a, self::from_int( 0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is_odd( $a ) {
|
||||||
|
if ( self::BACKEND_GMP === self::backend() ) {
|
||||||
|
return 1 === gmp_intval( gmp_mod( $a, gmp_init( 2 ) ) );
|
||||||
|
}
|
||||||
|
return '1' === bcmod( $a, '2' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modular inverse by Fermat's little theorem: a^(m-2) mod m.
|
||||||
|
*
|
||||||
|
* Only correct for prime moduli, which is all this library ever uses (the
|
||||||
|
* field prime p and the group order n are both prime). Saves carrying an
|
||||||
|
* extended-Euclid implementation that BCMath would make painful.
|
||||||
|
*/
|
||||||
|
public static function inv_mod( $a, $m ) {
|
||||||
|
return self::pow_mod( $a, self::sub( $m, self::from_int( 2 ) ), $m );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scalar's bits, most-significant first, as a string of '0'/'1'.
|
||||||
|
*
|
||||||
|
* Point multiplication walks these. Deriving them from hex rather than by
|
||||||
|
* repeated division keeps the BCMath path from doing 256 bcdiv calls.
|
||||||
|
*/
|
||||||
|
public static function bits( $a ) {
|
||||||
|
$hex = self::to_hex( $a, 0 );
|
||||||
|
$bits = '';
|
||||||
|
$len = strlen( $hex );
|
||||||
|
for ( $i = 0; $i < $len; $i++ ) {
|
||||||
|
$bits .= str_pad( decbin( hexdec( $hex[ $i ] ) ), 4, '0', STR_PAD_LEFT );
|
||||||
|
}
|
||||||
|
return ltrim( $bits, '0' );
|
||||||
|
}
|
||||||
|
}
|
||||||
269
plugins/sirius-press-core/includes/class-sp-cashaddr.php
Normal file
269
plugins/sirius-press-core/includes/class-sp-cashaddr.php
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CashAddress encoding — the fork's replacement for the email address.
|
||||||
|
*
|
||||||
|
* Wherever WordPress core would have stored, validated or compared a
|
||||||
|
* `user_email`, Sirius Press stores, validates and compares one of these. The
|
||||||
|
* format is the Bitcoin Cash address spec: a human-readable prefix, a colon,
|
||||||
|
* and base32 of [version byte | hash160] with a 40-bit BCH checksum.
|
||||||
|
*
|
||||||
|
* Two prefixes matter in practice: `bitcoincash` for mainnet names and
|
||||||
|
* `bchtest` for chipnet, which is what the test instances and the registrar's
|
||||||
|
* staging flow use. Token-aware forms (types 2 and 3) decode too, because a
|
||||||
|
* name certificate lives at a token address and users copy whichever form
|
||||||
|
* their wallet showed them — but they normalise to the same hash160, so a
|
||||||
|
* user who signs up with one form and signs in with the other is the same
|
||||||
|
* person as far as the auth plugin is concerned.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||||
|
|
||||||
|
final class SP_CashAddr {
|
||||||
|
|
||||||
|
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||||
|
|
||||||
|
const TYPE_P2PKH = 0;
|
||||||
|
const TYPE_P2SH = 1;
|
||||||
|
const TYPE_P2PKH_TOKEN = 2;
|
||||||
|
const TYPE_P2SH_TOKEN = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a 20-byte hash160 as a CashAddress.
|
||||||
|
*
|
||||||
|
* @param string $prefix e.g. 'bitcoincash' or 'bchtest'.
|
||||||
|
* @param string $hash 20 raw bytes.
|
||||||
|
* @param int $type One of the TYPE_* constants.
|
||||||
|
* @return string 'prefix:payload', or '' on bad input.
|
||||||
|
*/
|
||||||
|
public static function encode( $prefix, $hash, $type = self::TYPE_P2PKH ) {
|
||||||
|
$prefix = strtolower( (string) $prefix );
|
||||||
|
if ( 20 !== strlen( $hash ) || ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// Version byte: type in the high nibble, size code in the low bits.
|
||||||
|
// 20-byte hashes are size code 0, which is every address we mint.
|
||||||
|
$payload = chr( ( $type << 3 ) | 0 ) . $hash;
|
||||||
|
$data = self::convert_bits( self::bytes_to_array( $payload ), 8, 5, true );
|
||||||
|
if ( null === $data ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$checksum = self::checksum( $prefix, array_merge( $data, array_fill( 0, 8, 0 ) ) );
|
||||||
|
$out = '';
|
||||||
|
foreach ( array_merge( $data, $checksum ) as $v ) {
|
||||||
|
$out .= self::CHARSET[ $v ];
|
||||||
|
}
|
||||||
|
return $prefix . ':' . $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a CashAddress.
|
||||||
|
*
|
||||||
|
* Accepts the address with or without its prefix; a bare payload is tried
|
||||||
|
* against both known prefixes, because half the UIs in the ecosystem strip
|
||||||
|
* the prefix when they display an address and users paste what they see.
|
||||||
|
*
|
||||||
|
* @param string $addr
|
||||||
|
* @return array{prefix:string,type:int,hash:string}|null
|
||||||
|
*/
|
||||||
|
public static function decode( $addr ) {
|
||||||
|
$addr = trim( (string) $addr );
|
||||||
|
if ( '' === $addr ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ( false !== strpos( $addr, ':' ) ) {
|
||||||
|
list( $prefix, $payload ) = explode( ':', strtolower( $addr ), 2 );
|
||||||
|
return self::decode_parts( $prefix, $payload );
|
||||||
|
}
|
||||||
|
foreach ( array( 'bitcoincash', 'bchtest', 'bchreg' ) as $prefix ) {
|
||||||
|
$r = self::decode_parts( $prefix, strtolower( $addr ) );
|
||||||
|
if ( null !== $r ) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function decode_parts( $prefix, $payload ) {
|
||||||
|
if ( ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) || ! preg_match( '/^[' . self::CHARSET . ']{8,124}$/', $payload ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$values = array();
|
||||||
|
$len = strlen( $payload );
|
||||||
|
for ( $i = 0; $i < $len; $i++ ) {
|
||||||
|
$pos = strpos( self::CHARSET, $payload[ $i ] );
|
||||||
|
if ( false === $pos ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$values[] = $pos;
|
||||||
|
}
|
||||||
|
$sum = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $values ) );
|
||||||
|
if ( 0 !== $sum ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = self::convert_bits( array_slice( $values, 0, count( $values ) - 8 ), 5, 8, false );
|
||||||
|
if ( null === $data || count( $data ) < 21 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$version = $data[0];
|
||||||
|
$hash = self::array_to_bytes( array_slice( $data, 1 ) );
|
||||||
|
// Only the 20-byte size code is ever produced by a wallet in this
|
||||||
|
// ecosystem; anything else is a paste of something that is not an
|
||||||
|
// address we can check a signature against.
|
||||||
|
if ( 0 !== ( $version & 0x07 ) || 20 !== strlen( $hash ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'type' => ( $version >> 3 ) & 0x0f,
|
||||||
|
'hash' => $hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `$addr` is a well-formed CashAddress with a valid checksum. */
|
||||||
|
public static function is_valid( $addr ) {
|
||||||
|
return null !== self::decode( $addr );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical form for storage and comparison: lowercase, prefixed, and
|
||||||
|
* always the non-token p2pkh spelling.
|
||||||
|
*
|
||||||
|
* Two spellings of the same key must not become two accounts, so every
|
||||||
|
* address is squashed to this before it is written to or compared against
|
||||||
|
* the users table.
|
||||||
|
*
|
||||||
|
* @return string '' when the input is not an address.
|
||||||
|
*/
|
||||||
|
public static function normalize( $addr ) {
|
||||||
|
$d = self::decode( $addr );
|
||||||
|
if ( null === $d ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$type = ( self::TYPE_P2SH === $d['type'] || self::TYPE_P2SH_TOKEN === $d['type'] )
|
||||||
|
? self::TYPE_P2SH
|
||||||
|
: self::TYPE_P2PKH;
|
||||||
|
return self::encode( $d['prefix'], $d['hash'], $type );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The address a compressed public key controls. */
|
||||||
|
public static function from_public_key( $pubkey_bin, $prefix = 'bitcoincash' ) {
|
||||||
|
if ( 33 !== strlen( $pubkey_bin ) && 65 !== strlen( $pubkey_bin ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$hash = hash( 'ripemd160', hash( 'sha256', $pubkey_bin, true ), true );
|
||||||
|
return self::encode( $prefix, $hash, self::TYPE_P2PKH );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, human-checkable form for UI: `bchtest:qr4…8ktm`. */
|
||||||
|
public static function shorten( $addr, $head = 8, $tail = 6 ) {
|
||||||
|
$addr = (string) $addr;
|
||||||
|
$pos = strpos( $addr, ':' );
|
||||||
|
$body = false === $pos ? $addr : substr( $addr, $pos + 1 );
|
||||||
|
$pre = false === $pos ? '' : substr( $addr, 0, $pos + 1 );
|
||||||
|
if ( strlen( $body ) <= $head + $tail + 1 ) {
|
||||||
|
return $addr;
|
||||||
|
}
|
||||||
|
return $pre . substr( $body, 0, $head ) . '…' . substr( $body, -$tail );
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- checksum
|
||||||
|
|
||||||
|
private static function expand_prefix( $prefix ) {
|
||||||
|
$out = array();
|
||||||
|
$len = strlen( $prefix );
|
||||||
|
for ( $i = 0; $i < $len; $i++ ) {
|
||||||
|
$out[] = ord( $prefix[ $i ] ) & 0x1f;
|
||||||
|
}
|
||||||
|
$out[] = 0;
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function checksum( $prefix, $data ) {
|
||||||
|
$mod = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $data ) );
|
||||||
|
$out = array();
|
||||||
|
for ( $i = 0; $i < 8; $i++ ) {
|
||||||
|
$out[] = ( $mod >> ( 5 * ( 7 - $i ) ) ) & 0x1f;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BCH checksum polynomial over GF(32).
|
||||||
|
*
|
||||||
|
* Works on 40-bit accumulators, so this needs a 64-bit PHP build. 32-bit
|
||||||
|
* PHP has been unsupported by WordPress-adjacent hosting for years; the
|
||||||
|
* core plugin's requirements check says so explicitly rather than letting
|
||||||
|
* addresses silently fail to validate here.
|
||||||
|
*/
|
||||||
|
private static function poly_mod( $values ) {
|
||||||
|
$c = 1;
|
||||||
|
foreach ( $values as $d ) {
|
||||||
|
$c0 = ( $c >> 35 ) & 0xff;
|
||||||
|
$c = ( ( $c & 0x07ffffffff ) << 5 ) ^ $d;
|
||||||
|
if ( $c0 & 0x01 ) {
|
||||||
|
$c ^= 0x98f2bc8e61;
|
||||||
|
}
|
||||||
|
if ( $c0 & 0x02 ) {
|
||||||
|
$c ^= 0x79b76d99e2;
|
||||||
|
}
|
||||||
|
if ( $c0 & 0x04 ) {
|
||||||
|
$c ^= 0xf33e5fb3c4;
|
||||||
|
}
|
||||||
|
if ( $c0 & 0x08 ) {
|
||||||
|
$c ^= 0xae2eabe2a8;
|
||||||
|
}
|
||||||
|
if ( $c0 & 0x10 ) {
|
||||||
|
$c ^= 0x1e4f43e470;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $c ^ 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- bit fiddling
|
||||||
|
|
||||||
|
private static function bytes_to_array( $bin ) {
|
||||||
|
return array_values( unpack( 'C*', $bin ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function array_to_bytes( $arr ) {
|
||||||
|
$out = '';
|
||||||
|
foreach ( $arr as $v ) {
|
||||||
|
$out .= chr( $v & 0xff );
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regroup a list of values from `$from` bits each to `$to` bits each.
|
||||||
|
*
|
||||||
|
* @param bool $pad Pad the tail (encoding) or require it to be zero (decoding).
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
private static function convert_bits( $values, $from, $to, $pad ) {
|
||||||
|
$acc = 0;
|
||||||
|
$bits = 0;
|
||||||
|
$out = array();
|
||||||
|
$max = ( 1 << $to ) - 1;
|
||||||
|
foreach ( $values as $v ) {
|
||||||
|
if ( $v < 0 || ( $v >> $from ) !== 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$acc = ( $acc << $from ) | $v;
|
||||||
|
$bits += $from;
|
||||||
|
while ( $bits >= $to ) {
|
||||||
|
$bits -= $to;
|
||||||
|
$out[] = ( $acc >> $bits ) & $max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $pad ) {
|
||||||
|
if ( $bits > 0 ) {
|
||||||
|
$out[] = ( $acc << ( $to - $bits ) ) & $max;
|
||||||
|
}
|
||||||
|
} elseif ( $bits >= $from || ( ( $acc << ( $to - $bits ) ) & $max ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
265
plugins/sirius-press-core/includes/class-sp-gateway.php
Normal file
265
plugins/sirius-press-core/includes/class-sp-gateway.php
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The BNS gateway client.
|
||||||
|
*
|
||||||
|
* Three endpoints matter to this fork:
|
||||||
|
*
|
||||||
|
* GET /api/name/<name> who owns the name right now, so the
|
||||||
|
* settings screen can tell an owner that the
|
||||||
|
* key they pasted is not the one the chain
|
||||||
|
* recognises — before their first publish
|
||||||
|
* fails with a bare 403.
|
||||||
|
* GET /api/site/<name>/<path> read back what was published.
|
||||||
|
* PUT /api/site/<name>/<path> publish, signed BNS-SITE1.
|
||||||
|
*
|
||||||
|
* Every response is treated as untrusted input from a third party. The
|
||||||
|
* gateway is a relay, not an authority: it cannot forge a signature, but it
|
||||||
|
* can lie about what is stored, and nothing here grants it more trust than
|
||||||
|
* "it told us the upload succeeded".
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SP_Gateway {
|
||||||
|
|
||||||
|
/** Gateway's own cap. Anything larger is rejected before the round trip. */
|
||||||
|
const MAX_BYTES = 8388608;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish one file into the name's bucket.
|
||||||
|
*
|
||||||
|
* @param string $path Path inside the bucket, e.g. 'blog/hello/index.html'.
|
||||||
|
* @param string $body Raw bytes.
|
||||||
|
* @param string $mime Content type; guessed from the extension if ''.
|
||||||
|
* @return array{ok:bool,status:int,error:string,bytes:int,sia_key:string}
|
||||||
|
*/
|
||||||
|
public static function put( $path, $body, $mime = '' ) {
|
||||||
|
$name = SP_Settings::name();
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return self::fail( 'This site has no BCNR name configured yet.' );
|
||||||
|
}
|
||||||
|
$path = self::clean_path( $path );
|
||||||
|
if ( '' === $path ) {
|
||||||
|
return self::fail( 'Refusing to publish to an unusable path.' );
|
||||||
|
}
|
||||||
|
if ( strlen( $body ) > self::MAX_BYTES ) {
|
||||||
|
return self::fail( sprintf( 'That file is %s; the gateway accepts 8 MB per file.', size_format( strlen( $body ) ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$priv = SP_Settings::publishing_private_key();
|
||||||
|
} catch ( Exception $e ) {
|
||||||
|
return self::fail( $e->getMessage() );
|
||||||
|
}
|
||||||
|
if ( '' === $priv ) {
|
||||||
|
return self::fail( 'No publishing key is stored, so this site cannot sign an upload. Add the name\'s recovery phrase in Sirius Press settings, or switch to manual publishing.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = SP_Message::site_headers( $name, $path, $body, $priv );
|
||||||
|
} catch ( Exception $e ) {
|
||||||
|
return self::fail( 'Signing failed: ' . $e->getMessage() );
|
||||||
|
}
|
||||||
|
$headers['content-type'] = '' !== $mime ? $mime : self::mime_for( $path );
|
||||||
|
|
||||||
|
$res = wp_remote_request(
|
||||||
|
self::url( $name, $path ),
|
||||||
|
array(
|
||||||
|
'method' => 'PUT',
|
||||||
|
'timeout' => 45,
|
||||||
|
'headers' => $headers,
|
||||||
|
'body' => $body,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return self::interpret( $res );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file from the bucket — used when a post is unpublished or its
|
||||||
|
* permalink changes, so the static mirror does not keep serving a page the
|
||||||
|
* site no longer has.
|
||||||
|
*/
|
||||||
|
public static function delete( $path ) {
|
||||||
|
$name = SP_Settings::name();
|
||||||
|
$path = self::clean_path( $path );
|
||||||
|
if ( '' === $name || '' === $path ) {
|
||||||
|
return self::fail( 'Nothing to delete.' );
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$priv = SP_Settings::publishing_private_key();
|
||||||
|
if ( '' === $priv ) {
|
||||||
|
return self::fail( 'No publishing key is stored.' );
|
||||||
|
}
|
||||||
|
$headers = SP_Message::site_headers( $name, $path, '', $priv );
|
||||||
|
} catch ( Exception $e ) {
|
||||||
|
return self::fail( $e->getMessage() );
|
||||||
|
}
|
||||||
|
$res = wp_remote_request(
|
||||||
|
self::url( $name, $path ),
|
||||||
|
array(
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => $headers,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return self::interpret( $res );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a published file back. @return string|null */
|
||||||
|
public static function get( $path ) {
|
||||||
|
$name = SP_Settings::name();
|
||||||
|
$path = self::clean_path( $path );
|
||||||
|
if ( '' === $name || '' === $path ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$res = wp_remote_get( self::url( $name, $path ), array( 'timeout' => 30 ) );
|
||||||
|
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return wp_remote_retrieve_body( $res );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List what the bucket currently holds. @return array|null */
|
||||||
|
public static function listing() {
|
||||||
|
$name = SP_Settings::name();
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$res = wp_remote_get( SP_Settings::gateway() . '/api/site/' . rawurlencode( $name ), array( 'timeout' => 30 ) );
|
||||||
|
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$json = json_decode( wp_remote_retrieve_body( $res ), true );
|
||||||
|
return is_array( $json ) && isset( $json['files'] ) ? $json : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name's current on-chain owner address, or '' if the gateway will not
|
||||||
|
* say. Used to warn about a key mismatch before it becomes a 403.
|
||||||
|
*/
|
||||||
|
public static function owner_of_name() {
|
||||||
|
$name = SP_Settings::name();
|
||||||
|
if ( '' === $name ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$res = wp_remote_get(
|
||||||
|
SP_Settings::gateway() . '/api/name/' . rawurlencode( $name ),
|
||||||
|
array( 'timeout' => 20 )
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$json = json_decode( wp_remote_retrieve_body( $res ), true );
|
||||||
|
if ( ! is_array( $json ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
foreach ( array( 'owner', 'owner_address', 'address' ) as $k ) {
|
||||||
|
if ( ! empty( $json[ $k ] ) && is_string( $json[ $k ] ) ) {
|
||||||
|
return SP_CashAddr::normalize( $json[ $k ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- utils
|
||||||
|
|
||||||
|
private static function url( $name, $path ) {
|
||||||
|
$encoded = implode( '/', array_map( 'rawurlencode', explode( '/', $path ) ) );
|
||||||
|
return SP_Settings::gateway() . '/api/site/' . rawurlencode( $name ) . '/' . $encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squeeze a path into what the gateway's own validator accepts:
|
||||||
|
* `[A-Za-z0-9._\-\/]{1,200}`, no `..`, no leading or trailing slash.
|
||||||
|
*
|
||||||
|
* Anything that cannot be represented comes back '' rather than being
|
||||||
|
* silently mangled into a different file.
|
||||||
|
*/
|
||||||
|
public static function clean_path( $path ) {
|
||||||
|
$path = ltrim( str_replace( '\\', '/', (string) $path ), '/' );
|
||||||
|
$path = preg_replace( '#/+#', '/', $path );
|
||||||
|
if ( '' === $path || '/' === substr( $path, -1 ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ( false !== strpos( $path, '..' ) || strlen( $path ) > 200 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return preg_match( '#^[A-Za-z0-9._\-/]+$#', $path ) ? $path : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content types the gateway itself recognises, keyed by extension. */
|
||||||
|
public static function mime_for( $path ) {
|
||||||
|
static $types = array(
|
||||||
|
'html' => 'text/html; charset=utf-8',
|
||||||
|
'htm' => 'text/html; charset=utf-8',
|
||||||
|
'css' => 'text/css; charset=utf-8',
|
||||||
|
'js' => 'text/javascript; charset=utf-8',
|
||||||
|
'mjs' => 'text/javascript; charset=utf-8',
|
||||||
|
'json' => 'application/json',
|
||||||
|
'txt' => 'text/plain; charset=utf-8',
|
||||||
|
'md' => 'text/markdown; charset=utf-8',
|
||||||
|
'xml' => 'application/xml',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'avif' => 'image/avif',
|
||||||
|
'ico' => 'image/x-icon',
|
||||||
|
'woff' => 'font/woff',
|
||||||
|
'woff2' => 'font/woff2',
|
||||||
|
'ttf' => 'font/ttf',
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
'mp3' => 'audio/mpeg',
|
||||||
|
'mp4' => 'video/mp4',
|
||||||
|
'webm' => 'video/webm',
|
||||||
|
'wasm' => 'application/wasm',
|
||||||
|
);
|
||||||
|
$ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
|
||||||
|
return isset( $types[ $ext ] ) ? $types[ $ext ] : 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fail( $message ) {
|
||||||
|
return array(
|
||||||
|
'ok' => false,
|
||||||
|
'status' => 0,
|
||||||
|
'error' => $message,
|
||||||
|
'bytes' => 0,
|
||||||
|
'sia_key' => '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a wp_remote_* result into the shape the queue stores. */
|
||||||
|
private static function interpret( $res ) {
|
||||||
|
if ( is_wp_error( $res ) ) {
|
||||||
|
return self::fail( $res->get_error_message() );
|
||||||
|
}
|
||||||
|
$status = (int) wp_remote_retrieve_response_code( $res );
|
||||||
|
$json = json_decode( wp_remote_retrieve_body( $res ), true );
|
||||||
|
if ( 200 === $status && is_array( $json ) && ! empty( $json['ok'] ) ) {
|
||||||
|
return array(
|
||||||
|
'ok' => true,
|
||||||
|
'status' => $status,
|
||||||
|
'error' => '',
|
||||||
|
'bytes' => isset( $json['bytes'] ) ? (int) $json['bytes'] : 0,
|
||||||
|
'sia_key' => isset( $json['sia_key'] ) ? (string) $json['sia_key'] : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$error = is_array( $json ) && ! empty( $json['error'] )
|
||||||
|
? (string) $json['error']
|
||||||
|
: sprintf( 'gateway returned HTTP %d', $status );
|
||||||
|
// The two failures an operator will actually hit, translated out of
|
||||||
|
// gateway-speak into something that says what to do about it.
|
||||||
|
if ( 403 === $status ) {
|
||||||
|
$error .= ' — the stored publishing key is not the address that currently owns this name.';
|
||||||
|
} elseif ( 503 === $status ) {
|
||||||
|
$error .= ' — the gateway has not indexed this name yet; it usually catches up within a minute.';
|
||||||
|
}
|
||||||
|
$out = self::fail( $error );
|
||||||
|
$out['status'] = $status;
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
plugins/sirius-press-core/includes/class-sp-hd.php
Normal file
179
plugins/sirius-press-core/includes/class-sp-hd.php
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* BIP39 + BIP32, enough of them to turn a recovery phrase into the one key
|
||||||
|
* that matters.
|
||||||
|
*
|
||||||
|
* The publishing wallet is not a new secret invented by the CMS — it is the
|
||||||
|
* same wallet that holds the name's NFT certificate, because the BNS gateway
|
||||||
|
* only accepts writes signed by the current on-chain owner. So Sirius Press
|
||||||
|
* has to walk the identical derivation the portal wallet walks
|
||||||
|
* (Argus/src/lib/wallet-web.js): BIP39 phrase to seed, then m/44'/145'/0'/0/0.
|
||||||
|
*
|
||||||
|
* A different path here would produce a different address, the gateway would
|
||||||
|
* return 403, and the failure would look like a signing bug rather than a
|
||||||
|
* derivation mismatch — so the path is a constant, not a setting, and the
|
||||||
|
* tests pin it against libauth.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/class-sp-bn.php';
|
||||||
|
require_once __DIR__ . '/class-sp-secp256k1.php';
|
||||||
|
require_once __DIR__ . '/class-sp-cashaddr.php';
|
||||||
|
|
||||||
|
final class SP_HD {
|
||||||
|
|
||||||
|
/** BCH coin type 145, account 0, external branch, first address. */
|
||||||
|
const DEFAULT_PATH = "m/44'/145'/0'/0/0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BIP39 phrase to 64-byte seed.
|
||||||
|
*
|
||||||
|
* @param string $mnemonic Space-separated words. Normalised the same way
|
||||||
|
* the portal normalises: trimmed, lowercased,
|
||||||
|
* runs of whitespace collapsed.
|
||||||
|
* @param string $passphrase Optional BIP39 passphrase ("25th word").
|
||||||
|
*/
|
||||||
|
public static function seed_from_mnemonic( $mnemonic, $passphrase = '' ) {
|
||||||
|
$clean = self::normalize_mnemonic( $mnemonic );
|
||||||
|
return hash_pbkdf2( 'sha512', $clean, 'mnemonic' . $passphrase, 2048, 64, true );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalize_mnemonic( $mnemonic ) {
|
||||||
|
return trim( preg_replace( '/\s+/u', ' ', strtolower( (string) $mnemonic ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master key from a seed.
|
||||||
|
*
|
||||||
|
* @return array{key:string,chain:string} 32 raw bytes each.
|
||||||
|
*/
|
||||||
|
public static function master( $seed ) {
|
||||||
|
$i = hash_hmac( 'sha512', $seed, 'Bitcoin seed', true );
|
||||||
|
return array(
|
||||||
|
'key' => substr( $i, 0, 32 ),
|
||||||
|
'chain' => substr( $i, 32, 32 ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One CKDpriv step.
|
||||||
|
*
|
||||||
|
* @param array $node {key, chain}.
|
||||||
|
* @param int $index Child index; add 0x80000000 for a hardened step.
|
||||||
|
* @return array{key:string,chain:string}
|
||||||
|
* @throws Exception If the derived key is invalid (probability ~2^-127).
|
||||||
|
*/
|
||||||
|
public static function derive_child( $node, $index ) {
|
||||||
|
$hardened = $index >= 0x80000000;
|
||||||
|
if ( $hardened ) {
|
||||||
|
$data = "\x00" . $node['key'];
|
||||||
|
} else {
|
||||||
|
$pub = SP_Secp256k1::public_key( $node['key'] );
|
||||||
|
if ( '' === $pub ) {
|
||||||
|
throw new Exception( 'cannot derive public key for a hardened-only node' );
|
||||||
|
}
|
||||||
|
$data = $pub;
|
||||||
|
}
|
||||||
|
$data .= pack( 'N', $index );
|
||||||
|
|
||||||
|
$i = hash_hmac( 'sha512', $data, $node['chain'], true );
|
||||||
|
$il = substr( $i, 0, 32 );
|
||||||
|
$ir = substr( $i, 32, 32 );
|
||||||
|
|
||||||
|
$n = SP_BN::from_hex( SP_Secp256k1::N );
|
||||||
|
$tweak = SP_BN::from_bin( $il );
|
||||||
|
if ( SP_BN::cmp( $tweak, $n ) >= 0 ) {
|
||||||
|
throw new Exception( 'derived tweak out of range — pick the next index' );
|
||||||
|
}
|
||||||
|
$child = SP_BN::mod( SP_BN::add( $tweak, SP_BN::from_bin( $node['key'] ) ), $n );
|
||||||
|
if ( SP_BN::is_zero( $child ) ) {
|
||||||
|
throw new Exception( 'derived key is zero — pick the next index' );
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'key' => SP_BN::to_bin( $child, 32 ),
|
||||||
|
'chain' => $ir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a full path such as "m/44'/145'/0'/0/0".
|
||||||
|
*
|
||||||
|
* @return array{key:string,chain:string}
|
||||||
|
* @throws Exception On a malformed path.
|
||||||
|
*/
|
||||||
|
public static function derive_path( $seed, $path = self::DEFAULT_PATH ) {
|
||||||
|
$node = self::master( $seed );
|
||||||
|
$parts = preg_split( '#/#', trim( (string) $path ) );
|
||||||
|
if ( ! $parts || 'm' !== strtolower( $parts[0] ) ) {
|
||||||
|
throw new Exception( 'derivation path must start with "m"' );
|
||||||
|
}
|
||||||
|
foreach ( array_slice( $parts, 1 ) as $part ) {
|
||||||
|
if ( '' === $part ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hardened = ( "'" === substr( $part, -1 ) || 'h' === strtolower( substr( $part, -1 ) ) );
|
||||||
|
$num = (int) rtrim( $part, "'hH" );
|
||||||
|
if ( $num < 0 || $num > 0x7fffffff ) {
|
||||||
|
throw new Exception( "derivation index out of range: {$part}" );
|
||||||
|
}
|
||||||
|
$node = self::derive_child( $node, $hardened ? $num + 0x80000000 : $num );
|
||||||
|
}
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The private key and address a phrase publishes with.
|
||||||
|
*
|
||||||
|
* @param string $mnemonic
|
||||||
|
* @param string $prefix 'bitcoincash' or 'bchtest'.
|
||||||
|
* @param string $path Override only for wallets minted on another path
|
||||||
|
* (operator wallets were created on BTC coin type).
|
||||||
|
* @return array{private:string,public:string,address:string}
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function publishing_key( $mnemonic, $prefix = 'bitcoincash', $path = self::DEFAULT_PATH ) {
|
||||||
|
$node = self::derive_path( self::seed_from_mnemonic( $mnemonic ), $path );
|
||||||
|
$pub = SP_Secp256k1::public_key( $node['key'] );
|
||||||
|
if ( '' === $pub ) {
|
||||||
|
throw new Exception( 'derivation produced an unusable key' );
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'private' => $node['key'],
|
||||||
|
'public' => $pub,
|
||||||
|
'address' => SP_CashAddr::from_public_key( $pub, $prefix ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanity-check a phrase before storing it.
|
||||||
|
*
|
||||||
|
* Deliberately structural rather than a full BIP39 checksum test: this
|
||||||
|
* fork must accept phrases from every wallet a name owner might already
|
||||||
|
* use, and refusing one over a wordlist we shipped a year ago is a worse
|
||||||
|
* failure than accepting one that later derives the wrong address. The
|
||||||
|
* real check is the address comparison the settings screen does right
|
||||||
|
* after: derive, show the address, and let the owner confirm it is theirs.
|
||||||
|
*
|
||||||
|
* @return string '' when acceptable, otherwise a human-readable reason.
|
||||||
|
*/
|
||||||
|
public static function phrase_problem( $mnemonic ) {
|
||||||
|
$clean = self::normalize_mnemonic( $mnemonic );
|
||||||
|
if ( '' === $clean ) {
|
||||||
|
return 'The recovery phrase is empty.';
|
||||||
|
}
|
||||||
|
$words = explode( ' ', $clean );
|
||||||
|
$count = count( $words );
|
||||||
|
if ( ! in_array( $count, array( 12, 15, 18, 21, 24 ), true ) ) {
|
||||||
|
return sprintf( 'A recovery phrase has 12, 15, 18, 21 or 24 words — this one has %d.', $count );
|
||||||
|
}
|
||||||
|
foreach ( $words as $w ) {
|
||||||
|
if ( ! preg_match( '/^[a-z]{3,8}$/', $w ) ) {
|
||||||
|
return sprintf( '"%s" does not look like a recovery-phrase word.', $w );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
282
plugins/sirius-press-core/includes/class-sp-identity.php
Normal file
282
plugins/sirius-press-core/includes/class-sp-identity.php
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Who a user is, when there is no email address.
|
||||||
|
*
|
||||||
|
* The identity of a Sirius Press account is a CashAddress, and the proof of
|
||||||
|
* that identity is a signature. Everything else about WordPress users —
|
||||||
|
* roles, capabilities, nonces, the session cookie — is untouched, because
|
||||||
|
* those parts were never the problem. Only the "prove you can read this
|
||||||
|
* mailbox" step is replaced.
|
||||||
|
*
|
||||||
|
* **Where the address lives.** In user meta, not a new `wp_users` column.
|
||||||
|
* The roadmap sketched replacing `user_email` with a `wallet_addr` column,
|
||||||
|
* but a schema change to a core table is the one thing that makes every
|
||||||
|
* future upstream merge and every `dbDelta()` run a negotiation, and it buys
|
||||||
|
* nothing here: meta lookups are indexed, and a plugin that reads
|
||||||
|
* `$user->user_email` keeps reading something instead of crashing.
|
||||||
|
*
|
||||||
|
* **Why every user still has an email string.** `user_email` is populated
|
||||||
|
* with a unique, permanently unroutable address under a `.invalid` domain
|
||||||
|
* (RFC 2606 reserves it so it can never resolve). This is not a fallback path
|
||||||
|
* that might one day deliver mail — it is a placeholder that exists so the
|
||||||
|
* ecosystem's thousands of `$user->user_email` reads return a string. Mail to
|
||||||
|
* it cannot leave the building. See docs/plugin-compatibility.md.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SP_Identity {
|
||||||
|
|
||||||
|
const META_ADDRESS = 'sirius_wallet_address';
|
||||||
|
const META_ADDED = 'sirius_wallet_added';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wallet address for a user, or '' if they have none (an account
|
||||||
|
* created before the plugin was active, or by a plugin that made its own).
|
||||||
|
*/
|
||||||
|
public static function address_of( $user_id ) {
|
||||||
|
return (string) get_user_meta( (int) $user_id, self::META_ADDRESS, true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the account that owns an address.
|
||||||
|
*
|
||||||
|
* @return WP_User|null
|
||||||
|
*/
|
||||||
|
public static function user_by_address( $address ) {
|
||||||
|
$address = SP_CashAddr::normalize( $address );
|
||||||
|
if ( '' === $address ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$found = get_users(
|
||||||
|
array(
|
||||||
|
'meta_key' => self::META_ADDRESS,
|
||||||
|
'meta_value' => $address,
|
||||||
|
'number' => 2,
|
||||||
|
'fields' => 'all',
|
||||||
|
'count_total' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Two accounts on one address should be impossible — set_address()
|
||||||
|
// refuses it — but if a direct database edit ever produced one,
|
||||||
|
// authenticating an ambiguous identity is the wrong move.
|
||||||
|
if ( 1 !== count( $found ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach an address to an account.
|
||||||
|
*
|
||||||
|
* @return true|WP_Error
|
||||||
|
*/
|
||||||
|
public static function set_address( $user_id, $address ) {
|
||||||
|
$user_id = (int) $user_id;
|
||||||
|
$address = SP_CashAddr::normalize( $address );
|
||||||
|
if ( '' === $address ) {
|
||||||
|
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
if ( SP_CashAddr::decode( $address )['prefix'] !== SP_Settings::prefix() ) {
|
||||||
|
return new WP_Error(
|
||||||
|
'sirius_wrong_network',
|
||||||
|
sprintf(
|
||||||
|
/* translators: %s: the network prefix this site expects, e.g. bitcoincash */
|
||||||
|
__( 'This site uses %s addresses. That address is on a different network.', 'sirius-press' ),
|
||||||
|
SP_Settings::prefix()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$existing = self::user_by_address( $address );
|
||||||
|
if ( $existing && (int) $existing->ID !== $user_id ) {
|
||||||
|
return new WP_Error( 'sirius_address_taken', __( 'Another account already uses that address.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
update_user_meta( $user_id, self::META_ADDRESS, $address );
|
||||||
|
if ( ! get_user_meta( $user_id, self::META_ADDED, true ) ) {
|
||||||
|
update_user_meta( $user_id, self::META_ADDED, gmdate( 'c' ) );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable, unroutable placeholder email for an address.
|
||||||
|
*
|
||||||
|
* Derived from the address so it is deterministic: re-running a migration
|
||||||
|
* does not churn every user's `user_email`, and two accounts never collide
|
||||||
|
* on one placeholder.
|
||||||
|
*/
|
||||||
|
public static function stub_email( $address ) {
|
||||||
|
$address = SP_CashAddr::normalize( $address );
|
||||||
|
$tag = $address ? substr( hash( 'sha256', $address ), 0, 16 ) : wp_generate_password( 16, false );
|
||||||
|
return 'noreply+' . $tag . '@' . SP_Settings::stub_email_domain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A login name derived from an address.
|
||||||
|
*
|
||||||
|
* Addresses are 42 characters of base32 and make miserable usernames, so
|
||||||
|
* the default is the last eight characters of the payload — short enough
|
||||||
|
* to type, long enough not to collide in practice, and disambiguated with
|
||||||
|
* a counter if it does.
|
||||||
|
*/
|
||||||
|
public static function suggest_login( $address ) {
|
||||||
|
$address = SP_CashAddr::normalize( $address );
|
||||||
|
$body = substr( (string) strstr( $address, ':' ), 1 );
|
||||||
|
$base = 'bch_' . substr( $body, -8 );
|
||||||
|
$login = $base;
|
||||||
|
$n = 2;
|
||||||
|
while ( username_exists( $login ) ) {
|
||||||
|
$login = $base . '_' . $n;
|
||||||
|
$n++;
|
||||||
|
if ( $n > 50 ) {
|
||||||
|
$login = $base . '_' . wp_generate_password( 4, false );
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $login;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an account for an address that has proved itself.
|
||||||
|
*
|
||||||
|
* The password is random and never shown to anyone: there is no password
|
||||||
|
* login path in this fork, and leaving it empty would let any plugin that
|
||||||
|
* calls `wp_authenticate()` with an empty password walk straight in.
|
||||||
|
*
|
||||||
|
* @param string $address
|
||||||
|
* @param string $login Optional preferred username.
|
||||||
|
* @param string $role Defaults to the site's configured default role.
|
||||||
|
* @return int|WP_Error User ID.
|
||||||
|
*/
|
||||||
|
public static function create_user( $address, $login = '', $role = '' ) {
|
||||||
|
$address = SP_CashAddr::normalize( $address );
|
||||||
|
if ( '' === $address ) {
|
||||||
|
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
if ( self::user_by_address( $address ) ) {
|
||||||
|
return new WP_Error( 'sirius_address_taken', __( 'An account already exists for that address.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$login = sanitize_user( $login, true );
|
||||||
|
if ( '' === $login || username_exists( $login ) ) {
|
||||||
|
$login = self::suggest_login( $address );
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = wp_insert_user(
|
||||||
|
array(
|
||||||
|
'user_login' => $login,
|
||||||
|
'user_pass' => wp_generate_password( 64, true, true ),
|
||||||
|
'user_email' => self::stub_email( $address ),
|
||||||
|
'display_name' => $login,
|
||||||
|
'role' => '' !== $role ? $role : get_option( 'default_role', 'subscriber' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $user_id ) ) {
|
||||||
|
return $user_id;
|
||||||
|
}
|
||||||
|
$set = self::set_address( $user_id, $address );
|
||||||
|
if ( is_wp_error( $set ) ) {
|
||||||
|
// Do not leave a half-made account that can never be signed into.
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||||
|
wp_delete_user( $user_id );
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Fires after a wallet-backed account is created.
|
||||||
|
*
|
||||||
|
* @param int $user_id
|
||||||
|
* @param string $address Normalised CashAddress.
|
||||||
|
*/
|
||||||
|
do_action( 'sirius_press_user_registered', $user_id, $address );
|
||||||
|
return $user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- admin UI
|
||||||
|
|
||||||
|
/** Show the address in the users list, where the email column used to be useful. */
|
||||||
|
public static function hooks() {
|
||||||
|
add_filter( 'manage_users_columns', array( __CLASS__, 'add_column' ) );
|
||||||
|
add_filter( 'manage_users_custom_column', array( __CLASS__, 'render_column' ), 10, 3 );
|
||||||
|
add_action( 'show_user_profile', array( __CLASS__, 'profile_field' ) );
|
||||||
|
add_action( 'edit_user_profile', array( __CLASS__, 'profile_field' ) );
|
||||||
|
add_action( 'personal_options_update', array( __CLASS__, 'save_profile_field' ) );
|
||||||
|
add_action( 'edit_user_profile_update', array( __CLASS__, 'save_profile_field' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_column( $columns ) {
|
||||||
|
$out = array();
|
||||||
|
foreach ( $columns as $key => $label ) {
|
||||||
|
if ( 'email' === $key ) {
|
||||||
|
// Replace rather than append: a column of identical .invalid
|
||||||
|
// placeholders is worse than no column.
|
||||||
|
$out['sirius_address'] = __( 'Wallet', 'sirius-press' );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[ $key ] = $label;
|
||||||
|
}
|
||||||
|
if ( ! isset( $out['sirius_address'] ) ) {
|
||||||
|
$out['sirius_address'] = __( 'Wallet', 'sirius-press' );
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_column( $value, $column, $user_id ) {
|
||||||
|
if ( 'sirius_address' !== $column ) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
$address = self::address_of( $user_id );
|
||||||
|
if ( '' === $address ) {
|
||||||
|
return '<span style="color:#b32d2e">' . esc_html__( 'no wallet', 'sirius-press' ) . '</span>';
|
||||||
|
}
|
||||||
|
return '<code title="' . esc_attr( $address ) . '">' . esc_html( SP_CashAddr::shorten( $address ) ) . '</code>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function profile_field( $user ) {
|
||||||
|
$address = self::address_of( $user->ID );
|
||||||
|
$can_edit = current_user_can( 'edit_users' ) || get_current_user_id() === (int) $user->ID;
|
||||||
|
?>
|
||||||
|
<h2><?php esc_html_e( 'Wallet', 'sirius-press' ); ?></h2>
|
||||||
|
<table class="form-table" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<th><label for="sirius_wallet_address"><?php esc_html_e( 'Bitcoin Cash address', 'sirius-press' ); ?></label></th>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="sirius_wallet_address" id="sirius_wallet_address"
|
||||||
|
value="<?php echo esc_attr( $address ); ?>" class="regular-text code"
|
||||||
|
<?php disabled( ! $can_edit ); ?> />
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'This address is the account. Signing in means signing a challenge with the key that controls it — so changing it here hands the account to whoever holds the new key.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function save_profile_field( $user_id ) {
|
||||||
|
if ( ! current_user_can( 'edit_user', $user_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Nonce is checked by WordPress before these actions fire
|
||||||
|
// (check_admin_referer( 'update-user_' . $user_id ) in user-edit.php).
|
||||||
|
if ( ! isset( $_POST['sirius_wallet_address'] ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$address = sanitize_text_field( wp_unslash( $_POST['sirius_wallet_address'] ) );
|
||||||
|
if ( '' === trim( $address ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( trim( $address ) === self::address_of( $user_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$result = self::set_address( $user_id, $address );
|
||||||
|
if ( is_wp_error( $result ) ) {
|
||||||
|
SP_Inbox::add(
|
||||||
|
$user_id,
|
||||||
|
__( 'Wallet address not changed', 'sirius-press' ),
|
||||||
|
$result->get_error_message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
298
plugins/sirius-press-core/includes/class-sp-inbox.php
Normal file
298
plugins/sirius-press-core/includes/class-sp-inbox.php
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The in-app inbox that catches mail with nowhere to go.
|
||||||
|
*
|
||||||
|
* Sirius Press does not disable `wp_mail()`. That would break Contact Form 7,
|
||||||
|
* WooCommerce receipts, newsletters — every legitimate reason a site has to
|
||||||
|
* send a human an email — and those are not what the fork objects to. What it
|
||||||
|
* objects to is *identity* depending on a mailbox.
|
||||||
|
*
|
||||||
|
* So the rule is narrow and mechanical: messages addressed to one of this
|
||||||
|
* site's own `.invalid` placeholder addresses never leave the server. They
|
||||||
|
* land here, in an inbox the recipient reads while signed in. That is where
|
||||||
|
* password resets, comment-moderation pings, update nags and "your plugin
|
||||||
|
* needs attention" notices end up — everything core and plugins send to an
|
||||||
|
* account rather than to a person's real address.
|
||||||
|
*
|
||||||
|
* Mail addressed to a real domain is passed straight through to whatever the
|
||||||
|
* site owner configured. If they set up SMTP, it sends. If they did not, it
|
||||||
|
* fails exactly the way stock WordPress fails, which is the honest outcome.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SP_Inbox {
|
||||||
|
|
||||||
|
const TABLE = 'sirius_notices';
|
||||||
|
const VERSION = 1;
|
||||||
|
|
||||||
|
public static function table() {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->prefix . self::TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the table. Called on activation and on a version bump. */
|
||||||
|
public static function install() {
|
||||||
|
global $wpdb;
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
$table = self::table();
|
||||||
|
$collate = $wpdb->get_charset_collate();
|
||||||
|
dbDelta(
|
||||||
|
"CREATE TABLE {$table} (
|
||||||
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
created_at datetime NOT NULL,
|
||||||
|
read_at datetime DEFAULT NULL,
|
||||||
|
subject varchar(255) NOT NULL DEFAULT '',
|
||||||
|
body longtext NOT NULL,
|
||||||
|
source varchar(60) NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY user_unread (user_id, read_at),
|
||||||
|
KEY created (created_at)
|
||||||
|
) {$collate};"
|
||||||
|
);
|
||||||
|
update_option( 'sirius_press_inbox_version', self::VERSION, false );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_filter( 'pre_wp_mail', array( __CLASS__, 'intercept_mail' ), 10, 2 );
|
||||||
|
add_action( 'admin_menu', array( __CLASS__, 'menu' ) );
|
||||||
|
add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar' ), 80 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliver a message to a user's inbox.
|
||||||
|
*
|
||||||
|
* @param int $user_id 0 addresses every administrator.
|
||||||
|
* @param string $subject
|
||||||
|
* @param string $body HTML allowed; rendered with wp_kses_post.
|
||||||
|
* @param string $source Free-form tag, e.g. 'wp_mail' or 'export'.
|
||||||
|
* @return int Rows written.
|
||||||
|
*/
|
||||||
|
public static function add( $user_id, $subject, $body, $source = '' ) {
|
||||||
|
global $wpdb;
|
||||||
|
$targets = array();
|
||||||
|
if ( (int) $user_id > 0 ) {
|
||||||
|
$targets[] = (int) $user_id;
|
||||||
|
} else {
|
||||||
|
foreach ( get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) ) as $id ) {
|
||||||
|
$targets[] = (int) $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$written = 0;
|
||||||
|
foreach ( array_unique( $targets ) as $id ) {
|
||||||
|
$ok = $wpdb->insert(
|
||||||
|
self::table(),
|
||||||
|
array(
|
||||||
|
'user_id' => $id,
|
||||||
|
'created_at' => current_time( 'mysql', true ),
|
||||||
|
'subject' => mb_substr( wp_strip_all_tags( (string) $subject ), 0, 250 ),
|
||||||
|
'body' => (string) $body,
|
||||||
|
'source' => mb_substr( (string) $source, 0, 60 ),
|
||||||
|
),
|
||||||
|
array( '%d', '%s', '%s', '%s', '%s' )
|
||||||
|
);
|
||||||
|
$written += $ok ? 1 : 0;
|
||||||
|
}
|
||||||
|
return $written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short-circuit wp_mail for placeholder recipients.
|
||||||
|
*
|
||||||
|
* Returning a non-null value from `pre_wp_mail` tells WordPress the send
|
||||||
|
* is handled. Returning `true` specifically means "delivered", which is
|
||||||
|
* the truth here — it was delivered, to the inbox.
|
||||||
|
*
|
||||||
|
* @param null|bool $short Whatever an earlier filter decided.
|
||||||
|
* @param array $atts wp_mail()'s arguments.
|
||||||
|
* @return null|bool
|
||||||
|
*/
|
||||||
|
public static function intercept_mail( $short, $atts ) {
|
||||||
|
if ( null !== $short ) {
|
||||||
|
return $short; // Somebody else already claimed this send.
|
||||||
|
}
|
||||||
|
$to = isset( $atts['to'] ) ? $atts['to'] : array();
|
||||||
|
if ( ! is_array( $to ) ) {
|
||||||
|
$to = explode( ',', (string) $to );
|
||||||
|
}
|
||||||
|
$stub_domain = strtolower( SP_Settings::stub_email_domain() );
|
||||||
|
|
||||||
|
$captured = array();
|
||||||
|
$passing = array();
|
||||||
|
foreach ( $to as $recipient ) {
|
||||||
|
$addr = strtolower( trim( self::bare_address( $recipient ) ) );
|
||||||
|
if ( '' === $addr ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Any .invalid recipient is ours by definition — RFC 2606 says the
|
||||||
|
// TLD never resolves, so passing it to a mailer only produces a
|
||||||
|
// bounce or a hard error in the log.
|
||||||
|
if ( substr( $addr, -strlen( $stub_domain ) - 1 ) === '@' . $stub_domain
|
||||||
|
|| preg_match( '/@[^@]*\.invalid$/', $addr ) ) {
|
||||||
|
$captured[] = $addr;
|
||||||
|
} else {
|
||||||
|
$passing[] = $recipient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $captured ) {
|
||||||
|
return null; // Nothing of ours — let the normal mailer have it.
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = isset( $atts['subject'] ) ? (string) $atts['subject'] : __( '(no subject)', 'sirius-press' );
|
||||||
|
$body = isset( $atts['message'] ) ? (string) $atts['message'] : '';
|
||||||
|
foreach ( $captured as $addr ) {
|
||||||
|
$user = get_user_by( 'email', $addr );
|
||||||
|
self::add( $user ? (int) $user->ID : 0, $subject, self::to_html( $body ), 'wp_mail' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( $passing ) {
|
||||||
|
// A mixed send: the real recipients still deserve their copy, so
|
||||||
|
// re-enter wp_mail with only those. `pre_wp_mail` is not re-entered
|
||||||
|
// for them because their addresses are not placeholders.
|
||||||
|
$rest = $atts;
|
||||||
|
$rest['to'] = $passing;
|
||||||
|
wp_mail(
|
||||||
|
$rest['to'],
|
||||||
|
$subject,
|
||||||
|
$body,
|
||||||
|
isset( $rest['headers'] ) ? $rest['headers'] : '',
|
||||||
|
isset( $rest['attachments'] ) ? $rest['attachments'] : array()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull `foo@bar` out of `Name <foo@bar>`. */
|
||||||
|
private static function bare_address( $recipient ) {
|
||||||
|
$recipient = (string) $recipient;
|
||||||
|
if ( preg_match( '/<([^>]+)>/', $recipient, $m ) ) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return $recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text mail bodies are the common case; keep their line breaks. */
|
||||||
|
private static function to_html( $body ) {
|
||||||
|
if ( preg_match( '/<(a|p|br|div|table|html)\b/i', $body ) ) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
return wpautop( make_clickable( esc_html( $body ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- reading it
|
||||||
|
|
||||||
|
public static function unread_count( $user_id ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
return (int) $wpdb->get_var(
|
||||||
|
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND read_at IS NULL", (int) $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function recent( $user_id, $limit = 50, $offset = 0 ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
return (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE user_id = %d ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d", (int) $user_id, (int) $limit, (int) $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mark_all_read( $user_id ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
return (int) $wpdb->query(
|
||||||
|
$wpdb->prepare( "UPDATE {$table} SET read_at = %s WHERE user_id = %d AND read_at IS NULL", current_time( 'mysql', true ), (int) $user_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete_all( $user_id ) {
|
||||||
|
global $wpdb;
|
||||||
|
return (int) $wpdb->delete( self::table(), array( 'user_id' => (int) $user_id ), array( '%d' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- UI
|
||||||
|
|
||||||
|
public static function menu() {
|
||||||
|
$count = self::unread_count( get_current_user_id() );
|
||||||
|
$label = __( 'Inbox', 'sirius-press' );
|
||||||
|
if ( $count > 0 ) {
|
||||||
|
$label .= sprintf( ' <span class="awaiting-mod"><span class="pending-count">%d</span></span>', $count );
|
||||||
|
}
|
||||||
|
add_menu_page(
|
||||||
|
__( 'Inbox', 'sirius-press' ),
|
||||||
|
$label,
|
||||||
|
'read',
|
||||||
|
'sirius-inbox',
|
||||||
|
array( __CLASS__, 'render_page' ),
|
||||||
|
'dashicons-email-alt',
|
||||||
|
72
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function admin_bar( $bar ) {
|
||||||
|
if ( ! is_user_logged_in() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$count = self::unread_count( get_current_user_id() );
|
||||||
|
$bar->add_node(
|
||||||
|
array(
|
||||||
|
'id' => 'sirius-inbox',
|
||||||
|
'title' => '<span class="ab-icon dashicons dashicons-email-alt" style="top:2px"></span>'
|
||||||
|
. ( $count > 0 ? '<span class="ab-label">' . (int) $count . '</span>' : '' ),
|
||||||
|
'href' => admin_url( 'admin.php?page=sirius-inbox' ),
|
||||||
|
'meta' => array( 'title' => __( 'Sirius Press inbox', 'sirius-press' ) ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_page() {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
|
||||||
|
if ( isset( $_POST['sirius_inbox_action'] ) && check_admin_referer( 'sirius_inbox' ) ) {
|
||||||
|
$action = sanitize_key( wp_unslash( $_POST['sirius_inbox_action'] ) );
|
||||||
|
if ( 'read_all' === $action ) {
|
||||||
|
self::mark_all_read( $user_id );
|
||||||
|
} elseif ( 'delete_all' === $action ) {
|
||||||
|
self::delete_all( $user_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = self::recent( $user_id, 100 );
|
||||||
|
echo '<div class="wrap"><h1>' . esc_html__( 'Inbox', 'sirius-press' ) . '</h1>';
|
||||||
|
echo '<p class="description">' . esc_html__( 'Messages this site would have emailed to your account. Nothing here was sent anywhere.', 'sirius-press' ) . '</p>';
|
||||||
|
|
||||||
|
echo '<form method="post" style="margin:12px 0">';
|
||||||
|
wp_nonce_field( 'sirius_inbox' );
|
||||||
|
echo '<button class="button" name="sirius_inbox_action" value="read_all">' . esc_html__( 'Mark all read', 'sirius-press' ) . '</button> ';
|
||||||
|
echo '<button class="button" name="sirius_inbox_action" value="delete_all" onclick="return confirm(' . esc_attr( wp_json_encode( __( 'Delete every message in your inbox?', 'sirius-press' ) ) ) . ')">' . esc_html__( 'Delete all', 'sirius-press' ) . '</button>';
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
if ( ! $items ) {
|
||||||
|
echo '<p>' . esc_html__( 'Nothing here yet.', 'sirius-press' ) . '</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<table class="widefat striped"><thead><tr>';
|
||||||
|
echo '<th style="width:160px">' . esc_html__( 'When', 'sirius-press' ) . '</th>';
|
||||||
|
echo '<th>' . esc_html__( 'Message', 'sirius-press' ) . '</th>';
|
||||||
|
echo '</tr></thead><tbody>';
|
||||||
|
foreach ( $items as $item ) {
|
||||||
|
$unread = empty( $item->read_at );
|
||||||
|
printf(
|
||||||
|
'<tr%s><td>%s<br><small>%s</small></td><td><strong>%s</strong><div style="margin-top:6px">%s</div></td></tr>',
|
||||||
|
$unread ? ' style="font-weight:600;background:#fff8e5"' : '',
|
||||||
|
esc_html( mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $item->created_at ) ),
|
||||||
|
esc_html( $item->source ),
|
||||||
|
esc_html( $item->subject ),
|
||||||
|
wp_kses_post( $item->body )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</tbody></table></div>';
|
||||||
|
|
||||||
|
self::mark_all_read( $user_id );
|
||||||
|
}
|
||||||
|
}
|
||||||
175
plugins/sirius-press-core/includes/class-sp-message.php
Normal file
175
plugins/sirius-press-core/includes/class-sp-message.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The two signing envelopes Sirius Press speaks.
|
||||||
|
*
|
||||||
|
* **BIP-137** — the "Bitcoin Signed Message:\n" scheme every BCH wallet has
|
||||||
|
* implemented for a decade. Login, registration and any other place a *human*
|
||||||
|
* proves who they are uses this, because it is the only format a user can
|
||||||
|
* produce without installing anything of ours: Electron Cash's Sign Message
|
||||||
|
* box, the Theseus wallet bridge and the in-page wallet all emit it, so a
|
||||||
|
* signature can be pasted in from whatever the person already runs.
|
||||||
|
*
|
||||||
|
* **BNS-SITE1** — the BNS gateway's own envelope for `PUT /api/site/<name>/…`.
|
||||||
|
* Single SHA-256 over a fixed line format, no magic prefix. Only the server
|
||||||
|
* ever produces these, using the stored publishing key, so no wallet has to
|
||||||
|
* understand it.
|
||||||
|
*
|
||||||
|
* Getting these two confused is the single most likely source of a
|
||||||
|
* "signature does not match" that looks like a key problem, so they live side
|
||||||
|
* by side here rather than being inlined at their call sites.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/class-sp-secp256k1.php';
|
||||||
|
require_once __DIR__ . '/class-sp-cashaddr.php';
|
||||||
|
|
||||||
|
final class SP_Message {
|
||||||
|
|
||||||
|
const MAGIC = "Bitcoin Signed Message:\n";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- BIP-137
|
||||||
|
|
||||||
|
/** Bitcoin-style varint length prefix. */
|
||||||
|
private static function var_int( $n ) {
|
||||||
|
if ( $n < 0xfd ) {
|
||||||
|
return chr( $n );
|
||||||
|
}
|
||||||
|
if ( $n <= 0xffff ) {
|
||||||
|
return "\xfd" . pack( 'v', $n );
|
||||||
|
}
|
||||||
|
return "\xfe" . pack( 'V', $n );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function var_str( $s ) {
|
||||||
|
return self::var_int( strlen( $s ) ) . $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 32-byte digest a wallet actually signs for a text message.
|
||||||
|
*
|
||||||
|
* Double SHA-256 over varstr(magic) || varstr(message).
|
||||||
|
*/
|
||||||
|
public static function bip137_digest( $message ) {
|
||||||
|
$payload = self::var_str( self::MAGIC ) . self::var_str( (string) $message );
|
||||||
|
return hash( 'sha256', hash( 'sha256', $payload, true ), true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a wallet's message signature against a claimed address.
|
||||||
|
*
|
||||||
|
* The comparison is on the normalised address, so a user who registered
|
||||||
|
* with the plain form and signs with a token-aware wallet still matches.
|
||||||
|
* The network prefix is taken from the claimed address, which means a
|
||||||
|
* chipnet signature can never authenticate a mainnet account.
|
||||||
|
*
|
||||||
|
* @param string $message Exactly the text that was signed.
|
||||||
|
* @param string $sig_b64 Base64 of 65 bytes.
|
||||||
|
* @param string $address The CashAddress being claimed.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function verify( $message, $sig_b64, $address ) {
|
||||||
|
$claimed = SP_CashAddr::normalize( $address );
|
||||||
|
if ( '' === $claimed ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$decoded = SP_CashAddr::decode( $claimed );
|
||||||
|
$sig = base64_decode( (string) $sig_b64, true );
|
||||||
|
if ( false === $sig || 65 !== strlen( $sig ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$pub = SP_Secp256k1::recover( $sig, self::bip137_digest( $message ) );
|
||||||
|
if ( '' === $pub ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$derived = SP_CashAddr::from_public_key( $pub, $decoded['prefix'] );
|
||||||
|
return '' !== $derived && hash_equals( $claimed, $derived );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a BIP-137 signature. Used by tests and by WP-CLI helpers; the
|
||||||
|
* running site never signs on a user's behalf.
|
||||||
|
*
|
||||||
|
* @return string Base64 of 65 bytes.
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function sign( $message, $priv_bin ) {
|
||||||
|
return base64_encode( SP_Secp256k1::sign_recoverable( self::bip137_digest( $message ), $priv_bin ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- BNS-SITE1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digest for a gateway site write.
|
||||||
|
*
|
||||||
|
* Mirrors public-gateway.mjs exactly:
|
||||||
|
* sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
|
||||||
|
*
|
||||||
|
* @param string $name Fully-qualified BCNR name, e.g. "example.bch".
|
||||||
|
* @param string $path Path inside the name's bucket, no leading slash.
|
||||||
|
* @param string $body Raw bytes being uploaded ('' for DELETE).
|
||||||
|
* @param int $ts Unix milliseconds.
|
||||||
|
*/
|
||||||
|
public static function site_digest( $name, $path, $body, $ts ) {
|
||||||
|
$body_hex = hash( 'sha256', $body );
|
||||||
|
return hash( 'sha256', "BNS-SITE1\n{$name}\n{$path}\n{$body_hex}\n{$ts}", true );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `x-bns-sig` / `x-bns-ts` pair for a site write.
|
||||||
|
*
|
||||||
|
* @return array{'x-bns-sig':string,'x-bns-ts':string}
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function site_headers( $name, $path, $body, $priv_bin, $ts = null ) {
|
||||||
|
$ts = null === $ts ? (int) round( microtime( true ) * 1000 ) : (int) $ts;
|
||||||
|
$sig = SP_Secp256k1::sign_recoverable( self::site_digest( $name, $path, $body, $ts ), $priv_bin );
|
||||||
|
return array(
|
||||||
|
'x-bns-sig' => base64_encode( $sig ),
|
||||||
|
'x-bns-ts' => (string) $ts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- canonical JSON
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical JSON per DESIGN-signed-records-manifest.md: keys sorted at
|
||||||
|
* every level, no insignificant whitespace, non-ASCII \u-escaped, null
|
||||||
|
* members dropped.
|
||||||
|
*
|
||||||
|
* Only needed when this plugin writes a `_records.json` manifest — the
|
||||||
|
* export path signs raw bytes, not JSON. Kept here so there is exactly one
|
||||||
|
* canonicaliser in the codebase.
|
||||||
|
*/
|
||||||
|
public static function canonical_json( $value ) {
|
||||||
|
// JSON_UNESCAPED_SLASHES matches JavaScript's JSON.stringify, which the
|
||||||
|
// portal signs with; PHP would otherwise emit \/ and produce different
|
||||||
|
// bytes for the same manifest. Non-ASCII stays \u-escaped, which is
|
||||||
|
// json_encode's default and is what the spec asks for.
|
||||||
|
return json_encode( self::canonicalize( $value ), JSON_UNESCAPED_SLASHES );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function canonicalize( $value ) {
|
||||||
|
if ( is_object( $value ) ) {
|
||||||
|
$value = (array) $value;
|
||||||
|
}
|
||||||
|
if ( is_array( $value ) ) {
|
||||||
|
$is_list = ( array() === $value ) || array_keys( $value ) === range( 0, count( $value ) - 1 );
|
||||||
|
if ( $is_list ) {
|
||||||
|
return array_map( array( __CLASS__, 'canonicalize' ), $value );
|
||||||
|
}
|
||||||
|
$out = array();
|
||||||
|
foreach ( $value as $k => $v ) {
|
||||||
|
if ( null === $v ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[ (string) $k ] = self::canonicalize( $v );
|
||||||
|
}
|
||||||
|
ksort( $out, SORT_STRING );
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
375
plugins/sirius-press-core/includes/class-sp-secp256k1.php
Normal file
375
plugins/sirius-press-core/includes/class-sp-secp256k1.php
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* secp256k1 in pure PHP: recoverable message signatures, nothing else.
|
||||||
|
*
|
||||||
|
* Sirius Press replaces "prove you can read this mailbox" with "prove you hold
|
||||||
|
* this key", so signature verification sits on the login path and cannot be
|
||||||
|
* optional. Requiring a native secp256k1 extension would rule out most of the
|
||||||
|
* shared hosts this fork is meant to run on, so the curve lives here.
|
||||||
|
*
|
||||||
|
* Scope is deliberately narrow — the two operations the fork actually needs:
|
||||||
|
*
|
||||||
|
* sign_recoverable() produce the 65-byte [flag|r|s] blob the BNS gateway
|
||||||
|
* and the Sirius portal already speak.
|
||||||
|
* recover() pull the compressed public key back out of one, so a
|
||||||
|
* login signature can be turned into a CashAddress and
|
||||||
|
* compared to the user's stored address.
|
||||||
|
*
|
||||||
|
* Point arithmetic is Jacobian, so a scalar multiply costs exactly one modular
|
||||||
|
* inversion at the end instead of one per bit. That is what keeps the BCMath
|
||||||
|
* fallback in the "noticeable pause" range rather than the "gateway timeout"
|
||||||
|
* range.
|
||||||
|
*
|
||||||
|
* Signature format matches Argus/src/lib/wallet-web.js BuiltInWallet::signMessage
|
||||||
|
* byte for byte: flag = 27 + 4 + recovery_id, then r, then s, all big-endian.
|
||||||
|
* The +4 says "the public key was compressed"; the gateway recovers the id
|
||||||
|
* back out with (flag - 27) & 3.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/class-sp-bn.php';
|
||||||
|
|
||||||
|
final class SP_Secp256k1 {
|
||||||
|
|
||||||
|
const P = 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f';
|
||||||
|
const N = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141';
|
||||||
|
const GX = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||||||
|
const GY = '483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8';
|
||||||
|
|
||||||
|
/** Memoised curve constants, per request. */
|
||||||
|
private static $c = null;
|
||||||
|
|
||||||
|
private static function consts() {
|
||||||
|
if ( null === self::$c ) {
|
||||||
|
self::$c = array(
|
||||||
|
'p' => SP_BN::from_hex( self::P ),
|
||||||
|
'n' => SP_BN::from_hex( self::N ),
|
||||||
|
'gx' => SP_BN::from_hex( self::GX ),
|
||||||
|
'gy' => SP_BN::from_hex( self::GY ),
|
||||||
|
'zero' => SP_BN::from_int( 0 ),
|
||||||
|
'one' => SP_BN::from_int( 1 ),
|
||||||
|
'two' => SP_BN::from_int( 2 ),
|
||||||
|
'three' => SP_BN::from_int( 3 ),
|
||||||
|
'four' => SP_BN::from_int( 4 ),
|
||||||
|
'seven' => SP_BN::from_int( 7 ),
|
||||||
|
'eight' => SP_BN::from_int( 8 ),
|
||||||
|
);
|
||||||
|
// n/2, the low-S boundary.
|
||||||
|
self::$c['half_n'] = SP_BN::from_hex( '7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' );
|
||||||
|
}
|
||||||
|
return self::$c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- field ops
|
||||||
|
|
||||||
|
private static function fadd( $a, $b ) {
|
||||||
|
$c = self::consts();
|
||||||
|
return SP_BN::mod( SP_BN::add( $a, $b ), $c['p'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fsub( $a, $b ) {
|
||||||
|
$c = self::consts();
|
||||||
|
return SP_BN::mod( SP_BN::sub( $a, $b ), $c['p'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fmul( $a, $b ) {
|
||||||
|
$c = self::consts();
|
||||||
|
return SP_BN::mod( SP_BN::mul( $a, $b ), $c['p'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- Jacobian point maths
|
||||||
|
|
||||||
|
/** The point at infinity, in Jacobian coordinates (Z = 0). */
|
||||||
|
private static function jinf() {
|
||||||
|
$c = self::consts();
|
||||||
|
return array( $c['one'], $c['one'], $c['zero'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function is_inf( $pt ) {
|
||||||
|
return SP_BN::is_zero( $pt[2] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Affine (x, y) into Jacobian with Z = 1. */
|
||||||
|
private static function to_jacobian( $x, $y ) {
|
||||||
|
$c = self::consts();
|
||||||
|
return array( $x, $y, $c['one'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jacobian back to affine: (X/Z^2, Y/Z^3). The one inversion. */
|
||||||
|
private static function to_affine( $pt ) {
|
||||||
|
$c = self::consts();
|
||||||
|
if ( self::is_inf( $pt ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$zi = SP_BN::inv_mod( $pt[2], $c['p'] );
|
||||||
|
$zi2 = self::fmul( $zi, $zi );
|
||||||
|
$zi3 = self::fmul( $zi2, $zi );
|
||||||
|
return array( self::fmul( $pt[0], $zi2 ), self::fmul( $pt[1], $zi3 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point doubling, a = 0 case (dbl-2009-l). */
|
||||||
|
private static function jdouble( $pt ) {
|
||||||
|
$c = self::consts();
|
||||||
|
list( $x, $y, $z ) = $pt;
|
||||||
|
if ( SP_BN::is_zero( $z ) || SP_BN::is_zero( $y ) ) {
|
||||||
|
return self::jinf();
|
||||||
|
}
|
||||||
|
$a = self::fmul( $x, $x ); // X^2
|
||||||
|
$b = self::fmul( $y, $y ); // Y^2
|
||||||
|
$cc = self::fmul( $b, $b ); // Y^4
|
||||||
|
$d = self::fmul( $c['two'], self::fsub( self::fmul( self::fadd( $x, $b ), self::fadd( $x, $b ) ), self::fadd( $a, $cc ) ) );
|
||||||
|
$e = self::fmul( $c['three'], $a );
|
||||||
|
$f = self::fmul( $e, $e );
|
||||||
|
$x3 = self::fsub( $f, self::fmul( $c['two'], $d ) );
|
||||||
|
$y3 = self::fsub( self::fmul( $e, self::fsub( $d, $x3 ) ), self::fmul( $c['eight'], $cc ) );
|
||||||
|
$z3 = self::fmul( self::fmul( $c['two'], $y ), $z );
|
||||||
|
return array( $x3, $y3, $z3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point addition, Jacobian + Jacobian (add-2007-bl). */
|
||||||
|
private static function jadd( $p1, $p2 ) {
|
||||||
|
$c = self::consts();
|
||||||
|
if ( self::is_inf( $p1 ) ) {
|
||||||
|
return $p2;
|
||||||
|
}
|
||||||
|
if ( self::is_inf( $p2 ) ) {
|
||||||
|
return $p1;
|
||||||
|
}
|
||||||
|
list( $x1, $y1, $z1 ) = $p1;
|
||||||
|
list( $x2, $y2, $z2 ) = $p2;
|
||||||
|
|
||||||
|
$z1z1 = self::fmul( $z1, $z1 );
|
||||||
|
$z2z2 = self::fmul( $z2, $z2 );
|
||||||
|
$u1 = self::fmul( $x1, $z2z2 );
|
||||||
|
$u2 = self::fmul( $x2, $z1z1 );
|
||||||
|
$s1 = self::fmul( $y1, self::fmul( $z2, $z2z2 ) );
|
||||||
|
$s2 = self::fmul( $y2, self::fmul( $z1, $z1z1 ) );
|
||||||
|
|
||||||
|
if ( 0 === SP_BN::cmp( $u1, $u2 ) ) {
|
||||||
|
return 0 === SP_BN::cmp( $s1, $s2 ) ? self::jdouble( $p1 ) : self::jinf();
|
||||||
|
}
|
||||||
|
|
||||||
|
$h = self::fsub( $u2, $u1 );
|
||||||
|
$i = self::fmul( self::fmul( $c['two'], $h ), self::fmul( $c['two'], $h ) );
|
||||||
|
$j = self::fmul( $h, $i );
|
||||||
|
$r = self::fmul( $c['two'], self::fsub( $s2, $s1 ) );
|
||||||
|
$v = self::fmul( $u1, $i );
|
||||||
|
$x3 = self::fsub( self::fsub( self::fmul( $r, $r ), $j ), self::fmul( $c['two'], $v ) );
|
||||||
|
$y3 = self::fsub( self::fmul( $r, self::fsub( $v, $x3 ) ), self::fmul( $c['two'], self::fmul( $s1, $j ) ) );
|
||||||
|
$z3 = self::fmul( self::fsub( self::fmul( self::fadd( $z1, $z2 ), self::fadd( $z1, $z2 ) ), self::fadd( $z1z1, $z2z2 ) ), $h );
|
||||||
|
return array( $x3, $y3, $z3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scalar multiply, left-to-right double-and-add.
|
||||||
|
*
|
||||||
|
* Not constant time. It does not need to be: every scalar multiplied here
|
||||||
|
* is either a public value (recovery) or a per-signature nonce on a host
|
||||||
|
* where the attacker able to time it can already read the private key off
|
||||||
|
* disk.
|
||||||
|
*
|
||||||
|
* @return array{0:mixed,1:mixed}|null Affine point, or null for infinity.
|
||||||
|
*/
|
||||||
|
private static function mul_point( $k, $px, $py ) {
|
||||||
|
$c = self::consts();
|
||||||
|
$k = SP_BN::mod( $k, $c['n'] );
|
||||||
|
if ( SP_BN::is_zero( $k ) ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$base = self::to_jacobian( $px, $py );
|
||||||
|
$acc = self::jinf();
|
||||||
|
$bits = SP_BN::bits( $k );
|
||||||
|
$len = strlen( $bits );
|
||||||
|
for ( $i = 0; $i < $len; $i++ ) {
|
||||||
|
$acc = self::jdouble( $acc );
|
||||||
|
if ( '1' === $bits[ $i ] ) {
|
||||||
|
$acc = self::jadd( $acc, $base );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::to_affine( $acc );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ public API
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compressed public key for a private key.
|
||||||
|
*
|
||||||
|
* @param string $priv_bin 32 raw bytes.
|
||||||
|
* @return string 33 raw bytes, or '' if the key is out of range.
|
||||||
|
*/
|
||||||
|
public static function public_key( $priv_bin ) {
|
||||||
|
$c = self::consts();
|
||||||
|
$d = SP_BN::from_bin( $priv_bin );
|
||||||
|
if ( SP_BN::is_zero( $d ) || SP_BN::cmp( $d, $c['n'] ) >= 0 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$pt = self::mul_point( $d, $c['gx'], $c['gy'] );
|
||||||
|
return self::compress( $pt );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array|null $pt Affine point. */
|
||||||
|
private static function compress( $pt ) {
|
||||||
|
if ( null === $pt ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return chr( SP_BN::is_odd( $pt[1] ) ? 0x03 : 0x02 ) . SP_BN::to_bin( $pt[0], 32 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a 32-byte digest, producing the 65-byte recoverable blob.
|
||||||
|
*
|
||||||
|
* Low-S normalised, so the same message and key always yield the same
|
||||||
|
* bytes and nothing downstream has to worry about malleability.
|
||||||
|
*
|
||||||
|
* @param string $digest 32 raw bytes (already hashed).
|
||||||
|
* @param string $priv_bin 32 raw bytes.
|
||||||
|
* @return string 65 raw bytes.
|
||||||
|
* @throws Exception When the key is out of range or the host has no bignum extension.
|
||||||
|
*/
|
||||||
|
public static function sign_recoverable( $digest, $priv_bin ) {
|
||||||
|
if ( ! SP_BN::available() ) {
|
||||||
|
throw new Exception( 'Sirius Press needs either the GMP or the BCMath PHP extension to sign.' );
|
||||||
|
}
|
||||||
|
if ( 32 !== strlen( $digest ) || 32 !== strlen( $priv_bin ) ) {
|
||||||
|
throw new Exception( 'sign_recoverable expects a 32-byte digest and a 32-byte private key.' );
|
||||||
|
}
|
||||||
|
$c = self::consts();
|
||||||
|
$d = SP_BN::from_bin( $priv_bin );
|
||||||
|
if ( SP_BN::is_zero( $d ) || SP_BN::cmp( $d, $c['n'] ) >= 0 ) {
|
||||||
|
throw new Exception( 'private key out of range' );
|
||||||
|
}
|
||||||
|
$z = SP_BN::mod( SP_BN::from_bin( $digest ), $c['n'] );
|
||||||
|
|
||||||
|
foreach ( self::rfc6979_nonces( $digest, $priv_bin ) as $k ) {
|
||||||
|
$pt = self::mul_point( $k, $c['gx'], $c['gy'] );
|
||||||
|
if ( null === $pt ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$r = SP_BN::mod( $pt[0], $c['n'] );
|
||||||
|
if ( SP_BN::is_zero( $r ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$s = SP_BN::mod(
|
||||||
|
SP_BN::mul( SP_BN::inv_mod( $k, $c['n'] ), SP_BN::add( $z, SP_BN::mul( $r, $d ) ) ),
|
||||||
|
$c['n']
|
||||||
|
);
|
||||||
|
if ( SP_BN::is_zero( $s ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$recid = ( SP_BN::is_odd( $pt[1] ) ? 1 : 0 ) | ( SP_BN::cmp( $pt[0], $c['n'] ) >= 0 ? 2 : 0 );
|
||||||
|
if ( SP_BN::cmp( $s, $c['half_n'] ) > 0 ) {
|
||||||
|
$s = SP_BN::sub( $c['n'], $s );
|
||||||
|
$recid ^= 1;
|
||||||
|
}
|
||||||
|
return chr( 27 + 4 + $recid ) . SP_BN::to_bin( $r, 32 ) . SP_BN::to_bin( $s, 32 );
|
||||||
|
}
|
||||||
|
throw new Exception( 'could not find a valid signature nonce' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the compressed public key that produced a 65-byte signature.
|
||||||
|
*
|
||||||
|
* @param string $sig 65 raw bytes, [flag|r|s].
|
||||||
|
* @param string $digest 32 raw bytes.
|
||||||
|
* @return string 33 raw bytes, or '' when the signature is malformed or
|
||||||
|
* does not correspond to any point on the curve.
|
||||||
|
*/
|
||||||
|
public static function recover( $sig, $digest ) {
|
||||||
|
if ( ! SP_BN::available() || 65 !== strlen( $sig ) || 32 !== strlen( $digest ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$c = self::consts();
|
||||||
|
$flag = ord( $sig[0] );
|
||||||
|
$recid = ( $flag - 27 ) & 3;
|
||||||
|
$r = SP_BN::from_bin( substr( $sig, 1, 32 ) );
|
||||||
|
$s = SP_BN::from_bin( substr( $sig, 33, 32 ) );
|
||||||
|
if ( SP_BN::is_zero( $r ) || SP_BN::is_zero( $s )
|
||||||
|
|| SP_BN::cmp( $r, $c['n'] ) >= 0 || SP_BN::cmp( $s, $c['n'] ) >= 0 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// x = r + (recid >> 1) * n, which is only on the curve for the
|
||||||
|
// overflow cases the signer flagged.
|
||||||
|
$x = $r;
|
||||||
|
if ( $recid >= 2 ) {
|
||||||
|
$x = SP_BN::add( $r, $c['n'] );
|
||||||
|
if ( SP_BN::cmp( $x, $c['p'] ) >= 0 ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// y^2 = x^3 + 7; p ≡ 3 (mod 4) so the square root is a single powmod.
|
||||||
|
$alpha = self::fadd( self::fmul( self::fmul( $x, $x ), $x ), $c['seven'] );
|
||||||
|
$exp = self::sqrt_exponent();
|
||||||
|
$beta = SP_BN::pow_mod( $alpha, $exp, $c['p'] );
|
||||||
|
// Reject non-residues: if beta^2 != alpha there is no such point.
|
||||||
|
if ( 0 !== SP_BN::cmp( self::fmul( $beta, $beta ), $alpha ) ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$want_odd = ( $recid & 1 ) === 1;
|
||||||
|
$y = ( SP_BN::is_odd( $beta ) === $want_odd ) ? $beta : SP_BN::sub( $c['p'], $beta );
|
||||||
|
|
||||||
|
// Q = r^-1 (sR - zG)
|
||||||
|
$z = SP_BN::mod( SP_BN::from_bin( $digest ), $c['n'] );
|
||||||
|
$rinv = SP_BN::inv_mod( $r, $c['n'] );
|
||||||
|
|
||||||
|
$sr = self::mul_point( $s, $x, $y );
|
||||||
|
$zg = self::mul_point( SP_BN::mod( SP_BN::sub( $c['n'], $z ), $c['n'] ), $c['gx'], $c['gy'] );
|
||||||
|
if ( null === $sr ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$sum = null === $zg
|
||||||
|
? self::to_jacobian( $sr[0], $sr[1] )
|
||||||
|
: self::jadd( self::to_jacobian( $sr[0], $sr[1] ), self::to_jacobian( $zg[0], $zg[1] ) );
|
||||||
|
$sum = self::to_affine( $sum );
|
||||||
|
if ( null === $sum ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$q = self::mul_point( $rinv, $sum[0], $sum[1] );
|
||||||
|
return self::compress( $q );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (p + 1) / 4, precomputed as hex — the square-root exponent. */
|
||||||
|
private static function sqrt_exponent() {
|
||||||
|
return SP_BN::from_hex( '3fffffffffffffffffffffffffffffffffffffffffffffffffffffffbfffff0c' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- nonces
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 6979 deterministic nonces, yielded one at a time.
|
||||||
|
*
|
||||||
|
* Determinism matters here beyond tidiness: a PHP process on a shared host
|
||||||
|
* is exactly the place where a weak random source silently leaks the key
|
||||||
|
* across two signatures. Nothing in this path touches the system RNG.
|
||||||
|
*
|
||||||
|
* @return Generator
|
||||||
|
*/
|
||||||
|
private static function rfc6979_nonces( $digest, $priv_bin ) {
|
||||||
|
$c = self::consts();
|
||||||
|
$h1 = $digest;
|
||||||
|
// bits2octets(h1): reduce the digest mod n, re-pad to 32 bytes.
|
||||||
|
$h1_oct = SP_BN::to_bin( SP_BN::mod( SP_BN::from_bin( $h1 ), $c['n'] ), 32 );
|
||||||
|
|
||||||
|
$v = str_repeat( "\x01", 32 );
|
||||||
|
$k = str_repeat( "\x00", 32 );
|
||||||
|
|
||||||
|
$k = hash_hmac( 'sha256', $v . "\x00" . $priv_bin . $h1_oct, $k, true );
|
||||||
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||||
|
$k = hash_hmac( 'sha256', $v . "\x01" . $priv_bin . $h1_oct, $k, true );
|
||||||
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||||
|
|
||||||
|
for ( $attempt = 0; $attempt < 64; $attempt++ ) {
|
||||||
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||||
|
$cand = SP_BN::from_bin( $v );
|
||||||
|
if ( ! SP_BN::is_zero( $cand ) && SP_BN::cmp( $cand, $c['n'] ) < 0 ) {
|
||||||
|
yield $cand;
|
||||||
|
}
|
||||||
|
$k = hash_hmac( 'sha256', $v . "\x00", $k, true );
|
||||||
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
211
plugins/sirius-press-core/includes/class-sp-settings.php
Normal file
211
plugins/sirius-press-core/includes/class-sp-settings.php
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
plugins/sirius-press-core/sirius-press-core.php
Normal file
134
plugins/sirius-press-core/sirius-press-core.php
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Sirius Press Core
|
||||||
|
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||||
|
* Description: Wallet identity, gateway publishing and the in-app inbox that the rest of Sirius Press is built on.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Requires at least: 6.5
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Author: Silent Mode
|
||||||
|
* Author URI: https://silentmode.st
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
* Text Domain: sirius-press
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'SIRIUS_PRESS_VERSION', '0.1.0' );
|
||||||
|
define( 'SIRIUS_PRESS_CORE_FILE', __FILE__ );
|
||||||
|
define( 'SIRIUS_PRESS_CORE_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'SIRIUS_PRESS_CORE_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
array(
|
||||||
|
'class-sp-bn',
|
||||||
|
'class-sp-secp256k1',
|
||||||
|
'class-sp-cashaddr',
|
||||||
|
'class-sp-hd',
|
||||||
|
'class-sp-message',
|
||||||
|
'class-sp-settings',
|
||||||
|
'class-sp-gateway',
|
||||||
|
'class-sp-identity',
|
||||||
|
'class-sp-inbox',
|
||||||
|
'class-sp-admin',
|
||||||
|
) as $sp_class
|
||||||
|
) {
|
||||||
|
require_once SIRIUS_PRESS_CORE_DIR . 'includes/' . $sp_class . '.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function this whole fork turns on.
|
||||||
|
*
|
||||||
|
* Core calls `is_email()` in a dozen places to decide whether a string is a
|
||||||
|
* usable identity. Sirius Press answers the same question about a different
|
||||||
|
* kind of string, and the core patches in `patches/` swap the call rather
|
||||||
|
* than changing what `is_email()` itself means — plugins still need the real
|
||||||
|
* `is_email()` for contact forms and newsletters.
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
function is_cashaddr( $value ) {
|
||||||
|
return SP_CashAddr::is_valid( $value );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address that identifies a user, or '' — the `user_email` of this fork.
|
||||||
|
*
|
||||||
|
* @param int|WP_User|null $user Defaults to the current user.
|
||||||
|
*/
|
||||||
|
function sirius_press_address( $user = null ) {
|
||||||
|
if ( null === $user ) {
|
||||||
|
$user = get_current_user_id();
|
||||||
|
}
|
||||||
|
$id = $user instanceof WP_User ? $user->ID : (int) $user;
|
||||||
|
return $id ? SP_Identity::address_of( $id ) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this installation can do wallet cryptography at all.
|
||||||
|
*
|
||||||
|
* Checked on activation and shown as an admin notice rather than left to
|
||||||
|
* surface as a fatal during someone's first login attempt.
|
||||||
|
*
|
||||||
|
* @return string '' when fine, otherwise what is missing.
|
||||||
|
*/
|
||||||
|
function sirius_press_requirements_problem() {
|
||||||
|
if ( ! SP_BN::available() ) {
|
||||||
|
return __( 'Sirius Press needs either the GMP or the BCMath PHP extension to verify wallet signatures. Ask your host to enable one — GMP is much faster.', 'sirius-press' );
|
||||||
|
}
|
||||||
|
if ( PHP_INT_SIZE < 8 ) {
|
||||||
|
return __( 'Sirius Press needs 64-bit PHP. Address checksums cannot be computed on a 32-bit build.', 'sirius-press' );
|
||||||
|
}
|
||||||
|
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||||||
|
return __( 'Sirius Press needs the OpenSSL PHP extension to store a publishing key safely.', 'sirius-press' );
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
register_activation_hook(
|
||||||
|
__FILE__,
|
||||||
|
function () {
|
||||||
|
SP_Inbox::install();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'plugins_loaded',
|
||||||
|
function () {
|
||||||
|
load_plugin_textdomain( 'sirius-press', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
|
||||||
|
|
||||||
|
if ( (int) get_option( 'sirius_press_inbox_version', 0 ) < SP_Inbox::VERSION ) {
|
||||||
|
SP_Inbox::install();
|
||||||
|
}
|
||||||
|
|
||||||
|
SP_Identity::hooks();
|
||||||
|
SP_Inbox::hooks();
|
||||||
|
SP_Admin::hooks();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'admin_notices',
|
||||||
|
function () {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$problem = sirius_press_requirements_problem();
|
||||||
|
if ( '' !== $problem ) {
|
||||||
|
printf( '<div class="notice notice-error"><p>%s</p></div>', esc_html( $problem ) );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! SP_Settings::is_configured() ) {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-warning"><p>%s <a href="%s">%s</a></p></div>',
|
||||||
|
esc_html__( 'Sirius Press does not know which BCNR name this site publishes under yet.', 'sirius-press' ),
|
||||||
|
esc_url( admin_url( 'admin.php?page=sirius-press' ) ),
|
||||||
|
esc_html__( 'Set it now', 'sirius-press' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
190
plugins/sirius-press-sia-export/assets/export.js
Normal file
190
plugins/sirius-press-sia-export/assets/export.js
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
// Manual-mode publishing: sign in the browser, upload straight to the gateway.
|
||||||
|
//
|
||||||
|
// The point of this file is that the server never holds the key. WordPress
|
||||||
|
// builds the bytes — it is the only thing that can, since it knows how the
|
||||||
|
// theme renders — and then hands them here. This page hashes them, signs the
|
||||||
|
// gateway's upload envelope with a phrase typed a moment ago, PUTs the file,
|
||||||
|
// and tells WordPress what happened. The phrase is wiped when the run ends.
|
||||||
|
//
|
||||||
|
// The envelope is BNS-SITE1:
|
||||||
|
// sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<unix_ms>")
|
||||||
|
// signed as a 65-byte recoverable signature, base64, in x-bns-sig, with the
|
||||||
|
// same timestamp in x-bns-ts. The gateway recovers the signer and checks it
|
||||||
|
// against whoever owns the name on-chain right now.
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const CFG = window.SIRIUS_EXPORT || {};
|
||||||
|
const els = {};
|
||||||
|
let stopping = false;
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
const hex = (bytes) => [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
|
function log(text, kind = "") {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.textContent = text;
|
||||||
|
if (kind === "error") li.style.color = "#b32d2e";
|
||||||
|
if (kind === "ok") li.style.color = "#007017";
|
||||||
|
els.log.prepend(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(text) {
|
||||||
|
els.status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(CFG.restUrl + path, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-wp-nonce": CFG.nonce,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const json = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(json.message || `WordPress returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign and PUT one file. Returns nothing; throws on failure. */
|
||||||
|
async function upload(wallet, item) {
|
||||||
|
const body = base64ToBytes(item.body_b64);
|
||||||
|
const ts = Date.now();
|
||||||
|
const envelope = `BNS-SITE1\n${CFG.name}\n${item.path}\n${item.sha256}\n${ts}`;
|
||||||
|
const digest = await wallet.sha256(enc.encode(envelope));
|
||||||
|
const signature = await wallet.signRaw(digest);
|
||||||
|
|
||||||
|
const url =
|
||||||
|
CFG.gateway.replace(/\/$/, "") +
|
||||||
|
"/api/site/" +
|
||||||
|
encodeURIComponent(CFG.name) +
|
||||||
|
"/" +
|
||||||
|
item.path.split("/").map(encodeURIComponent).join("/");
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"content-type": item.mime,
|
||||||
|
"x-bns-sig": signature,
|
||||||
|
"x-bns-ts": String(ts),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !json.ok) {
|
||||||
|
throw new Error(json.error || `gateway returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (running) return;
|
||||||
|
|
||||||
|
const phrase = els.phrase.value;
|
||||||
|
const check = window.SiriusWallet.validatePhrase(phrase);
|
||||||
|
if (!check.ok) {
|
||||||
|
status(check.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wallet;
|
||||||
|
try {
|
||||||
|
wallet = await window.SiriusWallet.fromPhrase(phrase, {
|
||||||
|
prefix: CFG.prefix,
|
||||||
|
path: CFG.path,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
status(err.message || String(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Out of the DOM as soon as it has been used. The wallet object keeps the
|
||||||
|
// derived key for the run and is wiped in the finally below.
|
||||||
|
els.phrase.value = "";
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
stopping = false;
|
||||||
|
els.stop.hidden = false;
|
||||||
|
els.run.disabled = true;
|
||||||
|
log(`signing as ${wallet.address}`);
|
||||||
|
|
||||||
|
let published = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
if (stopping) {
|
||||||
|
status("Stopped.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
status("Building the next batch…");
|
||||||
|
const batch = await api("/next?limit=3");
|
||||||
|
if (!batch.items.length) {
|
||||||
|
status(
|
||||||
|
published || failed
|
||||||
|
? `Finished — ${published} published, ${failed} failed.`
|
||||||
|
: "Nothing queued.",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of batch.items) {
|
||||||
|
if (stopping) break;
|
||||||
|
status(`Publishing ${item.path} — ${batch.left} left`);
|
||||||
|
try {
|
||||||
|
await upload(wallet, item);
|
||||||
|
await api("/ack", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id: item.id, sha256: item.sha256 }),
|
||||||
|
});
|
||||||
|
published++;
|
||||||
|
log(`✓ ${item.path}`, "ok");
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
const message = err.message || String(err);
|
||||||
|
log(`✗ ${item.path} — ${message}`, "error");
|
||||||
|
await api("/ack", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id: item.id, error: message }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status(err.message || String(err));
|
||||||
|
} finally {
|
||||||
|
if (wallet.forget) wallet.forget();
|
||||||
|
running = false;
|
||||||
|
els.stop.hidden = true;
|
||||||
|
els.run.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
els.phrase = document.getElementById("sirius_export_phrase");
|
||||||
|
els.run = document.getElementById("sirius_export_run");
|
||||||
|
els.stop = document.getElementById("sirius_export_stop");
|
||||||
|
els.status = document.getElementById("sirius_export_status");
|
||||||
|
els.log = document.getElementById("sirius_export_log");
|
||||||
|
if (!els.run || !window.SiriusWallet) return;
|
||||||
|
|
||||||
|
els.run.addEventListener("click", run);
|
||||||
|
els.stop.addEventListener("click", () => {
|
||||||
|
stopping = true;
|
||||||
|
status("Stopping after this file…");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
})();
|
||||||
311
plugins/sirius-press-sia-export/includes/class-spe-admin.php
Normal file
311
plugins/sirius-press-sia-export/includes/class-spe-admin.php
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The export screen.
|
||||||
|
*
|
||||||
|
* Shows what is waiting, what failed and why, and offers the two buttons that
|
||||||
|
* matter: export everything, and try the failures again.
|
||||||
|
*
|
||||||
|
* It also hosts manual signing. In manual mode the server has no key, so the
|
||||||
|
* upload cannot happen without a person: this page builds each queued file
|
||||||
|
* server-side, hands the bytes to the browser, and the browser signs and PUTs
|
||||||
|
* them straight to the gateway. The file never round-trips through anyone
|
||||||
|
* else's server, and the key never touches this one.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPE_Admin {
|
||||||
|
|
||||||
|
const PAGE = 'sirius-press-export';
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'admin_menu', array( __CLASS__, 'menu' ), 30 );
|
||||||
|
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
|
||||||
|
add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function menu() {
|
||||||
|
add_submenu_page(
|
||||||
|
'sirius-press',
|
||||||
|
__( 'Publishing', 'sirius-press' ),
|
||||||
|
__( 'Publishing', 'sirius-press' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE,
|
||||||
|
array( __CLASS__, 'render' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function enqueue( $hook ) {
|
||||||
|
if ( false === strpos( (string) $hook, self::PAGE ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( SP_Settings::MODE_SERVER === SP_Settings::mode() ) {
|
||||||
|
return; // Nothing to sign here; cron has the key.
|
||||||
|
}
|
||||||
|
if ( ! class_exists( 'SPA_Login' ) ) {
|
||||||
|
return; // Auth plugin inactive — no wallet library to lean on.
|
||||||
|
}
|
||||||
|
SPA_Login::enqueue_wallet();
|
||||||
|
wp_enqueue_script(
|
||||||
|
'sirius-press-export',
|
||||||
|
SIRIUS_PRESS_EXPORT_URL . 'assets/export.js',
|
||||||
|
array( 'sirius-press-wallet', 'wp-api-fetch' ),
|
||||||
|
SIRIUS_PRESS_EXPORT_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
wp_localize_script(
|
||||||
|
'sirius-press-export',
|
||||||
|
'SIRIUS_EXPORT',
|
||||||
|
array(
|
||||||
|
'restUrl' => esc_url_raw( rest_url( 'sirius-press/v1/export' ) ),
|
||||||
|
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
|
'gateway' => SP_Settings::gateway(),
|
||||||
|
'name' => SP_Settings::name(),
|
||||||
|
'prefix' => SP_Settings::prefix(),
|
||||||
|
'path' => SP_Settings::derivation_path(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
add_action( 'admin_footer', array( 'SPA_Login', 'print_config' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- manual-signing routes
|
||||||
|
|
||||||
|
public static function register_routes() {
|
||||||
|
$can_manage = function () {
|
||||||
|
return current_user_can( 'manage_options' );
|
||||||
|
};
|
||||||
|
|
||||||
|
register_rest_route(
|
||||||
|
'sirius-press/v1',
|
||||||
|
'/export/next',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::READABLE,
|
||||||
|
'permission_callback' => $can_manage,
|
||||||
|
'callback' => array( __CLASS__, 'rest_next' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
register_rest_route(
|
||||||
|
'sirius-press/v1',
|
||||||
|
'/export/ack',
|
||||||
|
array(
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'permission_callback' => $can_manage,
|
||||||
|
'callback' => array( __CLASS__, 'rest_ack' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the next few queued files and hand them to the browser.
|
||||||
|
*
|
||||||
|
* Bodies come back base64-encoded because a queued file may be a PNG, and
|
||||||
|
* JSON has no way to carry arbitrary bytes otherwise.
|
||||||
|
*/
|
||||||
|
public static function rest_next( WP_REST_Request $request ) {
|
||||||
|
$limit = max( 1, min( 5, (int) $request->get_param( 'limit' ) ?: 3 ) );
|
||||||
|
$items = array();
|
||||||
|
|
||||||
|
foreach ( SPE_Queue::claim( $limit ) as $row ) {
|
||||||
|
$body = SPE_Runner::build( $row );
|
||||||
|
if ( is_wp_error( $body ) ) {
|
||||||
|
SPE_Queue::mark_failed( $row->id, $body->get_error_message() );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hash = hash( 'sha256', $body );
|
||||||
|
if ( '' !== $row->content_hash && hash_equals( $row->content_hash, $hash ) ) {
|
||||||
|
SPE_Queue::mark_done( $row->id, $hash );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = array(
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'path' => $row->path,
|
||||||
|
'mime' => SP_Gateway::mime_for( $row->path ),
|
||||||
|
'sha256' => $hash,
|
||||||
|
'body_b64' => base64_encode( $body ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rest_ensure_response(
|
||||||
|
array(
|
||||||
|
'items' => $items,
|
||||||
|
'left' => SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The browser reports how an upload went. */
|
||||||
|
public static function rest_ack( WP_REST_Request $request ) {
|
||||||
|
$id = (int) $request->get_param( 'id' );
|
||||||
|
$hash = (string) $request->get_param( 'sha256' );
|
||||||
|
$error = (string) $request->get_param( 'error' );
|
||||||
|
|
||||||
|
if ( '' !== $error ) {
|
||||||
|
SPE_Queue::mark_failed( $id, $error );
|
||||||
|
return rest_ensure_response( array( 'ok' => false ) );
|
||||||
|
}
|
||||||
|
if ( ! preg_match( '/^[a-f0-9]{64}$/', $hash ) ) {
|
||||||
|
return new WP_Error( 'sirius_bad_hash', __( 'Bad hash.', 'sirius-press' ), array( 'status' => 400 ) );
|
||||||
|
}
|
||||||
|
SPE_Queue::mark_done( $id, $hash );
|
||||||
|
return rest_ensure_response( array( 'ok' => true ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ page
|
||||||
|
|
||||||
|
public static function render() {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_die( esc_html__( 'You are not allowed to publish this site.', 'sirius-press' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$notes = array();
|
||||||
|
if ( isset( $_POST['sirius_export_action'] ) && check_admin_referer( 'sirius_export' ) ) {
|
||||||
|
$action = sanitize_key( wp_unslash( $_POST['sirius_export_action'] ) );
|
||||||
|
if ( 'full' === $action ) {
|
||||||
|
$n = SPE_Runner::queue_full_site();
|
||||||
|
$notes[] = sprintf(
|
||||||
|
/* translators: %d: number of pages queued. */
|
||||||
|
_n( '%d page queued for export.', '%d pages queued for export.', $n, 'sirius-press' ),
|
||||||
|
$n
|
||||||
|
);
|
||||||
|
} elseif ( 'retry' === $action ) {
|
||||||
|
$n = SPE_Queue::retry_failed();
|
||||||
|
$notes[] = sprintf(
|
||||||
|
/* translators: %d: number of failed exports re-queued. */
|
||||||
|
_n( '%d failed export queued again.', '%d failed exports queued again.', $n, 'sirius-press' ),
|
||||||
|
$n
|
||||||
|
);
|
||||||
|
} elseif ( 'run' === $action ) {
|
||||||
|
$r = SPE_Runner::run_batch( 20 );
|
||||||
|
$notes[] = sprintf(
|
||||||
|
/* translators: 1: uploaded, 2: unchanged, 3: failed, 4: remaining. */
|
||||||
|
__( 'Uploaded %1$d, %2$d unchanged, %3$d failed, %4$d still queued.', 'sirius-press' ),
|
||||||
|
$r['done'],
|
||||||
|
$r['skipped'],
|
||||||
|
$r['failed'],
|
||||||
|
$r['left']
|
||||||
|
);
|
||||||
|
} elseif ( 'clear' === $action ) {
|
||||||
|
SPE_Queue::clear_done();
|
||||||
|
$notes[] = __( 'Completed rows cleared.', 'sirius-press' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = SPE_Queue::counts();
|
||||||
|
$manual = SP_Settings::MODE_SERVER !== SP_Settings::mode();
|
||||||
|
|
||||||
|
echo '<div class="wrap"><h1>' . esc_html__( 'Publishing', 'sirius-press' ) . '</h1>';
|
||||||
|
|
||||||
|
foreach ( $notes as $n ) {
|
||||||
|
printf( '<div class="notice notice-success"><p>%s</p></div>', esc_html( $n ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! SP_Settings::is_configured() ) {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-error"><p>%s <a href="%s">%s</a></p></div>',
|
||||||
|
esc_html__( 'No BCNR name is configured, so there is nowhere to publish to.', 'sirius-press' ),
|
||||||
|
esc_url( admin_url( 'admin.php?page=sirius-press' ) ),
|
||||||
|
esc_html__( 'Settings', 'sirius-press' )
|
||||||
|
);
|
||||||
|
echo '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<p>%s <code>%s</code>%s</p>',
|
||||||
|
esc_html__( 'Publishing to', 'sirius-press' ),
|
||||||
|
esc_html( SP_Settings::name() ),
|
||||||
|
$manual
|
||||||
|
? ' — ' . esc_html__( 'signing happens in your browser, on this page.', 'sirius-press' )
|
||||||
|
: ' — ' . esc_html__( 'this server signs uploads by itself.', 'sirius-press' )
|
||||||
|
);
|
||||||
|
|
||||||
|
printf(
|
||||||
|
'<p><strong>%1$d</strong> %2$s <strong>%3$d</strong> %4$s <strong>%5$d</strong> %6$s</p>',
|
||||||
|
(int) $counts[ SPE_Queue::STATUS_PENDING ],
|
||||||
|
esc_html__( 'queued', 'sirius-press' ),
|
||||||
|
(int) $counts[ SPE_Queue::STATUS_DONE ],
|
||||||
|
esc_html__( 'published', 'sirius-press' ),
|
||||||
|
(int) $counts[ SPE_Queue::STATUS_FAILED ],
|
||||||
|
esc_html__( 'failed', 'sirius-press' )
|
||||||
|
);
|
||||||
|
|
||||||
|
echo '<form method="post" style="margin:16px 0">';
|
||||||
|
wp_nonce_field( 'sirius_export' );
|
||||||
|
echo '<button class="button button-primary" name="sirius_export_action" value="full">'
|
||||||
|
. esc_html__( 'Export everything', 'sirius-press' ) . '</button> ';
|
||||||
|
if ( ! $manual ) {
|
||||||
|
echo '<button class="button" name="sirius_export_action" value="run">'
|
||||||
|
. esc_html__( 'Run a batch now', 'sirius-press' ) . '</button> ';
|
||||||
|
}
|
||||||
|
echo '<button class="button" name="sirius_export_action" value="retry">'
|
||||||
|
. esc_html__( 'Try failures again', 'sirius-press' ) . '</button> ';
|
||||||
|
echo '<button class="button" name="sirius_export_action" value="clear">'
|
||||||
|
. esc_html__( 'Clear finished rows', 'sirius-press' ) . '</button>';
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
if ( $manual ) {
|
||||||
|
self::render_manual_panel();
|
||||||
|
}
|
||||||
|
|
||||||
|
self::render_queue_table();
|
||||||
|
echo '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function render_manual_panel() {
|
||||||
|
?>
|
||||||
|
<div id="sirius-export-panel" class="sirius-wallet" style="max-width:820px">
|
||||||
|
<h2 style="margin-top:0"><?php esc_html_e( 'Sign and upload', 'sirius-press' ); ?></h2>
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Your phrase is used in this page to sign each upload and is wiped when the run finishes. Files go straight from this browser to the gateway.', 'sirius-press' ); ?>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="sirius_export_phrase"><strong><?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?></strong></label><br>
|
||||||
|
<textarea id="sirius_export_phrase" rows="2" style="width:100%;font-family:monospace"
|
||||||
|
autocomplete="off" spellcheck="false"></textarea>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" class="button button-primary" id="sirius_export_run">
|
||||||
|
<?php esc_html_e( 'Publish queued pages', 'sirius-press' ); ?>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button" id="sirius_export_stop" hidden>
|
||||||
|
<?php esc_html_e( 'Stop', 'sirius-press' ); ?>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p id="sirius_export_status" role="status" aria-live="polite"></p>
|
||||||
|
<ul id="sirius_export_log" style="font-family:monospace;font-size:12px;max-height:240px;overflow:auto;margin:0"></ul>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function render_queue_table() {
|
||||||
|
$rows = SPE_Queue::recent( 100 );
|
||||||
|
echo '<h2>' . esc_html__( 'Queue', 'sirius-press' ) . '</h2>';
|
||||||
|
if ( ! $rows ) {
|
||||||
|
echo '<p>' . esc_html__( 'Nothing queued. Publish a post, or use “Export everything”.', 'sirius-press' ) . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
echo '<table class="widefat striped"><thead><tr>';
|
||||||
|
echo '<th>' . esc_html__( 'Path', 'sirius-press' ) . '</th>';
|
||||||
|
echo '<th style="width:100px">' . esc_html__( 'Status', 'sirius-press' ) . '</th>';
|
||||||
|
echo '<th style="width:60px">' . esc_html__( 'Tries', 'sirius-press' ) . '</th>';
|
||||||
|
echo '<th>' . esc_html__( 'Last error', 'sirius-press' ) . '</th>';
|
||||||
|
echo '</tr></thead><tbody>';
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$colour = SPE_Queue::STATUS_FAILED === $row->status
|
||||||
|
? '#b32d2e'
|
||||||
|
: ( SPE_Queue::STATUS_DONE === $row->status ? '#007017' : '#646970' );
|
||||||
|
printf(
|
||||||
|
'<tr><td><code>%s</code></td><td style="color:%s;font-weight:600">%s</td><td>%d</td><td><small>%s</small></td></tr>',
|
||||||
|
esc_html( $row->path ),
|
||||||
|
esc_attr( $colour ),
|
||||||
|
esc_html( $row->status ),
|
||||||
|
(int) $row->attempts,
|
||||||
|
esc_html( (string) $row->last_error )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
echo '</tbody></table>';
|
||||||
|
}
|
||||||
|
}
|
||||||
144
plugins/sirius-press-sia-export/includes/class-spe-cli.php
Normal file
144
plugins/sirius-press-sia-export/includes/class-spe-cli.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WP-CLI commands for publishing.
|
||||||
|
*
|
||||||
|
* The one place the fork is genuinely better driven from a terminal is the
|
||||||
|
* first full export of an existing site: thousands of pages, a long run, and
|
||||||
|
* a browser tab that must not be closed. `wp sirius export --all` does it
|
||||||
|
* with no tab involved.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish this site to its BCNR name.
|
||||||
|
*/
|
||||||
|
final class SPE_CLI {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue pages and push them to the name's storage.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* [--all]
|
||||||
|
* : Queue every published page, archive and attachment page first.
|
||||||
|
*
|
||||||
|
* [--limit=<n>]
|
||||||
|
* : Stop after this many uploads. Default: everything queued.
|
||||||
|
*
|
||||||
|
* [--dry-run]
|
||||||
|
* : Build each file and report what would be uploaded, without uploading.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp sirius export --all
|
||||||
|
* wp sirius export --limit=50
|
||||||
|
* wp sirius export --all --dry-run
|
||||||
|
*
|
||||||
|
* @when after_wp_load
|
||||||
|
*/
|
||||||
|
public function export( $args, $assoc ) {
|
||||||
|
if ( ! SP_Settings::is_configured() ) {
|
||||||
|
WP_CLI::error( 'No BCNR name is configured. Set one in Sirius Press settings first.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! empty( $assoc['all'] ) ) {
|
||||||
|
$queued = SPE_Runner::queue_full_site();
|
||||||
|
WP_CLI::log( sprintf( 'Queued %d paths.', $queued ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$dry = ! empty( $assoc['dry-run'] );
|
||||||
|
$limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 0;
|
||||||
|
|
||||||
|
if ( ! $dry && SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||||||
|
WP_CLI::error(
|
||||||
|
'This site is set to manual signing, so no key is available here. '
|
||||||
|
. 'Switch to automatic publishing in settings, or sign from the Publishing screen in wp-admin.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$done = 0;
|
||||||
|
$fail = 0;
|
||||||
|
$skip = 0;
|
||||||
|
|
||||||
|
while ( true ) {
|
||||||
|
$rows = SPE_Queue::claim( 10 );
|
||||||
|
if ( ! $rows ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
if ( $limit > 0 && $done + $fail + $skip >= $limit ) {
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
if ( $dry ) {
|
||||||
|
$body = SPE_Runner::build( $row );
|
||||||
|
if ( is_wp_error( $body ) ) {
|
||||||
|
WP_CLI::warning( sprintf( '%s — %s', $row->path, $body->get_error_message() ) );
|
||||||
|
$fail++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
WP_CLI::log( sprintf( '%s (%s)', $row->path, size_format( strlen( $body ) ) ) );
|
||||||
|
$skip++;
|
||||||
|
// A dry run must not consume the queue, so the row is left
|
||||||
|
// pending — which also means this loop would spin forever
|
||||||
|
// on the same rows. Stop after one pass instead.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$outcome = SPE_Runner::process( $row );
|
||||||
|
if ( 'done' === $outcome ) {
|
||||||
|
$done++;
|
||||||
|
WP_CLI::log( sprintf( '✓ %s', $row->path ) );
|
||||||
|
} elseif ( 'skipped' === $outcome ) {
|
||||||
|
$skip++;
|
||||||
|
} else {
|
||||||
|
$fail++;
|
||||||
|
WP_CLI::warning( sprintf( '✗ %s', $row->path ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( $dry ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI::success( sprintf( '%d uploaded, %d unchanged, %d failed.', $done, $skip, $fail ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the publishing status of this site.
|
||||||
|
*
|
||||||
|
* @when after_wp_load
|
||||||
|
*/
|
||||||
|
public function status() {
|
||||||
|
$counts = SPE_Queue::counts();
|
||||||
|
$owner = SP_Gateway::owner_of_name();
|
||||||
|
$mine = SP_Settings::publishing_address();
|
||||||
|
|
||||||
|
WP_CLI::log( 'Name: ' . ( SP_Settings::name() ?: '(not set)' ) );
|
||||||
|
WP_CLI::log( 'Network: ' . SP_Settings::network() );
|
||||||
|
WP_CLI::log( 'Gateway: ' . SP_Settings::gateway() );
|
||||||
|
WP_CLI::log( 'Mode: ' . SP_Settings::mode() );
|
||||||
|
WP_CLI::log( 'Signs as: ' . ( $mine ?: '(no key stored)' ) );
|
||||||
|
WP_CLI::log( 'Owner: ' . ( $owner ?: '(gateway did not answer)' ) );
|
||||||
|
|
||||||
|
if ( $owner && $mine && ! hash_equals( $owner, $mine ) ) {
|
||||||
|
WP_CLI::warning( 'The stored key does not own this name. Uploads will be refused.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI::log(
|
||||||
|
sprintf(
|
||||||
|
'Queue: %d pending, %d done, %d failed',
|
||||||
|
$counts[ SPE_Queue::STATUS_PENDING ],
|
||||||
|
$counts[ SPE_Queue::STATUS_DONE ],
|
||||||
|
$counts[ SPE_Queue::STATUS_FAILED ]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WP_CLI::add_command( 'sirius', 'SPE_CLI' );
|
||||||
156
plugins/sirius-press-sia-export/includes/class-spe-hooks.php
Normal file
156
plugins/sirius-press-sia-export/includes/class-spe-hooks.php
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* What makes publishing a post also publish it to the name.
|
||||||
|
*
|
||||||
|
* Everything hangs off `transition_post_status`, which fires for every way a
|
||||||
|
* post can change state — the editor, quick edit, a scheduled publish firing
|
||||||
|
* from cron, the REST API, WP-CLI, an importer. Hooking `save_post` instead
|
||||||
|
* would miss the scheduled case, which is exactly the case a site owner most
|
||||||
|
* expects to work while they are asleep.
|
||||||
|
*
|
||||||
|
* Unpublishing is handled too, and it matters more than it looks: a static
|
||||||
|
* mirror that keeps serving a post the author deleted is worse than no mirror,
|
||||||
|
* because the author believes the page is gone.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPE_Hooks {
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( 'transition_post_status', array( __CLASS__, 'on_transition' ), 10, 3 );
|
||||||
|
add_action( 'before_delete_post', array( __CLASS__, 'on_delete' ), 10, 2 );
|
||||||
|
add_action( 'edited_term', array( __CLASS__, 'on_term_edited' ), 10, 3 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $new_status
|
||||||
|
* @param string $old_status
|
||||||
|
* @param WP_Post $post
|
||||||
|
*/
|
||||||
|
public static function on_transition( $new_status, $old_status, $post ) {
|
||||||
|
if ( ! self::should_export( $post ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Went from public to not — take the file down before refreshing the
|
||||||
|
// pages that used to link to it.
|
||||||
|
if ( 'publish' === $old_status && 'publish' !== $new_status ) {
|
||||||
|
$path = SPE_Mapper::path_for_post( $post );
|
||||||
|
if ( '' !== $path ) {
|
||||||
|
self::unpublish( $path );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
|
||||||
|
return; // A draft being edited changes nothing anyone can see.
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( SPE_Mapper::affected_by_post( $post ) as $path => $target ) {
|
||||||
|
SPE_Queue::add( $path, $target['url'], $target['kind'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_delete( $post_id, $post = null ) {
|
||||||
|
$post = $post ? $post : get_post( $post_id );
|
||||||
|
if ( ! $post || ! self::should_export( $post ) || 'publish' !== $post->post_status ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$path = SPE_Mapper::path_for_post( $post );
|
||||||
|
if ( '' !== $path ) {
|
||||||
|
self::unpublish( $path );
|
||||||
|
}
|
||||||
|
foreach ( SPE_Mapper::affected_by_post( $post ) as $affected => $target ) {
|
||||||
|
if ( $affected !== $path ) {
|
||||||
|
SPE_Queue::add( $affected, $target['url'], $target['kind'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renaming a category changes its archive URL and every page linking to it. */
|
||||||
|
public static function on_term_edited( $term_id, $tt_id, $taxonomy ) {
|
||||||
|
if ( ! SP_Settings::auto_publish() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tax = get_taxonomy( $taxonomy );
|
||||||
|
if ( ! $tax || ! $tax->public ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$link = get_term_link( (int) $term_id, $taxonomy );
|
||||||
|
if ( is_wp_error( $link ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$path = SPE_Mapper::path_for_url_path( (string) wp_parse_url( $link, PHP_URL_PATH ) );
|
||||||
|
if ( '' !== $path ) {
|
||||||
|
SPE_Queue::add( $path, $link, 'archive' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function should_export( $post ) {
|
||||||
|
if ( ! SP_Settings::auto_publish() || ! SP_Settings::is_configured() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( ! $post instanceof WP_Post ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$type = get_post_type_object( $post->post_type );
|
||||||
|
if ( ! $type || ! $type->public ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A password-protected post renders as a password form to the
|
||||||
|
// exporter, and publishing that form to permanent public storage
|
||||||
|
// would be worse than not publishing at all.
|
||||||
|
if ( '' !== $post->post_password ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Filters whether a post is exported at all.
|
||||||
|
*
|
||||||
|
* @param bool $export
|
||||||
|
* @param WP_Post $post
|
||||||
|
*/
|
||||||
|
return (bool) apply_filters( 'sirius_press_should_export_post', true, $post );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a path from the bucket.
|
||||||
|
*
|
||||||
|
* In manual mode there is no key to sign a delete with, so the operator is
|
||||||
|
* told rather than left with a stale page they think is gone.
|
||||||
|
*/
|
||||||
|
private static function unpublish( $path ) {
|
||||||
|
if ( SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||||||
|
SP_Inbox::add(
|
||||||
|
0,
|
||||||
|
__( 'A removed page is still published to your name', 'sirius-press' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: bucket path, 2: export screen URL. */
|
||||||
|
wp_kses_post( __( '<code>%1$s</code> is no longer public on this site, but this server holds no key to delete it from the name\'s storage. <a href="%2$s">Remove it from the export screen</a>.', 'sirius-press' ) ),
|
||||||
|
esc_html( $path ),
|
||||||
|
esc_url( admin_url( 'admin.php?page=sirius-press-export' ) )
|
||||||
|
),
|
||||||
|
'export'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$result = SP_Gateway::delete( $path );
|
||||||
|
if ( empty( $result['ok'] ) ) {
|
||||||
|
SP_Inbox::add(
|
||||||
|
0,
|
||||||
|
__( 'A removed page could not be unpublished', 'sirius-press' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: bucket path, 2: error message. */
|
||||||
|
wp_kses_post( __( '<code>%1$s</code> could not be deleted from the name\'s storage: %2$s', 'sirius-press' ) ),
|
||||||
|
esc_html( $path ),
|
||||||
|
esc_html( $result['error'] )
|
||||||
|
),
|
||||||
|
'export'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
plugins/sirius-press-sia-export/includes/class-spe-mapper.php
Normal file
221
plugins/sirius-press-sia-export/includes/class-spe-mapper.php
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Where each URL lands in the name's bucket.
|
||||||
|
*
|
||||||
|
* WordPress URLs are directories: `/hello-world/`. Object storage has no
|
||||||
|
* directories and no index resolution of its own, so every such URL becomes
|
||||||
|
* `hello-world/index.html` and the resolver's directory-index rescue does the
|
||||||
|
* rest. URLs that already name a file — `/robots.txt`, `/wp-content/…/app.css`
|
||||||
|
* — keep their own name.
|
||||||
|
*
|
||||||
|
* The one rule this file exists to enforce is that the mapping is total and
|
||||||
|
* reversible enough to be safe: anything that cannot be represented as a
|
||||||
|
* gateway-legal path returns '' rather than being coerced into some other
|
||||||
|
* file's name. A path collision in a static export is silent data loss — one
|
||||||
|
* page overwrites another and nobody notices until a reader hits the wrong
|
||||||
|
* article.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPE_Mapper {
|
||||||
|
|
||||||
|
/** Extensions treated as assets rather than pages. */
|
||||||
|
const ASSET_EXTENSIONS = array(
|
||||||
|
'css', 'js', 'mjs', 'json', 'map',
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico',
|
||||||
|
'woff', 'woff2', 'ttf', 'eot', 'otf',
|
||||||
|
'mp3', 'mp4', 'webm', 'ogg', 'wav', 'pdf', 'zip', 'txt', 'xml',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket path for a site-relative URL path.
|
||||||
|
*
|
||||||
|
* @param string $url_path e.g. '/blog/hello/' or '/wp-content/x.css'.
|
||||||
|
* @return string '' when the path cannot be exported.
|
||||||
|
*/
|
||||||
|
public static function path_for_url_path( $url_path ) {
|
||||||
|
$home_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
|
||||||
|
$url_path = '/' . ltrim( (string) $url_path, '/' );
|
||||||
|
|
||||||
|
// A site installed in a subdirectory serves its own root at that
|
||||||
|
// prefix; the export has the name's root, so the prefix comes off.
|
||||||
|
if ( '' !== $home_path && '/' !== $home_path && 0 === strpos( $url_path, $home_path ) ) {
|
||||||
|
$url_path = '/' . ltrim( substr( $url_path, strlen( $home_path ) ), '/' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$url_path = rawurldecode( $url_path );
|
||||||
|
$trimmed = trim( $url_path, '/' );
|
||||||
|
|
||||||
|
if ( '' === $trimmed ) {
|
||||||
|
return 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL naming a file keeps its name; a URL naming a directory gets an
|
||||||
|
// index.html inside it. "Names a file" means the last segment has an
|
||||||
|
// extension AND the URL did not end in a slash — `/about.us/` is a
|
||||||
|
// page called "about.us", not a file.
|
||||||
|
$slash = strrpos( $trimmed, '/' );
|
||||||
|
$last = false === $slash ? $trimmed : substr( $trimmed, $slash + 1 );
|
||||||
|
$path = ( false !== strpos( $last, '.' ) && '/' !== substr( $url_path, -1 ) )
|
||||||
|
? $trimmed
|
||||||
|
: $trimmed . '/index.html';
|
||||||
|
|
||||||
|
// Everything the gateway would reject is better refused here, where
|
||||||
|
// there is still a chance to leave the original URL alone.
|
||||||
|
return SP_Gateway::clean_path( $path );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bucket path for a post, page or any public post type. */
|
||||||
|
public static function path_for_post( $post ) {
|
||||||
|
$post = get_post( $post );
|
||||||
|
if ( ! $post ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$permalink = get_permalink( $post );
|
||||||
|
if ( ! $permalink ) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return self::path_for_url_path( (string) wp_parse_url( $permalink, PHP_URL_PATH ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is_asset( $path ) {
|
||||||
|
$ext = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );
|
||||||
|
return in_array( $ext, self::ASSET_EXTENSIONS, true ) && 'html' !== $ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a full export should cover.
|
||||||
|
*
|
||||||
|
* @return array<string,array{url:string,kind:string}> path => descriptor.
|
||||||
|
*/
|
||||||
|
public static function full_site() {
|
||||||
|
$targets = array();
|
||||||
|
|
||||||
|
$add = function ( $url, $kind = 'page' ) use ( &$targets ) {
|
||||||
|
if ( ! $url ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$path = self::path_for_url_path( (string) wp_parse_url( $url, PHP_URL_PATH ) );
|
||||||
|
if ( '' === $path ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$targets[ $path ] = array(
|
||||||
|
'url' => $url,
|
||||||
|
'kind' => $kind,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$add( home_url( '/' ) );
|
||||||
|
|
||||||
|
// Every published thing with a public permalink.
|
||||||
|
$types = get_post_types( array( 'public' => true ), 'names' );
|
||||||
|
unset( $types['attachment'] );
|
||||||
|
$posts = get_posts(
|
||||||
|
array(
|
||||||
|
'post_type' => array_values( $types ),
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'numberposts' => -1,
|
||||||
|
'fields' => 'ids',
|
||||||
|
'suppress_filters' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
foreach ( $posts as $post_id ) {
|
||||||
|
$add( get_permalink( $post_id ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archives people actually navigate through. Date archives are left
|
||||||
|
// out by default: on a long-running blog they multiply into hundreds
|
||||||
|
// of near-identical pages that nothing links to.
|
||||||
|
foreach ( get_taxonomies( array( 'public' => true ), 'names' ) as $taxonomy ) {
|
||||||
|
$terms = get_terms(
|
||||||
|
array(
|
||||||
|
'taxonomy' => $taxonomy,
|
||||||
|
'hide_empty' => true,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $terms ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ( $terms as $term ) {
|
||||||
|
$link = get_term_link( $term );
|
||||||
|
if ( ! is_wp_error( $link ) ) {
|
||||||
|
$add( $link, 'archive' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'page' === get_option( 'show_on_front' ) && get_option( 'page_for_posts' ) ) {
|
||||||
|
$add( get_permalink( (int) get_option( 'page_for_posts' ) ), 'archive' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the full list of export targets.
|
||||||
|
*
|
||||||
|
* @param array $targets path => {url, kind}.
|
||||||
|
*/
|
||||||
|
return apply_filters( 'sirius_press_export_targets', $targets );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paths that go stale when one post changes.
|
||||||
|
*
|
||||||
|
* A post edit does not only change the post: it changes the home page, the
|
||||||
|
* archives it belongs to, and the posts either side of it if the theme
|
||||||
|
* shows previous/next links. Being generous here costs a few uploads;
|
||||||
|
* being stingy leaves a reader looking at a stale index.
|
||||||
|
*
|
||||||
|
* @return array<string,array{url:string,kind:string}>
|
||||||
|
*/
|
||||||
|
public static function affected_by_post( $post ) {
|
||||||
|
$post = get_post( $post );
|
||||||
|
$targets = array();
|
||||||
|
if ( ! $post ) {
|
||||||
|
return $targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
$add = function ( $url, $kind = 'page' ) use ( &$targets ) {
|
||||||
|
if ( ! $url || is_wp_error( $url ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$path = SPE_Mapper::path_for_url_path( (string) wp_parse_url( $url, PHP_URL_PATH ) );
|
||||||
|
if ( '' !== $path ) {
|
||||||
|
$targets[ $path ] = array(
|
||||||
|
'url' => $url,
|
||||||
|
'kind' => $kind,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if ( 'publish' === $post->post_status ) {
|
||||||
|
$add( get_permalink( $post ) );
|
||||||
|
}
|
||||||
|
$add( home_url( '/' ) );
|
||||||
|
|
||||||
|
if ( 'page' === get_option( 'show_on_front' ) && get_option( 'page_for_posts' ) ) {
|
||||||
|
$add( get_permalink( (int) get_option( 'page_for_posts' ) ), 'archive' );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( get_object_taxonomies( $post->post_type, 'names' ) as $taxonomy ) {
|
||||||
|
$tax = get_taxonomy( $taxonomy );
|
||||||
|
if ( ! $tax || ! $tax->public ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ( (array) get_the_terms( $post, $taxonomy ) as $term ) {
|
||||||
|
if ( $term instanceof WP_Term ) {
|
||||||
|
$add( get_term_link( $term ), 'archive' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters which paths a post change invalidates.
|
||||||
|
*
|
||||||
|
* @param array $targets
|
||||||
|
* @param WP_Post $post
|
||||||
|
*/
|
||||||
|
return apply_filters( 'sirius_press_affected_paths', $targets, $post );
|
||||||
|
}
|
||||||
|
}
|
||||||
217
plugins/sirius-press-sia-export/includes/class-spe-queue.php
Normal file
217
plugins/sirius-press-sia-export/includes/class-spe-queue.php
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The export queue.
|
||||||
|
*
|
||||||
|
* Publishing a post must not wait on a network round trip to a gateway that
|
||||||
|
* might be slow, rate limited or down. If it did, the first flaky minute the
|
||||||
|
* gateway has would show up as WordPress hanging on "Publish", and the site
|
||||||
|
* owner would reasonably conclude the fork is broken.
|
||||||
|
*
|
||||||
|
* So publishing writes a row here and returns. Something else — cron, an
|
||||||
|
* admin screen, WP-CLI, or a browser in manual-signing mode — drains it. The
|
||||||
|
* queue is also what makes retries, bulk exports and manual signing the same
|
||||||
|
* mechanism instead of three.
|
||||||
|
*
|
||||||
|
* Rows are keyed by path, not by post. A post and its archive page and the
|
||||||
|
* home page all move when one post changes, and several posts changing in a
|
||||||
|
* minute should not queue the home page five times.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPE_Queue {
|
||||||
|
|
||||||
|
const TABLE = 'sirius_export_queue';
|
||||||
|
const VERSION = 1;
|
||||||
|
|
||||||
|
const STATUS_PENDING = 'pending';
|
||||||
|
const STATUS_DONE = 'done';
|
||||||
|
const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
/** Give up after this many attempts and leave the row for a human. */
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
public static function table() {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->prefix . self::TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function install() {
|
||||||
|
global $wpdb;
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
$table = self::table();
|
||||||
|
$collate = $wpdb->get_charset_collate();
|
||||||
|
dbDelta(
|
||||||
|
"CREATE TABLE {$table} (
|
||||||
|
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
path varchar(200) NOT NULL,
|
||||||
|
source_url text NOT NULL,
|
||||||
|
kind varchar(20) NOT NULL DEFAULT 'page',
|
||||||
|
status varchar(12) NOT NULL DEFAULT 'pending',
|
||||||
|
attempts smallint(5) unsigned NOT NULL DEFAULT 0,
|
||||||
|
last_error text NULL,
|
||||||
|
content_hash char(64) NOT NULL DEFAULT '',
|
||||||
|
queued_at datetime NOT NULL,
|
||||||
|
done_at datetime NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY path (path),
|
||||||
|
KEY status (status)
|
||||||
|
) {$collate};"
|
||||||
|
);
|
||||||
|
update_option( 'sirius_press_export_db_version', self::VERSION, false );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or refresh a path.
|
||||||
|
*
|
||||||
|
* An existing row is reset to pending rather than duplicated, so a path
|
||||||
|
* that changes ten times before the queue drains is exported once, from
|
||||||
|
* its final state.
|
||||||
|
*
|
||||||
|
* @param string $path Path inside the bucket.
|
||||||
|
* @param string $source_url The URL to render.
|
||||||
|
* @param string $kind 'page', 'asset', 'feed' — for the admin UI only.
|
||||||
|
*/
|
||||||
|
public static function add( $path, $source_url, $kind = 'page' ) {
|
||||||
|
global $wpdb;
|
||||||
|
$path = SP_Gateway::clean_path( $path );
|
||||||
|
if ( '' === $path ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$table = self::table();
|
||||||
|
// A single statement so two concurrent publishes cannot both decide
|
||||||
|
// the row is missing and then collide on the unique index.
|
||||||
|
$sql = $wpdb->prepare(
|
||||||
|
"INSERT INTO {$table} (path, source_url, kind, status, attempts, queued_at)
|
||||||
|
VALUES (%s, %s, %s, %s, 0, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
source_url = VALUES(source_url),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
status = VALUES(status),
|
||||||
|
attempts = 0,
|
||||||
|
last_error = NULL,
|
||||||
|
queued_at = VALUES(queued_at)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$path,
|
||||||
|
(string) $source_url,
|
||||||
|
$kind,
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
current_time( 'mysql', true )
|
||||||
|
);
|
||||||
|
return false !== $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array Rows waiting to go out. */
|
||||||
|
public static function claim( $limit = 10 ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
return (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT * FROM {$table} WHERE status = %s AND attempts < %d ORDER BY id ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
self::MAX_ATTEMPTS,
|
||||||
|
(int) $limit
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mark_done( $id, $hash ) {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->update(
|
||||||
|
self::table(),
|
||||||
|
array(
|
||||||
|
'status' => self::STATUS_DONE,
|
||||||
|
'done_at' => current_time( 'mysql', true ),
|
||||||
|
'content_hash' => $hash,
|
||||||
|
'last_error' => null,
|
||||||
|
),
|
||||||
|
array( 'id' => (int) $id ),
|
||||||
|
array( '%s', '%s', '%s', '%s' ),
|
||||||
|
array( '%d' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mark_failed( $id, $error ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"UPDATE {$table} SET attempts = attempts + 1, last_error = %s,
|
||||||
|
status = CASE WHEN attempts + 1 >= %d THEN %s ELSE %s END
|
||||||
|
WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
mb_substr( (string) $error, 0, 1000 ),
|
||||||
|
self::MAX_ATTEMPTS,
|
||||||
|
self::STATUS_FAILED,
|
||||||
|
self::STATUS_PENDING,
|
||||||
|
(int) $id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put failed rows back in the queue — the "try again" button. */
|
||||||
|
public static function retry_failed() {
|
||||||
|
global $wpdb;
|
||||||
|
return (int) $wpdb->update(
|
||||||
|
self::table(),
|
||||||
|
array(
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'attempts' => 0,
|
||||||
|
),
|
||||||
|
array( 'status' => self::STATUS_FAILED ),
|
||||||
|
array( '%s', '%d' ),
|
||||||
|
array( '%s' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function counts() {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
$rows = (array) $wpdb->get_results( "SELECT status, COUNT(*) AS n FROM {$table} GROUP BY status" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$out = array(
|
||||||
|
self::STATUS_PENDING => 0,
|
||||||
|
self::STATUS_DONE => 0,
|
||||||
|
self::STATUS_FAILED => 0,
|
||||||
|
);
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
$out[ $row->status ] = (int) $row->n;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function recent( $limit = 50, $status = '' ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
if ( '' !== $status ) {
|
||||||
|
return (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY id DESC LIMIT %d", $status, (int) $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (array) $wpdb->get_results(
|
||||||
|
$wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d", (int) $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hash of what was last successfully published at a path.
|
||||||
|
*
|
||||||
|
* Used to skip an upload whose bytes have not changed — the common case
|
||||||
|
* when a post edit re-renders the home page identically, and the
|
||||||
|
* difference between a queue that costs bandwidth and one that does not.
|
||||||
|
*/
|
||||||
|
public static function hash_at( $path ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table();
|
||||||
|
// Deliberately not filtered by status: re-queueing a path resets it to
|
||||||
|
// pending but leaves the hash of what is actually sitting in the
|
||||||
|
// bucket, which is the thing worth comparing against.
|
||||||
|
return (string) $wpdb->get_var(
|
||||||
|
$wpdb->prepare( "SELECT content_hash FROM {$table} WHERE path = %s", $path ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clear_done() {
|
||||||
|
global $wpdb;
|
||||||
|
return (int) $wpdb->delete( self::table(), array( 'status' => self::STATUS_DONE ), array( '%s' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
234
plugins/sirius-press-sia-export/includes/class-spe-renderer.php
Normal file
234
plugins/sirius-press-sia-export/includes/class-spe-renderer.php
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Turning a live WordPress page into a file that stands on its own.
|
||||||
|
*
|
||||||
|
* The export is fetched over a loopback HTTP request rather than rendered
|
||||||
|
* in-process. That costs a round trip, and it is worth it: a theme's output
|
||||||
|
* depends on the whole request lifecycle — the main query, `wp_head`,
|
||||||
|
* enqueued assets, late-filtered content, caching plugins. Re-creating that
|
||||||
|
* by calling `get_the_content()` produces something that looks like the page
|
||||||
|
* and is not the page, and the differences only show up in whichever theme
|
||||||
|
* the site owner actually uses.
|
||||||
|
*
|
||||||
|
* URLs are then rewritten to be document-relative. Not absolute, because the
|
||||||
|
* exported copy is served from the name, not from this WordPress host, and
|
||||||
|
* every absolute link would drag readers back to the origin the export exists
|
||||||
|
* to make optional. Not root-relative either, because the same bucket is
|
||||||
|
* browsable under `/bns/<name>/` on the public gateway, where a root-relative
|
||||||
|
* link points outside the site. Document-relative is the only form that works
|
||||||
|
* in both places.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPE_Renderer {
|
||||||
|
|
||||||
|
/** Marks a request as the exporter's own, so the site can render for it. */
|
||||||
|
const FLAG = 'sirius_export';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a URL as an anonymous visitor would see it.
|
||||||
|
*
|
||||||
|
* @param string $url
|
||||||
|
* @return array{ok:bool,body:string,mime:string,error:string}
|
||||||
|
*/
|
||||||
|
public static function fetch( $url ) {
|
||||||
|
$request_url = add_query_arg( self::FLAG, self::token(), $url );
|
||||||
|
|
||||||
|
$response = wp_remote_get(
|
||||||
|
$request_url,
|
||||||
|
array(
|
||||||
|
'timeout' => 60,
|
||||||
|
'redirection' => 3,
|
||||||
|
// No cookies: the export must be what a logged-out reader
|
||||||
|
// sees, not what the admin who triggered it sees.
|
||||||
|
'cookies' => array(),
|
||||||
|
'sslverify' => apply_filters( 'sirius_press_export_sslverify', true ),
|
||||||
|
'headers' => array(
|
||||||
|
'user-agent' => 'SiriusPress/' . SIRIUS_PRESS_EXPORT_VERSION . ' (static export)',
|
||||||
|
'accept-encoding' => 'identity',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
return self::error( $response->get_error_message() );
|
||||||
|
}
|
||||||
|
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||||
|
if ( 200 !== $code ) {
|
||||||
|
return self::error( sprintf( 'the site returned HTTP %d for %s', $code, $url ) );
|
||||||
|
}
|
||||||
|
$body = wp_remote_retrieve_body( $response );
|
||||||
|
if ( '' === $body ) {
|
||||||
|
return self::error( sprintf( '%s rendered an empty page', $url ) );
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'ok' => true,
|
||||||
|
'body' => $body,
|
||||||
|
'mime' => (string) wp_remote_retrieve_header( $response, 'content-type' ),
|
||||||
|
'error' => '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A token proving a loopback request came from this site.
|
||||||
|
*
|
||||||
|
* Not a security boundary — the pages being fetched are public. It exists
|
||||||
|
* so the site can recognise its own exporter and suppress things that
|
||||||
|
* make no sense in a static copy (admin bar, nonce-bearing markup) without
|
||||||
|
* giving a stranger a way to request that same altered output.
|
||||||
|
*/
|
||||||
|
public static function token() {
|
||||||
|
return substr( hash_hmac( 'sha256', 'sirius-export', wp_salt( 'nonce' ) ), 0, 24 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the current request is the exporter fetching a page. */
|
||||||
|
public static function is_export_request() {
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||||
|
return isset( $_GET[ self::FLAG ] ) && hash_equals( self::token(), sanitize_text_field( wp_unslash( $_GET[ self::FLAG ] ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite a page's URLs for life outside this host.
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @param string $doc_path Where this document will live, e.g. 'blog/hi/index.html'.
|
||||||
|
* @return array{html:string,assets:array<string,string>} Rewritten HTML and the
|
||||||
|
* local assets it referenced, as path => absolute source URL.
|
||||||
|
*/
|
||||||
|
public static function rewrite( $html, $doc_path ) {
|
||||||
|
$home = untrailingslashit( home_url() );
|
||||||
|
$site = untrailingslashit( site_url() );
|
||||||
|
$assets = array();
|
||||||
|
|
||||||
|
// The exporter's own marker must never survive into the output, or
|
||||||
|
// every internal link in the static copy carries it.
|
||||||
|
$html = preg_replace( '/([?&])' . preg_quote( self::FLAG, '/' ) . '=[^"\'&\s]*(&|&)?/', '$1', $html );
|
||||||
|
$html = str_replace( array( '?"', "?'" ), array( '"', "'" ), $html );
|
||||||
|
|
||||||
|
$bases = array_unique( array( $home, $site, set_url_scheme( $home, 'http' ), set_url_scheme( $home, 'https' ) ) );
|
||||||
|
|
||||||
|
// Protocol-relative references to our own host (`//example.test/…`)
|
||||||
|
// are normalised to absolute first, so the pass below only has one
|
||||||
|
// shape to match.
|
||||||
|
//
|
||||||
|
// The lookbehind is load-bearing: without it this also matches the
|
||||||
|
// `//example.test/` sitting inside every `https://example.test/`,
|
||||||
|
// and every absolute URL on the page gains a second scheme.
|
||||||
|
foreach ( array_unique( array_filter( array(
|
||||||
|
(string) wp_parse_url( $home, PHP_URL_HOST ),
|
||||||
|
(string) wp_parse_url( $site, PHP_URL_HOST ),
|
||||||
|
) ) ) as $host ) {
|
||||||
|
$html = preg_replace(
|
||||||
|
'#(?<!:)//' . preg_quote( $host, '#' ) . '#',
|
||||||
|
$home,
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = '#(?:' . implode( '|', array_map( function ( $b ) {
|
||||||
|
return preg_quote( $b, '#' );
|
||||||
|
}, $bases ) ) . ')(/[^"\'\s<>\\\\)]*)?#';
|
||||||
|
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
$pattern,
|
||||||
|
function ( $matches ) use ( $doc_path, &$assets ) {
|
||||||
|
$path = isset( $matches[1] ) ? $matches[1] : '/';
|
||||||
|
return self::localise( $path, $doc_path, $assets );
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'html' => $html,
|
||||||
|
'assets' => $assets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One absolute URL becomes one relative one, and any asset it names gets
|
||||||
|
* remembered so the caller can queue it.
|
||||||
|
*/
|
||||||
|
private static function localise( $path, $doc_path, &$assets ) {
|
||||||
|
$parts = wp_parse_url( $path );
|
||||||
|
$clean = isset( $parts['path'] ) ? $parts['path'] : '/';
|
||||||
|
$suffix = ( isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : '' )
|
||||||
|
. ( isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '' );
|
||||||
|
$target = SPE_Mapper::path_for_url_path( $clean );
|
||||||
|
|
||||||
|
if ( '' === $target ) {
|
||||||
|
// Nothing sensible to point at — an upload directory listing, a
|
||||||
|
// path with characters the bucket will not take. Leave the URL
|
||||||
|
// absolute rather than producing a link to nowhere.
|
||||||
|
return untrailingslashit( home_url() ) . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( SPE_Mapper::is_asset( $target ) ) {
|
||||||
|
$assets[ $target ] = home_url( $clean );
|
||||||
|
// Assets are versioned with ?ver=; the bucket stores one copy, so
|
||||||
|
// the query string would only ever produce a 404.
|
||||||
|
$suffix = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::relative( $doc_path, $target ) . $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path of `$target` as seen from the document at `$from`.
|
||||||
|
*
|
||||||
|
* `blog/post/index.html` referring to `assets/app.css` yields
|
||||||
|
* `../../assets/app.css`.
|
||||||
|
*/
|
||||||
|
public static function relative( $from, $target ) {
|
||||||
|
$from_dir = explode( '/', $from );
|
||||||
|
array_pop( $from_dir ); // Drop the file name.
|
||||||
|
$to = explode( '/', $target );
|
||||||
|
|
||||||
|
while ( $from_dir && $to && $from_dir[0] === $to[0] ) {
|
||||||
|
array_shift( $from_dir );
|
||||||
|
array_shift( $to );
|
||||||
|
}
|
||||||
|
$up = str_repeat( '../', count( $from_dir ) );
|
||||||
|
$rel = $up . implode( '/', $to );
|
||||||
|
return '' === $rel ? './' : $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function error( $message ) {
|
||||||
|
return array(
|
||||||
|
'ok' => false,
|
||||||
|
'body' => '',
|
||||||
|
'mime' => '',
|
||||||
|
'error' => $message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the parts of a page that only make sense on the live site.
|
||||||
|
*
|
||||||
|
* Runs on the *live* request when the exporter is the one asking, which
|
||||||
|
* is why it is here rather than in the rewriting pass: removing the admin
|
||||||
|
* bar after the fact means also unpicking the styles and spacing it added.
|
||||||
|
*/
|
||||||
|
public static function hooks() {
|
||||||
|
add_action(
|
||||||
|
'init',
|
||||||
|
function () {
|
||||||
|
if ( ! self::is_export_request() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show_admin_bar( false );
|
||||||
|
/**
|
||||||
|
* Fires on a page being rendered for static export.
|
||||||
|
*
|
||||||
|
* The place for a theme or plugin to drop anything that cannot
|
||||||
|
* work without PHP behind it — a live search box, a comment
|
||||||
|
* form, a cart widget.
|
||||||
|
*/
|
||||||
|
do_action( 'sirius_press_rendering_export' );
|
||||||
|
},
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
plugins/sirius-press-sia-export/includes/class-spe-runner.php
Normal file
209
plugins/sirius-press-sia-export/includes/class-spe-runner.php
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Draining the queue.
|
||||||
|
*
|
||||||
|
* One row at a time: fetch the source URL, rewrite its links, compare the
|
||||||
|
* result against what is already in the bucket, and upload only if it
|
||||||
|
* differs. The hash comparison is not an optimisation detail — a WordPress
|
||||||
|
* page re-renders byte-identically far more often than it changes, and
|
||||||
|
* without it every post edit would push the home page, every archive and
|
||||||
|
* every asset they reference back over the wire.
|
||||||
|
*
|
||||||
|
* Batches are small and bounded by wall-clock time rather than count, because
|
||||||
|
* the thing that kills a cron-driven exporter is a run that takes longer than
|
||||||
|
* PHP's `max_execution_time` and dies halfway, leaving rows claimed and
|
||||||
|
* nothing to show. Finishing early and picking up next time is always
|
||||||
|
* correct here; the queue is the state.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
final class SPE_Runner {
|
||||||
|
|
||||||
|
const CRON_HOOK = 'sirius_press_export_tick';
|
||||||
|
|
||||||
|
/** Stop a batch after this long, whatever is left in the queue. */
|
||||||
|
const TIME_BUDGET = 20;
|
||||||
|
|
||||||
|
public static function hooks() {
|
||||||
|
add_action( self::CRON_HOOK, array( __CLASS__, 'run_batch' ) );
|
||||||
|
add_filter( 'cron_schedules', array( __CLASS__, 'add_schedule' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add_schedule( $schedules ) {
|
||||||
|
$schedules['sirius_minute'] = array(
|
||||||
|
'interval' => 60,
|
||||||
|
'display' => __( 'Every minute (Sirius Press export)', 'sirius-press' ),
|
||||||
|
);
|
||||||
|
return $schedules;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function schedule() {
|
||||||
|
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
|
||||||
|
wp_schedule_event( time() + 60, 'sirius_minute', self::CRON_HOOK );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unschedule() {
|
||||||
|
$timestamp = wp_next_scheduled( self::CRON_HOOK );
|
||||||
|
if ( $timestamp ) {
|
||||||
|
wp_unschedule_event( $timestamp, self::CRON_HOOK );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a batch.
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum rows to attempt.
|
||||||
|
* @return array{done:int,failed:int,skipped:int,left:int}
|
||||||
|
*/
|
||||||
|
public static function run_batch( $limit = 10 ) {
|
||||||
|
$result = array(
|
||||||
|
'done' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'left' => 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( SP_Settings::MODE_SERVER !== SP_Settings::mode() ) {
|
||||||
|
// Manual mode: the server has no key, so there is nothing for cron
|
||||||
|
// to do. Rows wait for a browser to sign them.
|
||||||
|
$result['left'] = SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ];
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
if ( ! SP_Settings::is_configured() ) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$deadline = microtime( true ) + self::TIME_BUDGET;
|
||||||
|
foreach ( SPE_Queue::claim( $limit ) as $row ) {
|
||||||
|
if ( microtime( true ) > $deadline ) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$outcome = self::process( $row );
|
||||||
|
if ( 'done' === $outcome ) {
|
||||||
|
$result['done']++;
|
||||||
|
} elseif ( 'skipped' === $outcome ) {
|
||||||
|
$result['skipped']++;
|
||||||
|
} else {
|
||||||
|
$result['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['left'] = SPE_Queue::counts()[ SPE_Queue::STATUS_PENDING ];
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row.
|
||||||
|
*
|
||||||
|
* @return string 'done', 'skipped' or 'failed'.
|
||||||
|
*/
|
||||||
|
public static function process( $row ) {
|
||||||
|
$body = self::build( $row );
|
||||||
|
if ( is_wp_error( $body ) ) {
|
||||||
|
SPE_Queue::mark_failed( $row->id, $body->get_error_message() );
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = hash( 'sha256', $body );
|
||||||
|
if ( '' !== $row->content_hash && hash_equals( $row->content_hash, $hash ) ) {
|
||||||
|
// Byte-identical to what is already published.
|
||||||
|
SPE_Queue::mark_done( $row->id, $hash );
|
||||||
|
return 'skipped';
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload = SP_Gateway::put( $row->path, $body );
|
||||||
|
if ( empty( $upload['ok'] ) ) {
|
||||||
|
SPE_Queue::mark_failed( $row->id, $upload['error'] );
|
||||||
|
self::report_failure( $row, $upload['error'] );
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
SPE_Queue::mark_done( $row->id, $hash );
|
||||||
|
/**
|
||||||
|
* Fires after a path is published to the name's bucket.
|
||||||
|
*
|
||||||
|
* @param string $path
|
||||||
|
* @param int $bytes
|
||||||
|
*/
|
||||||
|
do_action( 'sirius_press_exported', $row->path, strlen( $body ) );
|
||||||
|
return 'done';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce the bytes for a row.
|
||||||
|
*
|
||||||
|
* Assets are fetched and passed through untouched. Pages are fetched,
|
||||||
|
* rewritten, and the assets they mention are queued behind them — which
|
||||||
|
* is how a bulk export discovers the theme's stylesheet without anybody
|
||||||
|
* having to list it.
|
||||||
|
*
|
||||||
|
* @return string|WP_Error
|
||||||
|
*/
|
||||||
|
public static function build( $row ) {
|
||||||
|
$fetched = SPE_Renderer::fetch( $row->source_url );
|
||||||
|
if ( empty( $fetched['ok'] ) ) {
|
||||||
|
return new WP_Error( 'sirius_export_fetch', $fetched['error'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'asset' === $row->kind || SPE_Mapper::is_asset( $row->path ) ) {
|
||||||
|
return $fetched['body'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rewritten = SPE_Renderer::rewrite( $fetched['body'], $row->path );
|
||||||
|
foreach ( $rewritten['assets'] as $path => $url ) {
|
||||||
|
// Only queue an asset the first time it is seen; re-queueing it on
|
||||||
|
// every page that links the stylesheet would reset its row to
|
||||||
|
// pending forever and the queue would never empty.
|
||||||
|
if ( '' === SPE_Queue::hash_at( $path ) ) {
|
||||||
|
SPE_Queue::add( $path, $url, 'asset' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rewritten['html'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell somebody when an export stops working.
|
||||||
|
*
|
||||||
|
* Only on the attempt that exhausts the retries, and only once per hour:
|
||||||
|
* a misconfigured key fails on every row in the queue, and an inbox with
|
||||||
|
* four hundred copies of the same message is indistinguishable from no
|
||||||
|
* message at all.
|
||||||
|
*/
|
||||||
|
private static function report_failure( $row, $error ) {
|
||||||
|
if ( (int) $row->attempts + 1 < SPE_Queue::MAX_ATTEMPTS ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( get_transient( 'sirius_export_failure_notified' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_transient( 'sirius_export_failure_notified', 1, HOUR_IN_SECONDS );
|
||||||
|
|
||||||
|
SP_Inbox::add(
|
||||||
|
0,
|
||||||
|
__( 'A page could not be published to your name', 'sirius-press' ),
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: bucket path, 2: error message, 3: URL of the export screen. */
|
||||||
|
wp_kses_post( __( '<code>%1$s</code> failed to upload: %2$s<br><br>Other pages may be failing for the same reason. <a href="%3$s">Open the export screen</a> to see the queue.', 'sirius-press' ) ),
|
||||||
|
esc_html( $row->path ),
|
||||||
|
esc_html( $error ),
|
||||||
|
esc_url( admin_url( 'admin.php?page=sirius-press-export' ) )
|
||||||
|
),
|
||||||
|
'export'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue every exportable page on the site. */
|
||||||
|
public static function queue_full_site() {
|
||||||
|
$queued = 0;
|
||||||
|
foreach ( SPE_Mapper::full_site() as $path => $target ) {
|
||||||
|
if ( SPE_Queue::add( $path, $target['url'], $target['kind'] ) ) {
|
||||||
|
$queued++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $queued;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
plugins/sirius-press-sia-export/sirius-press-sia-export.php
Normal file
73
plugins/sirius-press-sia-export/sirius-press-sia-export.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Sirius Press Publishing
|
||||||
|
* Plugin URI: https://code.silentmode.st/silentmode/sirius-press
|
||||||
|
* Description: Publishes a static copy of every page to the site's BCNR name, so the site keeps serving from decentralised storage even when this server does not.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Requires at least: 6.5
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Requires Plugins: sirius-press-core
|
||||||
|
* Author: Silent Mode
|
||||||
|
* Author URI: https://silentmode.st
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
* Text Domain: sirius-press
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'SIRIUS_PRESS_EXPORT_VERSION', '0.1.0' );
|
||||||
|
define( 'SIRIUS_PRESS_EXPORT_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
define( 'SIRIUS_PRESS_EXPORT_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
||||||
|
register_activation_hook(
|
||||||
|
__FILE__,
|
||||||
|
function () {
|
||||||
|
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-queue.php';
|
||||||
|
SPE_Queue::install();
|
||||||
|
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-runner.php';
|
||||||
|
SPE_Runner::schedule();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
register_deactivation_hook(
|
||||||
|
__FILE__,
|
||||||
|
function () {
|
||||||
|
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-runner.php';
|
||||||
|
SPE_Runner::unschedule();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'plugins_loaded',
|
||||||
|
function () {
|
||||||
|
if ( ! class_exists( 'SP_Gateway' ) ) {
|
||||||
|
add_action(
|
||||||
|
'admin_notices',
|
||||||
|
function () {
|
||||||
|
printf(
|
||||||
|
'<div class="notice notice-error"><p>%s</p></div>',
|
||||||
|
esc_html__( 'Sirius Press Publishing needs Sirius Press Core to be active. Nothing is being published.', 'sirius-press' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( array( 'queue', 'mapper', 'renderer', 'runner', 'hooks', 'admin', 'cli' ) as $part ) {
|
||||||
|
require_once SIRIUS_PRESS_EXPORT_DIR . 'includes/class-spe-' . $part . '.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( (int) get_option( 'sirius_press_export_db_version', 0 ) < SPE_Queue::VERSION ) {
|
||||||
|
SPE_Queue::install();
|
||||||
|
}
|
||||||
|
|
||||||
|
SPE_Renderer::hooks();
|
||||||
|
SPE_Runner::hooks();
|
||||||
|
SPE_Hooks::hooks();
|
||||||
|
SPE_Admin::hooks();
|
||||||
|
SPE_Runner::schedule();
|
||||||
|
}
|
||||||
|
);
|
||||||
157
tests/bootstrap.php
Normal file
157
tests/bootstrap.php
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Enough of WordPress to test the parts that do not need it.
|
||||||
|
*
|
||||||
|
* The classes worth testing hardest — the curve, address encoding, the signing
|
||||||
|
* envelopes, URL-to-path mapping — are the ones with no WordPress in them.
|
||||||
|
* Standing up a full WordPress test install to exercise them would make the
|
||||||
|
* suite slow enough that nobody runs it, which is the failure mode that
|
||||||
|
* matters most for cryptographic code.
|
||||||
|
*
|
||||||
|
* So this file supplies the handful of core functions those classes touch, and
|
||||||
|
* the suite runs under plain PHP in well under a second. Anything that needs
|
||||||
|
* real WordPress — the auth flow, the queue, the admin screens — is tested by
|
||||||
|
* installing the fork, which docs/testing.md describes.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
define( 'ABSPATH', __DIR__ . '/' );
|
||||||
|
define( 'SP_CLI', true );
|
||||||
|
|
||||||
|
// --- a tiny test runner ------------------------------------------------------
|
||||||
|
|
||||||
|
final class T {
|
||||||
|
public static $passed = 0;
|
||||||
|
public static $failed = 0;
|
||||||
|
public static $group = '';
|
||||||
|
|
||||||
|
public static function group( $name ) {
|
||||||
|
self::$group = $name;
|
||||||
|
echo "\n {$name}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ok( $condition, $what ) {
|
||||||
|
if ( $condition ) {
|
||||||
|
self::$passed++;
|
||||||
|
echo " ok {$what}\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self::$failed++;
|
||||||
|
echo " FAIL {$what}\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function is( $actual, $expected, $what ) {
|
||||||
|
if ( $actual === $expected ) {
|
||||||
|
self::$passed++;
|
||||||
|
echo " ok {$what}\n";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self::$failed++;
|
||||||
|
echo " FAIL {$what}\n";
|
||||||
|
echo " expected: " . self::show( $expected ) . "\n";
|
||||||
|
echo " actual: " . self::show( $actual ) . "\n";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function show( $value ) {
|
||||||
|
if ( is_bool( $value ) ) {
|
||||||
|
return $value ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
if ( null === $value ) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
if ( is_array( $value ) ) {
|
||||||
|
return json_encode( $value );
|
||||||
|
}
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function summary() {
|
||||||
|
$total = self::$passed + self::$failed;
|
||||||
|
echo "\n";
|
||||||
|
if ( self::$failed > 0 ) {
|
||||||
|
echo " {$total} checks, " . self::$failed . " FAILED\n\n";
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
echo " {$total} checks, all passed\n\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WordPress functions the tested classes call -----------------------------
|
||||||
|
|
||||||
|
if ( ! function_exists( 'wp_parse_url' ) ) {
|
||||||
|
function wp_parse_url( $url, $component = -1 ) {
|
||||||
|
return parse_url( $url, $component );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'home_url' ) ) {
|
||||||
|
function home_url( $path = '' ) {
|
||||||
|
return 'https://example.test' . ( '' !== $path ? $path : '' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'site_url' ) ) {
|
||||||
|
function site_url( $path = '' ) {
|
||||||
|
return home_url( $path );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'set_url_scheme' ) ) {
|
||||||
|
function set_url_scheme( $url, $scheme = null ) {
|
||||||
|
return preg_replace( '#^\w+://#', ( $scheme ? $scheme : 'https' ) . '://', (string) $url );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'untrailingslashit' ) ) {
|
||||||
|
function untrailingslashit( $value ) {
|
||||||
|
return rtrim( (string) $value, '/\\' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'apply_filters' ) ) {
|
||||||
|
function apply_filters( $hook, $value ) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'size_format' ) ) {
|
||||||
|
function size_format( $bytes ) {
|
||||||
|
return $bytes . ' bytes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'esc_html' ) ) {
|
||||||
|
function esc_html( $text ) {
|
||||||
|
return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( '__' ) ) {
|
||||||
|
function __( $text, $domain = '' ) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the code under test -----------------------------------------------------
|
||||||
|
|
||||||
|
$core = __DIR__ . '/../plugins/sirius-press-core/includes/';
|
||||||
|
require_once $core . 'class-sp-bn.php';
|
||||||
|
require_once $core . 'class-sp-secp256k1.php';
|
||||||
|
require_once $core . 'class-sp-cashaddr.php';
|
||||||
|
require_once $core . 'class-sp-hd.php';
|
||||||
|
require_once $core . 'class-sp-message.php';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These three reference WordPress functions only from inside their methods, so
|
||||||
|
* they load cleanly here and the suite exercises the code that actually ships
|
||||||
|
* rather than a copy of it.
|
||||||
|
*/
|
||||||
|
require_once $core . 'class-sp-gateway.php';
|
||||||
|
|
||||||
|
$export = __DIR__ . '/../plugins/sirius-press-sia-export/includes/';
|
||||||
|
require_once $export . 'class-spe-mapper.php';
|
||||||
|
require_once $export . 'class-spe-renderer.php';
|
||||||
174
tests/interop.mjs
Normal file
174
tests/interop.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// The browser wallet against the PHP one.
|
||||||
|
//
|
||||||
|
// Sirius Press has two independent implementations of the same cryptography:
|
||||||
|
// PHP on the server, which verifies, and JavaScript in the page, which signs.
|
||||||
|
// If they disagree by one byte, nobody can log in — and the error will look
|
||||||
|
// like a rejected password rather than a hash mismatch, which is a miserable
|
||||||
|
// thing to debug at three in the morning.
|
||||||
|
//
|
||||||
|
// So this compares them directly. Every vector is the output of the other
|
||||||
|
// implementation, not of this one.
|
||||||
|
//
|
||||||
|
// node tests/interop.mjs
|
||||||
|
//
|
||||||
|
// Needs no dependencies: the wallet is loaded as plain source, with `window`
|
||||||
|
// pointed at the Node global so WebCrypto is found where the browser puts it.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const assets = join(here, "..", "plugins", "sirius-press-auth", "assets");
|
||||||
|
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
new Function(readFileSync(join(assets, "bip39-en.js"), "utf8"))();
|
||||||
|
new Function(readFileSync(join(assets, "wallet.js"), "utf8"))();
|
||||||
|
|
||||||
|
const W = window.SiriusWallet;
|
||||||
|
const I = W._internals;
|
||||||
|
const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function is(actual, expected, what) {
|
||||||
|
if (actual === expected) {
|
||||||
|
passed++;
|
||||||
|
console.log(` ok ${what}`);
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
|
console.log(` FAIL ${what}`);
|
||||||
|
console.log(` expected: ${expected}`);
|
||||||
|
console.log(` actual: ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(condition, what) {
|
||||||
|
is(Boolean(condition), true, what);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RIPEMD-160, against the published vectors -------------------------------
|
||||||
|
// WebCrypto has no RIPEMD-160, so the wallet carries its own. These are the
|
||||||
|
// reference values from the algorithm's own specification.
|
||||||
|
|
||||||
|
console.log("\n RIPEMD-160");
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
is(hex(I.ripemd160(new Uint8Array(0))), "9c1185a5c5e9fc54612808977ee8f548b2258d31", 'the empty string');
|
||||||
|
is(hex(I.ripemd160(enc.encode("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", '"abc"');
|
||||||
|
is(
|
||||||
|
hex(I.ripemd160(enc.encode("message digest"))),
|
||||||
|
"5d0689ef49d2fae572b881b123a85ffa21595f36",
|
||||||
|
'"message digest"',
|
||||||
|
);
|
||||||
|
is(
|
||||||
|
hex(I.ripemd160(enc.encode("abcdefghijklmnopqrstuvwxyz"))),
|
||||||
|
"f71c27109c692c1b56bbdceb5b9d2865b3708dbc",
|
||||||
|
"the lowercase alphabet",
|
||||||
|
);
|
||||||
|
// Longer than one 64-byte block, which exercises the padding and the length
|
||||||
|
// field — the parts a single-block test never reaches.
|
||||||
|
is(
|
||||||
|
hex(I.ripemd160(enc.encode("1234567890".repeat(8)))),
|
||||||
|
"9b752e45573d4b39f4dbd3323cab82bf63326bfb",
|
||||||
|
"eighty bytes, spanning two blocks",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- secp256k1 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
console.log("\n secp256k1");
|
||||||
|
const privOne = Uint8Array.from([...new Array(31).fill(0), 1]);
|
||||||
|
is(
|
||||||
|
hex(await I.publicKey(privOne)),
|
||||||
|
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||||
|
"the public key for private key 1",
|
||||||
|
);
|
||||||
|
is(
|
||||||
|
await W.addressFromPublicKey(await I.publicKey(privOne), "bitcoincash"),
|
||||||
|
"bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
|
||||||
|
"the mainnet address for private key 1",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- derivation, against libauth ---------------------------------------------
|
||||||
|
// This phrase, and the address below it, came out of BuiltInWallet in
|
||||||
|
// Argus/src/lib/wallet-web.js — the wallet the Sirius portal runs.
|
||||||
|
|
||||||
|
console.log("\n BIP-39 and BIP-32");
|
||||||
|
const phrase = "trash key flip dawn impulse float medal rain sell hand neither hub";
|
||||||
|
const wallet = await W.fromPhrase(phrase, { prefix: "bchtest" });
|
||||||
|
is(wallet.address, "bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy", "derives the portal wallet's address");
|
||||||
|
|
||||||
|
const messy = await W.fromPhrase(" Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", {
|
||||||
|
prefix: "bchtest",
|
||||||
|
});
|
||||||
|
is(messy.address, wallet.address, "spacing and capitals do not change the wallet");
|
||||||
|
|
||||||
|
// --- signing, against PHP -----------------------------------------------------
|
||||||
|
// The signature below was produced by SP_Message::sign() in PHP. Both sides run
|
||||||
|
// RFC 6979, so the same key and message give the same bytes — an equality this
|
||||||
|
// strict is only possible because neither implementation uses randomness.
|
||||||
|
|
||||||
|
console.log("\n signatures match PHP");
|
||||||
|
const loginMessage = [
|
||||||
|
"SIRIUS-PRESS-LOGIN1",
|
||||||
|
"https://example.bch",
|
||||||
|
wallet.address,
|
||||||
|
"abc123",
|
||||||
|
"1758412800000",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
is(
|
||||||
|
hex(await I.messageDigest(loginMessage)),
|
||||||
|
"98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19",
|
||||||
|
"the BIP-137 digest matches PHP and the Theseus wallet",
|
||||||
|
);
|
||||||
|
is(
|
||||||
|
await wallet.sign(loginMessage),
|
||||||
|
"IGr9FSoDInLUr+iKZGw8w5LPyi/JsZAU6mFvGc80uQReP3IQZU7KMODzwS78FkRE57sh2osmWq1sVHZam81PTkY=",
|
||||||
|
"the signature is byte-identical to the one PHP produces",
|
||||||
|
);
|
||||||
|
is(await wallet.sign(loginMessage), await wallet.sign(loginMessage), "signing is deterministic");
|
||||||
|
|
||||||
|
// --- the upload envelope ------------------------------------------------------
|
||||||
|
// Manual-mode publishing signs a raw digest rather than a text message. The
|
||||||
|
// expected value is SP_Message::site_digest() from PHP.
|
||||||
|
|
||||||
|
console.log("\n BNS-SITE1 upload envelope");
|
||||||
|
const body = "<h1>hi</h1>";
|
||||||
|
const bodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(body))));
|
||||||
|
const envelope = `BNS-SITE1\nexample.bch\nindex.html\n${bodyHash}\n1758412800000`;
|
||||||
|
const siteDigest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(envelope)));
|
||||||
|
is(
|
||||||
|
hex(siteDigest),
|
||||||
|
"a71c4db0bd7268cd2b2103e2c0753ad37b046b203a4bf1ba7e84271b816d10de",
|
||||||
|
"the upload digest matches PHP",
|
||||||
|
);
|
||||||
|
const rawSig = await wallet.signRaw(siteDigest);
|
||||||
|
is(atob(rawSig).length, 65, "signRaw produces 65 bytes");
|
||||||
|
|
||||||
|
// --- phrase generation --------------------------------------------------------
|
||||||
|
|
||||||
|
console.log("\n phrase handling");
|
||||||
|
const generated = await W.generatePhrase(12);
|
||||||
|
is(generated.split(" ").length, 12, "a generated phrase has twelve words");
|
||||||
|
ok(W.validatePhrase(generated).ok, "a generated phrase validates");
|
||||||
|
const generated24 = await W.generatePhrase(24);
|
||||||
|
is(generated24.split(" ").length, 24, "twenty-four words on request");
|
||||||
|
ok(W.validatePhrase(generated24).ok, "the long phrase validates too");
|
||||||
|
ok((await W.fromPhrase(generated, { prefix: "bchtest" })).address.startsWith("bchtest:"), "and opens a wallet");
|
||||||
|
|
||||||
|
ok(!W.validatePhrase("").ok, "an empty phrase is refused");
|
||||||
|
ok(!W.validatePhrase("one two three").ok, "a three-word phrase is refused");
|
||||||
|
is(
|
||||||
|
W.validatePhrase("abandon abandon recieve abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||||
|
.error,
|
||||||
|
"Word 3, “recieve”, is not a recovery-phrase word.",
|
||||||
|
"a misspelled word is named and numbered",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log(` ${passed + failed} checks, ${failed} FAILED\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(` ${passed + failed} checks, all passed\n`);
|
||||||
68
tests/run.sh
Normal file
68
tests/run.sh
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the Sirius Press test suite.
|
||||||
|
#
|
||||||
|
# tests/run.sh
|
||||||
|
#
|
||||||
|
# Needs PHP 7.4+ with GMP or BCMath, and Node 18+ for the interop suite. Both
|
||||||
|
# are optional in the sense that the script says which one is missing and runs
|
||||||
|
# the rest — but the interop suite is the one that proves a signature made in
|
||||||
|
# a browser verifies on the server, so a run without it has not checked the
|
||||||
|
# thing most worth checking.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PHP_BIN="${PHP:-php}"
|
||||||
|
NODE_BIN="${NODE:-node}"
|
||||||
|
status=0
|
||||||
|
|
||||||
|
bar() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
||||||
|
for suite in test-crypto test-identity test-export; do
|
||||||
|
bar "php ${suite}"
|
||||||
|
"$PHP_BIN" "$here/$suite.php" || status=1
|
||||||
|
done
|
||||||
|
else
|
||||||
|
printf '\033[33mskipped:\033[0m no PHP on PATH (set PHP=/path/to/php)\n' >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v "$NODE_BIN" >/dev/null 2>&1; then
|
||||||
|
bar "node interop (browser wallet vs PHP)"
|
||||||
|
"$NODE_BIN" "$here/interop.mjs" || status=1
|
||||||
|
else
|
||||||
|
printf '\033[33mskipped:\033[0m no Node on PATH — the browser wallet was not checked against PHP\n' >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bar "syntax"
|
||||||
|
syntax_ok=1
|
||||||
|
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if ! "$PHP_BIN" -l "$file" >/dev/null 2>&1; then
|
||||||
|
echo " FAIL $file"
|
||||||
|
"$PHP_BIN" -l "$file" 2>&1 | head -3
|
||||||
|
syntax_ok=0
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done < <(find "$here/.." -name '*.php' -not -path '*/dist/*' -not -path '*/node_modules/*')
|
||||||
|
fi
|
||||||
|
if command -v "$NODE_BIN" >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if ! "$NODE_BIN" --check "$file" >/dev/null 2>&1; then
|
||||||
|
echo " FAIL $file"
|
||||||
|
syntax_ok=0
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done < <(find "$here/.." -name '*.js' -not -path '*/dist/*' -not -path '*/node_modules/*')
|
||||||
|
fi
|
||||||
|
[ "$syntax_ok" -eq 1 ] && echo " ok every PHP and JS file parses"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
printf '\033[32mAll suites passed.\033[0m\n\n'
|
||||||
|
else
|
||||||
|
printf '\033[31mSomething failed or was skipped.\033[0m\n\n'
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
138
tests/test-crypto.php
Normal file
138
tests/test-crypto.php
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* The cryptography, against fixed vectors.
|
||||||
|
*
|
||||||
|
* Every number in here was produced by something other than this code —
|
||||||
|
* published secp256k1 and RIPEMD-160 test vectors, and libauth, which is the
|
||||||
|
* library the Sirius portal wallet and the BNS gateway both use. If this file
|
||||||
|
* passes, a signature made in Sirius Press verifies at the gateway and a
|
||||||
|
* signature made in the portal verifies here.
|
||||||
|
*
|
||||||
|
* That cross-check is the point. A self-consistent implementation that signs
|
||||||
|
* and verifies its own output proves nothing: it would pass just as happily
|
||||||
|
* with the wrong curve.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
T::group( 'big numbers' );
|
||||||
|
|
||||||
|
T::ok( SP_BN::available(), 'a bignum backend is available (' . SP_BN::backend() . ')' );
|
||||||
|
T::is( SP_BN::to_hex( SP_BN::from_hex( 'deadbeef' ), 8 ), 'deadbeef', 'hex round-trips' );
|
||||||
|
T::is( SP_BN::to_hex( SP_BN::from_int( 255 ), 2 ), 'ff', 'integers convert' );
|
||||||
|
T::is(
|
||||||
|
SP_BN::to_hex( SP_BN::mod( SP_BN::from_int( -7 ), SP_BN::from_int( 5 ) ), 1 ),
|
||||||
|
'3',
|
||||||
|
'mod of a negative is a non-negative residue'
|
||||||
|
);
|
||||||
|
T::is( SP_BN::bits( SP_BN::from_int( 13 ) ), '1101', 'bit decomposition' );
|
||||||
|
T::ok( SP_BN::is_odd( SP_BN::from_int( 7 ) ), '7 is odd' );
|
||||||
|
T::ok( ! SP_BN::is_odd( SP_BN::from_int( 8 ) ), '8 is not odd' );
|
||||||
|
|
||||||
|
// a^-1 * a == 1 (mod p)
|
||||||
|
$p = SP_BN::from_hex( SP_Secp256k1::P );
|
||||||
|
$a = SP_BN::from_hex( '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' );
|
||||||
|
$prod = SP_BN::mod( SP_BN::mul( $a, SP_BN::inv_mod( $a, $p ) ), $p );
|
||||||
|
T::is( SP_BN::to_hex( $prod, 1 ), '1', 'modular inverse' );
|
||||||
|
|
||||||
|
T::group( 'secp256k1' );
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generator multiples. These are the canonical public keys for private keys
|
||||||
|
* 1, 2 and 3 — any implementation that gets these wrong is not on the curve
|
||||||
|
* everyone else is using.
|
||||||
|
*/
|
||||||
|
$vectors = array(
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000001' => '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000002' => '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5',
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000003' => '02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9',
|
||||||
|
// n-1 gives -G: the same x as G, the other y, so the parity byte flips
|
||||||
|
// from 02 to 03. A good check that compression reads y and not something
|
||||||
|
// that merely correlates with it.
|
||||||
|
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140' => '0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
|
||||||
|
);
|
||||||
|
foreach ( $vectors as $priv => $expected ) {
|
||||||
|
T::is(
|
||||||
|
bin2hex( SP_Secp256k1::public_key( hex2bin( $priv ) ) ),
|
||||||
|
$expected,
|
||||||
|
'public key for ' . substr( $priv, -8 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
T::is( SP_Secp256k1::public_key( str_repeat( "\x00", 32 ) ), '', 'a zero private key is refused' );
|
||||||
|
T::is(
|
||||||
|
SP_Secp256k1::public_key( hex2bin( 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141' ) ),
|
||||||
|
'',
|
||||||
|
'a private key equal to the group order is refused'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign, then recover: the public key must come back out.
|
||||||
|
$priv = hex2bin( str_repeat( '0', 63 ) . '1' );
|
||||||
|
$digest = hash( 'sha256', 'hello sirius', true );
|
||||||
|
$sig = SP_Secp256k1::sign_recoverable( $digest, $priv );
|
||||||
|
|
||||||
|
T::is( strlen( $sig ), 65, 'a signature is 65 bytes' );
|
||||||
|
T::is(
|
||||||
|
bin2hex( $sig ),
|
||||||
|
'1f5ec23ce0b5572febc59f09cb5b93e73e7b8ced06dbffa5cae138faf0cc4d06236093c3e0296d6d76cd811eb29c7e4b6ba30c430313e8e2e18666a3edc277a5c8',
|
||||||
|
'the signature is byte-identical to libauth for the same key and digest'
|
||||||
|
);
|
||||||
|
T::is(
|
||||||
|
bin2hex( SP_Secp256k1::recover( $sig, $digest ) ),
|
||||||
|
bin2hex( SP_Secp256k1::public_key( $priv ) ),
|
||||||
|
'recovery returns the signing key'
|
||||||
|
);
|
||||||
|
T::ok(
|
||||||
|
SP_Secp256k1::recover( $sig, hash( 'sha256', 'a different message', true ) ) !== SP_Secp256k1::public_key( $priv ),
|
||||||
|
'a tampered digest does not recover the signing key'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determinism: RFC 6979 means the same inputs always give the same bytes.
|
||||||
|
T::is(
|
||||||
|
bin2hex( SP_Secp256k1::sign_recoverable( $digest, $priv ) ),
|
||||||
|
bin2hex( $sig ),
|
||||||
|
'signing is deterministic'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Malformed input must be refused rather than half-processed.
|
||||||
|
T::is( SP_Secp256k1::recover( 'short', $digest ), '', 'a short signature is refused' );
|
||||||
|
T::is( SP_Secp256k1::recover( str_repeat( "\x00", 65 ), $digest ), '', 'an all-zero signature is refused' );
|
||||||
|
|
||||||
|
T::group( 'low-S normalisation' );
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Every signature must have S in the lower half of the order. A high-S
|
||||||
|
* signature is still valid arithmetic but is the malleable twin of the low-S
|
||||||
|
* one, and tooling across the ecosystem rejects it.
|
||||||
|
*/
|
||||||
|
$half_n = SP_BN::from_hex( '7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' );
|
||||||
|
$all_low = true;
|
||||||
|
for ( $i = 1; $i <= 8; $i++ ) {
|
||||||
|
$k = hash( 'sha256', "key {$i}", true );
|
||||||
|
$d = hash( 'sha256', "message {$i}", true );
|
||||||
|
$s = SP_BN::from_bin( substr( SP_Secp256k1::sign_recoverable( $d, $k ), 33, 32 ) );
|
||||||
|
if ( SP_BN::cmp( $s, $half_n ) > 0 ) {
|
||||||
|
$all_low = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
T::ok( $all_low, 'S is in the lower half of the order across 8 signatures' );
|
||||||
|
|
||||||
|
T::group( 'many round trips' );
|
||||||
|
|
||||||
|
$round_trips = true;
|
||||||
|
for ( $i = 1; $i <= 12; $i++ ) {
|
||||||
|
$k = hash( 'sha256', "roundtrip key {$i}", true );
|
||||||
|
$d = hash( 'sha256', "roundtrip message {$i}", true );
|
||||||
|
$s = SP_Secp256k1::sign_recoverable( $d, $k );
|
||||||
|
if ( SP_Secp256k1::recover( $s, $d ) !== SP_Secp256k1::public_key( $k ) ) {
|
||||||
|
$round_trips = false;
|
||||||
|
echo " failed at iteration {$i}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Twelve is enough to catch the recovery-id bug that only shows on the
|
||||||
|
// signatures where R.y happens to be odd — which is half of them.
|
||||||
|
T::ok( $round_trips, '12 sign-and-recover round trips, covering both recovery ids' );
|
||||||
|
|
||||||
|
exit( T::summary() );
|
||||||
131
tests/test-export.php
Normal file
131
tests/test-export.php
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* URL-to-path mapping and link rewriting.
|
||||||
|
*
|
||||||
|
* A wrong answer here is silent. Two pages that map to one path means one
|
||||||
|
* quietly overwrites the other, and nobody finds out until a reader opens an
|
||||||
|
* article and gets a different one. A link rewritten wrongly means the static
|
||||||
|
* copy points back at the origin server it exists to make optional.
|
||||||
|
*
|
||||||
|
* So the cases below are mostly the awkward ones: a URL that looks like a file
|
||||||
|
* but is a page, a path the gateway would refuse, a document deep in a tree
|
||||||
|
* linking back up to an asset at the root.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
T::group( 'gateway path rules' );
|
||||||
|
|
||||||
|
T::is( SP_Gateway::clean_path( '/blog/hello/index.html' ), 'blog/hello/index.html', 'a leading slash is dropped' );
|
||||||
|
T::is( SP_Gateway::clean_path( 'blog//hello///index.html' ), 'blog/hello/index.html', 'repeated slashes collapse' );
|
||||||
|
T::is( SP_Gateway::clean_path( 'a/../../etc/passwd' ), '', 'traversal is refused' );
|
||||||
|
T::is( SP_Gateway::clean_path( 'blog/' ), '', 'a trailing slash is refused — the gateway stores objects, not directories' );
|
||||||
|
T::is( SP_Gateway::clean_path( '' ), '', 'an empty path is refused' );
|
||||||
|
T::is( SP_Gateway::clean_path( 'café/index.html' ), '', 'characters the gateway rejects are refused here' );
|
||||||
|
T::is( SP_Gateway::clean_path( str_repeat( 'a', 201 ) ), '', 'an over-long path is refused' );
|
||||||
|
T::is( SP_Gateway::clean_path( 'wp-content/themes/x/style.css' ), 'wp-content/themes/x/style.css', 'an asset path survives' );
|
||||||
|
|
||||||
|
T::is( SP_Gateway::mime_for( 'index.html' ), 'text/html; charset=utf-8', 'html content type' );
|
||||||
|
T::is( SP_Gateway::mime_for( 'app.CSS' ), 'text/css; charset=utf-8', 'extensions are matched case-insensitively' );
|
||||||
|
T::is( SP_Gateway::mime_for( 'thing.unknown' ), 'application/octet-stream', 'an unknown extension falls back' );
|
||||||
|
|
||||||
|
T::group( 'URL to bucket path' );
|
||||||
|
|
||||||
|
$cases = array(
|
||||||
|
'/' => 'index.html',
|
||||||
|
'' => 'index.html',
|
||||||
|
'/hello-world/' => 'hello-world/index.html',
|
||||||
|
'/hello-world' => 'hello-world/index.html',
|
||||||
|
'/blog/2026/09/a-post/' => 'blog/2026/09/a-post/index.html',
|
||||||
|
'/robots.txt' => 'robots.txt',
|
||||||
|
'/wp-content/themes/x/style.css' => 'wp-content/themes/x/style.css',
|
||||||
|
'/sitemap.xml' => 'sitemap.xml',
|
||||||
|
// Ends in a slash, so it is a page whose name happens to contain a dot —
|
||||||
|
// not a file called "about.us".
|
||||||
|
'/about.us/' => 'about.us/index.html',
|
||||||
|
);
|
||||||
|
foreach ( $cases as $url => $expected ) {
|
||||||
|
T::is( SPE_Mapper::path_for_url_path( $url ), $expected, "'{$url}' maps to '{$expected}'" );
|
||||||
|
}
|
||||||
|
|
||||||
|
T::ok( SPE_Mapper::is_asset( 'wp-content/x.css' ), 'a stylesheet is an asset' );
|
||||||
|
T::ok( SPE_Mapper::is_asset( 'a/b/photo.JPG' ), 'an uppercase extension still reads as an asset' );
|
||||||
|
T::ok( ! SPE_Mapper::is_asset( 'blog/post/index.html' ), 'a page is not an asset' );
|
||||||
|
|
||||||
|
// Distinct pages must never land on one path.
|
||||||
|
$paths = array();
|
||||||
|
foreach ( array( '/a/', '/b/', '/a/b/', '/a/b/c/', '/a.html', '/ab/' ) as $url ) {
|
||||||
|
$paths[] = SPE_Mapper::path_for_url_path( $url );
|
||||||
|
}
|
||||||
|
T::is( count( array_unique( $paths ) ), count( $paths ), 'six distinct URLs give six distinct paths' );
|
||||||
|
|
||||||
|
// `/a/` and `/a/index.html` are the same page in WordPress and are SUPPOSED to
|
||||||
|
// share a path — this records that as intended, not as the collision above.
|
||||||
|
T::is(
|
||||||
|
SPE_Mapper::path_for_url_path( '/a/' ),
|
||||||
|
SPE_Mapper::path_for_url_path( '/a/index.html' ),
|
||||||
|
'a directory URL and its explicit index are one page'
|
||||||
|
);
|
||||||
|
|
||||||
|
T::group( 'document-relative links' );
|
||||||
|
|
||||||
|
$relative = array(
|
||||||
|
// from to expected
|
||||||
|
array( 'index.html', 'about/index.html', 'about/index.html' ),
|
||||||
|
array( 'about/index.html', 'index.html', '../index.html' ),
|
||||||
|
array( 'blog/post/index.html', 'wp-content/app.css', '../../wp-content/app.css' ),
|
||||||
|
array( 'blog/post/index.html', 'blog/other/index.html', '../other/index.html' ),
|
||||||
|
array( 'a/b/c/index.html', 'a/b/c/style.css', 'style.css' ),
|
||||||
|
array( 'index.html', 'index.html', 'index.html' ),
|
||||||
|
);
|
||||||
|
foreach ( $relative as $case ) {
|
||||||
|
list( $from, $to, $expected ) = $case;
|
||||||
|
T::is( SPE_Renderer::relative( $from, $to ), $expected, "{$from} -> {$to}" );
|
||||||
|
}
|
||||||
|
|
||||||
|
T::group( 'rewriting a page' );
|
||||||
|
|
||||||
|
$html = '<!doctype html><html><head>'
|
||||||
|
. '<link rel="stylesheet" href="https://example.test/wp-content/themes/x/style.css?ver=1.2">'
|
||||||
|
. '<link rel="canonical" href="https://example.test/blog/hello/">'
|
||||||
|
. '</head><body>'
|
||||||
|
. '<a href="https://example.test/">Home</a>'
|
||||||
|
. '<a href="https://example.test/about/">About</a>'
|
||||||
|
. '<a href="https://elsewhere.example/offsite">Offsite</a>'
|
||||||
|
. '<img src="//example.test/wp-content/uploads/pic.png" alt="">'
|
||||||
|
. '<a href="https://example.test/blog/hello/#notes">Notes</a>'
|
||||||
|
. '</body></html>';
|
||||||
|
|
||||||
|
$result = SPE_Renderer::rewrite( $html, 'blog/hello/index.html' );
|
||||||
|
$out = $result['html'];
|
||||||
|
|
||||||
|
T::ok( false === strpos( $out, 'https://example.test' ), 'no absolute self-links remain' );
|
||||||
|
T::ok( false !== strpos( $out, 'href="../../index.html"' ), 'the home link became document-relative' );
|
||||||
|
T::ok( false !== strpos( $out, 'href="../../about/index.html"' ), 'an internal page link became relative' );
|
||||||
|
T::ok( false !== strpos( $out, 'href="https://elsewhere.example/offsite"' ), 'an external link is left alone' );
|
||||||
|
T::ok(
|
||||||
|
false !== strpos( $out, 'href="../../wp-content/themes/x/style.css"' ),
|
||||||
|
'the ?ver= query is dropped from an asset, which the bucket stores once'
|
||||||
|
);
|
||||||
|
T::ok( false !== strpos( $out, 'src="../../wp-content/uploads/pic.png"' ), 'a protocol-relative URL is rewritten too' );
|
||||||
|
T::ok( false !== strpos( $out, 'href="index.html#notes"' ), 'a fragment survives rewriting' );
|
||||||
|
|
||||||
|
T::ok( isset( $result['assets']['wp-content/themes/x/style.css'] ), 'the stylesheet was collected for export' );
|
||||||
|
T::ok( isset( $result['assets']['wp-content/uploads/pic.png'] ), 'the image was collected for export' );
|
||||||
|
T::is(
|
||||||
|
$result['assets']['wp-content/themes/x/style.css'],
|
||||||
|
'https://example.test/wp-content/themes/x/style.css',
|
||||||
|
'a collected asset carries the URL to fetch it from'
|
||||||
|
);
|
||||||
|
T::ok( ! isset( $result['assets']['about/index.html'] ), 'a page is not collected as an asset' );
|
||||||
|
|
||||||
|
// The exporter's own marker must never survive into a published page: every
|
||||||
|
// internal link would carry it, and the published copy would then be asking
|
||||||
|
// for the export-rendered variant of every page.
|
||||||
|
$flagged = '<a href="https://example.test/about/?sirius_export=abc123">About</a>';
|
||||||
|
$rewrote = SPE_Renderer::rewrite( $flagged, 'index.html' )['html'];
|
||||||
|
T::ok( false === strpos( $rewrote, 'sirius_export' ), 'the export marker is stripped' );
|
||||||
|
|
||||||
|
exit( T::summary() );
|
||||||
197
tests/test-identity.php
Normal file
197
tests/test-identity.php
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Addresses, derivation and the two signing envelopes.
|
||||||
|
*
|
||||||
|
* These are the pieces that decide who a user is. A bug here does not produce
|
||||||
|
* a crash — it produces an account attached to the wrong key, or a login that
|
||||||
|
* accepts a signature it should not. So the checks lean hard on cases where
|
||||||
|
* a plausible-looking shortcut would be wrong: a flipped checksum character, a
|
||||||
|
* token-aware address spelling, a signature over a slightly different message.
|
||||||
|
*
|
||||||
|
* Addresses and the derived wallet were produced with libauth, the same
|
||||||
|
* library the Sirius portal uses.
|
||||||
|
*
|
||||||
|
* @package SiriusPress
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
T::group( 'CashAddress' );
|
||||||
|
|
||||||
|
$mainnet = 'bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h';
|
||||||
|
$chipnet = 'bchtest:qp63uahgrxged4z5jswyt5dn5v3lzsem6cq85x00dt';
|
||||||
|
$pub_one = SP_Secp256k1::public_key( hex2bin( str_repeat( '0', 63 ) . '1' ) );
|
||||||
|
|
||||||
|
T::is( SP_CashAddr::from_public_key( $pub_one, 'bitcoincash' ), $mainnet, 'mainnet address for key 1' );
|
||||||
|
T::is( SP_CashAddr::from_public_key( $pub_one, 'bchtest' ), $chipnet, 'chipnet address for the same key' );
|
||||||
|
|
||||||
|
T::ok( SP_CashAddr::is_valid( $mainnet ), 'a good address validates' );
|
||||||
|
T::ok( ! SP_CashAddr::is_valid( substr( $mainnet, 0, -1 ) . 'q' ), 'a corrupted checksum is rejected' );
|
||||||
|
T::ok( ! SP_CashAddr::is_valid( 'bitcoincash:notanaddressatall' ), 'nonsense is rejected' );
|
||||||
|
T::ok( ! SP_CashAddr::is_valid( '' ), 'an empty string is rejected' );
|
||||||
|
T::ok( ! SP_CashAddr::is_valid( 'walerikus@example.com' ), 'an email address is not an address' );
|
||||||
|
|
||||||
|
// The prefix is part of the checksum, so a mainnet payload under a testnet
|
||||||
|
// prefix must not validate. Without this, a chipnet signature could
|
||||||
|
// authenticate a mainnet account.
|
||||||
|
$payload = substr( $mainnet, strlen( 'bitcoincash:' ) );
|
||||||
|
T::ok( ! SP_CashAddr::is_valid( 'bchtest:' . $payload ), 'a mainnet payload does not validate under the chipnet prefix' );
|
||||||
|
|
||||||
|
$decoded = SP_CashAddr::decode( $mainnet );
|
||||||
|
T::is( $decoded['prefix'], 'bitcoincash', 'decode reports the prefix' );
|
||||||
|
T::is( $decoded['type'], SP_CashAddr::TYPE_P2PKH, 'decode reports the type' );
|
||||||
|
T::is( strlen( $decoded['hash'] ), 20, 'decode returns a 20-byte hash' );
|
||||||
|
|
||||||
|
// Token-aware and plain spellings of one key must resolve to one account.
|
||||||
|
$token = SP_CashAddr::encode( 'bitcoincash', $decoded['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN );
|
||||||
|
T::ok( $token !== $mainnet, 'the token-aware spelling differs' );
|
||||||
|
T::is( SP_CashAddr::normalize( $token ), $mainnet, 'the token-aware spelling normalises to the plain one' );
|
||||||
|
T::is( SP_CashAddr::normalize( strtoupper( $mainnet ) ), $mainnet, 'case does not create a second identity' );
|
||||||
|
T::is( SP_CashAddr::normalize( 'nonsense' ), '', 'normalising a non-address gives an empty string' );
|
||||||
|
|
||||||
|
// A bare payload, which is how half the ecosystem displays an address.
|
||||||
|
T::is( SP_CashAddr::normalize( $payload ), $mainnet, 'a prefix-less address is understood' );
|
||||||
|
|
||||||
|
T::group( 'BIP-39 and BIP-32' );
|
||||||
|
|
||||||
|
// Generated by libauth's BuiltInWallet; the address is what the Sirius portal
|
||||||
|
// shows for this phrase.
|
||||||
|
$phrase = 'trash key flip dawn impulse float medal rain sell hand neither hub';
|
||||||
|
$key = SP_HD::publishing_key( $phrase, 'bchtest' );
|
||||||
|
|
||||||
|
T::is( $key['address'], 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy', 'derives the address the portal wallet derives' );
|
||||||
|
T::is( strlen( $key['private'] ), 32, 'the private key is 32 bytes' );
|
||||||
|
T::is( strlen( $key['public'] ), 33, 'the public key is compressed' );
|
||||||
|
|
||||||
|
// Normalisation must not change the key: a phrase pasted with odd spacing or
|
||||||
|
// capitals is the same wallet, and treating it as a different one would look
|
||||||
|
// to the user like their funds had vanished.
|
||||||
|
T::is(
|
||||||
|
SP_HD::publishing_key( " Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", 'bchtest' )['address'],
|
||||||
|
$key['address'],
|
||||||
|
'spacing and capitals do not change the wallet'
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different path is a different wallet — this is the single most likely
|
||||||
|
// cause of an unexplained 403 from the gateway.
|
||||||
|
T::ok(
|
||||||
|
SP_HD::publishing_key( $phrase, 'bchtest', "m/44'/0'/0'/0/0" )['address'] !== $key['address'],
|
||||||
|
'a different derivation path gives a different address'
|
||||||
|
);
|
||||||
|
|
||||||
|
T::is( SP_HD::phrase_problem( $phrase ), '', 'a good phrase has no problem' );
|
||||||
|
T::ok( '' !== SP_HD::phrase_problem( 'too few words' ), 'a short phrase is reported' );
|
||||||
|
T::ok( '' !== SP_HD::phrase_problem( '' ), 'an empty phrase is reported' );
|
||||||
|
|
||||||
|
T::group( 'BIP-137 message signatures' );
|
||||||
|
|
||||||
|
$message = "Sign in to Example\n\nSite: https://example.test/\nNonce: 1.2.3";
|
||||||
|
|
||||||
|
// The digest a wallet computes. Checked against the Theseus wallet's own
|
||||||
|
// implementation (bundled-addons/aegis/lib/chain-bch.js).
|
||||||
|
T::is(
|
||||||
|
bin2hex( SP_Message::bip137_digest( 'SIRIUS-PRESS-LOGIN1' . "\n" . 'https://example.bch' . "\n" . 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy' . "\n" . 'abc123' . "\n" . '1758412800000' ) ),
|
||||||
|
'98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19',
|
||||||
|
'the message digest matches the Theseus wallet'
|
||||||
|
);
|
||||||
|
|
||||||
|
$signature = SP_Message::sign( $message, $key['private'] );
|
||||||
|
T::ok( SP_Message::verify( $message, $signature, $key['address'] ), 'a signature verifies against its own address' );
|
||||||
|
T::ok( ! SP_Message::verify( $message . ' ', $signature, $key['address'] ), 'a trailing space breaks verification' );
|
||||||
|
T::ok( ! SP_Message::verify( $message, $signature, $mainnet ), 'the signature does not verify against another address' );
|
||||||
|
T::ok( ! SP_Message::verify( $message, 'not base64 at all!!', $key['address'] ), 'garbage is not a signature' );
|
||||||
|
T::ok( ! SP_Message::verify( $message, base64_encode( 'short' ), $key['address'] ), 'a short signature is refused' );
|
||||||
|
T::ok( ! SP_Message::verify( $message, $signature, 'bitcoincash:notanaddress' ), 'an invalid claimed address is refused' );
|
||||||
|
|
||||||
|
// A token-aware address must still verify — a user may present either form.
|
||||||
|
T::ok(
|
||||||
|
SP_Message::verify(
|
||||||
|
$message,
|
||||||
|
$signature,
|
||||||
|
SP_CashAddr::encode( 'bchtest', SP_CashAddr::decode( $key['address'] )['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN )
|
||||||
|
),
|
||||||
|
'the token-aware spelling of the signer verifies'
|
||||||
|
);
|
||||||
|
|
||||||
|
// A message longer than 252 bytes crosses the varint boundary in the BIP-137
|
||||||
|
// payload. Getting that wrong produces signatures no other wallet accepts.
|
||||||
|
$long = str_repeat( 'x', 300 );
|
||||||
|
T::ok(
|
||||||
|
SP_Message::verify( $long, SP_Message::sign( $long, $key['private'] ), $key['address'] ),
|
||||||
|
'a 300-byte message signs and verifies across the varint boundary'
|
||||||
|
);
|
||||||
|
|
||||||
|
T::group( 'BNS-SITE1 upload envelope' );
|
||||||
|
|
||||||
|
T::is(
|
||||||
|
bin2hex( SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 ) ),
|
||||||
|
hash( 'sha256', "BNS-SITE1\nexample.bch\nindex.html\n" . hash( 'sha256', '<h1>hi</h1>' ) . "\n1758412800000" ),
|
||||||
|
'the upload digest matches the gateway formula'
|
||||||
|
);
|
||||||
|
|
||||||
|
$headers = SP_Message::site_headers( 'example.bch', 'index.html', '<h1>hi</h1>', $key['private'], 1758412800000 );
|
||||||
|
T::is( $headers['x-bns-ts'], '1758412800000', 'the timestamp header is the one signed' );
|
||||||
|
T::is( strlen( base64_decode( $headers['x-bns-sig'], true ) ), 65, 'the signature header decodes to 65 bytes' );
|
||||||
|
|
||||||
|
// The gateway recovers the signer and compares it to the on-chain owner.
|
||||||
|
// Reproduce that here so a change to either side is caught.
|
||||||
|
T::is(
|
||||||
|
SP_CashAddr::from_public_key(
|
||||||
|
SP_Secp256k1::recover(
|
||||||
|
base64_decode( $headers['x-bns-sig'], true ),
|
||||||
|
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 )
|
||||||
|
),
|
||||||
|
'bchtest'
|
||||||
|
),
|
||||||
|
$key['address'],
|
||||||
|
'the gateway would recover the publishing address from the header'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Changing any signed component must invalidate it.
|
||||||
|
T::ok(
|
||||||
|
SP_CashAddr::from_public_key(
|
||||||
|
SP_Secp256k1::recover(
|
||||||
|
base64_decode( $headers['x-bns-sig'], true ),
|
||||||
|
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>TAMPERED</h1>', 1758412800000 )
|
||||||
|
),
|
||||||
|
'bchtest'
|
||||||
|
) !== $key['address'],
|
||||||
|
'altered upload bytes no longer recover the publishing address'
|
||||||
|
);
|
||||||
|
|
||||||
|
T::group( 'canonical JSON' );
|
||||||
|
|
||||||
|
T::is(
|
||||||
|
SP_Message::canonical_json( array( 'b' => 1, 'a' => 2 ) ),
|
||||||
|
'{"a":2,"b":1}',
|
||||||
|
'keys are sorted'
|
||||||
|
);
|
||||||
|
T::is(
|
||||||
|
SP_Message::canonical_json( array( 'a' => null, 'b' => 1 ) ),
|
||||||
|
'{"b":1}',
|
||||||
|
'null members are dropped'
|
||||||
|
);
|
||||||
|
T::is(
|
||||||
|
SP_Message::canonical_json( array( 'z' => array( 'y' => 1, 'x' => 2 ) ) ),
|
||||||
|
'{"z":{"x":2,"y":1}}',
|
||||||
|
'nested keys are sorted too'
|
||||||
|
);
|
||||||
|
T::is(
|
||||||
|
SP_Message::canonical_json( array( 'url' => 'https://a/b' ) ),
|
||||||
|
'{"url":"https://a/b"}',
|
||||||
|
'slashes are not escaped, matching JSON.stringify'
|
||||||
|
);
|
||||||
|
// Written as a concatenation so the expected value cannot itself be stored as
|
||||||
|
// a literal e-acute, which would make this assertion test nothing.
|
||||||
|
T::is(
|
||||||
|
SP_Message::canonical_json( array( 'note' => 'caf' . chr( 0xc3 ) . chr( 0xa9 ) ) ),
|
||||||
|
'{"note":"caf' . chr( 92 ) . 'u00e9"}',
|
||||||
|
'non-ASCII is backslash-u escaped'
|
||||||
|
);
|
||||||
|
T::is(
|
||||||
|
SP_Message::canonical_json( array( 'list' => array( 3, 1, 2 ) ) ),
|
||||||
|
'{"list":[3,1,2]}',
|
||||||
|
'array order is preserved'
|
||||||
|
);
|
||||||
|
|
||||||
|
exit( T::summary() );
|
||||||
140
tools/build.sh
Normal file
140
tools/build.sh
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# build.sh — assemble a complete, patched Sirius Press tree.
|
||||||
|
#
|
||||||
|
# Downloads the pinned WordPress, verifies it against the checksum in
|
||||||
|
# tools/wordpress.lock, applies the patch series in patches/, drops this
|
||||||
|
# repository's plugins and must-use plugins into place, and leaves the result
|
||||||
|
# in dist/sirius-press/.
|
||||||
|
#
|
||||||
|
# With --zip it also produces dist/sirius-press-<version>.zip, which is the
|
||||||
|
# artifact shared-hosting users upload.
|
||||||
|
#
|
||||||
|
# The verification is not a formality. This script fetches executable code
|
||||||
|
# over the network and then runs it as a web server; the checksum is the only
|
||||||
|
# thing standing between a compromised mirror and every site built from it.
|
||||||
|
# If it fails, the build stops — there is no --skip-verify, on purpose.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$here"
|
||||||
|
|
||||||
|
# shellcheck source=wordpress.lock
|
||||||
|
source tools/wordpress.lock
|
||||||
|
|
||||||
|
VERSION="$(grep -m1 "^ \* Version:" plugins/sirius-press-core/sirius-press-core.php | awk '{print $3}')"
|
||||||
|
DIST="$here/dist"
|
||||||
|
TARGET="$DIST/sirius-press"
|
||||||
|
CACHE="${SIRIUS_BUILD_CACHE:-$DIST/.cache}"
|
||||||
|
MAKE_ZIP=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--zip) MAKE_ZIP=1 ;;
|
||||||
|
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||||
|
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v curl >/dev/null || die "curl is required"
|
||||||
|
command -v tar >/dev/null || die "tar is required"
|
||||||
|
command -v patch >/dev/null || die "patch is required (used to apply the core patch series)"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- fetch core
|
||||||
|
|
||||||
|
mkdir -p "$CACHE"
|
||||||
|
tarball="$CACHE/wordpress-$WP_VERSION.tar.gz"
|
||||||
|
|
||||||
|
if [ ! -f "$tarball" ]; then
|
||||||
|
say "downloading WordPress $WP_VERSION"
|
||||||
|
curl -fsSL -o "$tarball.part" "$WP_URL" || die "download failed"
|
||||||
|
mv "$tarball.part" "$tarball"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "verifying checksum"
|
||||||
|
actual="$(sha256sum "$tarball" | cut -d' ' -f1)"
|
||||||
|
if [ "$actual" != "$WP_SHA256" ]; then
|
||||||
|
rm -f "$tarball"
|
||||||
|
die "checksum mismatch for WordPress $WP_VERSION
|
||||||
|
expected $WP_SHA256
|
||||||
|
got $actual
|
||||||
|
The cached download has been deleted. If this repeats, the mirror is serving
|
||||||
|
something other than what this fork was built against — do not work around it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- assemble
|
||||||
|
|
||||||
|
say "unpacking into dist/sirius-press"
|
||||||
|
rm -rf "$TARGET"
|
||||||
|
mkdir -p "$TARGET"
|
||||||
|
tar -xzf "$tarball" -C "$DIST"
|
||||||
|
mv "$DIST/wordpress"/* "$TARGET"/
|
||||||
|
mv "$DIST/wordpress"/.[!.]* "$TARGET"/ 2>/dev/null || true
|
||||||
|
rmdir "$DIST/wordpress"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ patch series
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
patches=(patches/*.patch)
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
if [ ${#patches[@]} -eq 0 ]; then
|
||||||
|
say "no core patches to apply"
|
||||||
|
else
|
||||||
|
say "applying ${#patches[@]} core patch(es)"
|
||||||
|
for patch in "${patches[@]}"; do
|
||||||
|
# -F3 tolerates a few lines of drift around the hunk, so a WordPress
|
||||||
|
# point release that shifts the surrounding code still takes the patch.
|
||||||
|
# If it genuinely no longer fits, stop: a half-patched installer is
|
||||||
|
# worse than a failed build.
|
||||||
|
if (cd "$TARGET" && patch -p1 -F3 --forward --silent < "$here/$patch"); then
|
||||||
|
echo " ok $(basename "$patch")"
|
||||||
|
else
|
||||||
|
die "$(basename "$patch") did not apply to WordPress $WP_VERSION.
|
||||||
|
Run tools/update-wordpress.sh to refresh the series against this version."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- our code
|
||||||
|
|
||||||
|
say "installing Sirius Press plugins"
|
||||||
|
mkdir -p "$TARGET/wp-content/plugins" "$TARGET/wp-content/mu-plugins"
|
||||||
|
for plugin in plugins/*/; do
|
||||||
|
cp -R "$plugin" "$TARGET/wp-content/plugins/"
|
||||||
|
done
|
||||||
|
cp mu-plugins/*.php "$TARGET/wp-content/mu-plugins/"
|
||||||
|
|
||||||
|
# The stock wp-config-sample.php has no SIRIUS_PRESS_KEY, and a site that
|
||||||
|
# never sets one falls back to its auth salts — which works, but means
|
||||||
|
# rotating salts orphans the stored publishing key.
|
||||||
|
if [ -f docker/wp-config-sirius.php ]; then
|
||||||
|
cp docker/wp-config-sirius.php "$TARGET/wp-config-sirius-sample.php"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "built dist/sirius-press ($(du -sh "$TARGET" | cut -f1))"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- zip
|
||||||
|
|
||||||
|
if [ "$MAKE_ZIP" -eq 1 ]; then
|
||||||
|
command -v zip >/dev/null || die "zip is required for --zip"
|
||||||
|
archive="$DIST/sirius-press-$VERSION.zip"
|
||||||
|
say "packing $(basename "$archive")"
|
||||||
|
rm -f "$archive"
|
||||||
|
(cd "$DIST" && zip -qr "$archive" sirius-press)
|
||||||
|
sha256sum "$archive" | cut -d' ' -f1 > "$archive.sha256"
|
||||||
|
say "$(basename "$archive") — $(du -h "$archive" | cut -f1), sha256 $(cat "$archive.sha256")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Sirius Press $VERSION, built on WordPress $WP_VERSION.
|
||||||
|
|
||||||
|
dist/sirius-press/ a complete, patched WordPress tree
|
||||||
|
$( [ "$MAKE_ZIP" -eq 1 ] && echo " dist/sirius-press-$VERSION.zip upload this to a shared host" )
|
||||||
|
|
||||||
|
To run it locally: cd docker && docker compose up -d
|
||||||
|
To install on a VPS: see docs/install.md
|
||||||
|
EOF
|
||||||
141
tools/publish-release.sh
Normal file
141
tools/publish-release.sh
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# publish-release.sh — put a built release on both mirrors.
|
||||||
|
#
|
||||||
|
# tools/publish-release.sh # build, then publish
|
||||||
|
# tools/publish-release.sh --dry-run # say what would happen
|
||||||
|
#
|
||||||
|
# A Silent Mode release is not shipped until it exists in both places:
|
||||||
|
#
|
||||||
|
# silentmode.st over scp, served by nginx on the VPS
|
||||||
|
# silentmode.bch on Sia, reached through any BCNR resolver
|
||||||
|
#
|
||||||
|
# Publishing to only one is the recurring mistake this script exists to
|
||||||
|
# prevent. A release on silentmode.st alone is a release that disappears when
|
||||||
|
# the VPS does, which rather undercuts the argument the project is making.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$here"
|
||||||
|
|
||||||
|
SSH_HOST="${SIRIUS_SSH_HOST:-silentmode}"
|
||||||
|
WEB_ROOT="${SIRIUS_WEB_ROOT:-/opt/silent-mode/site/sirius-press}"
|
||||||
|
S3_ENDPOINT="${SIRIUS_S3_ENDPOINT:-https://s3.silentmode.st:8600}"
|
||||||
|
S3_PREFIX="${SIRIUS_S3_PREFIX:-s3://bns/silentmode/sirius-press}"
|
||||||
|
AWS_PROFILE_NAME="${SIRIUS_AWS_PROFILE:-sia-storage}"
|
||||||
|
DRY=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run) DRY=1 ;;
|
||||||
|
*) echo "unknown option: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
run() { if [ "$DRY" -eq 1 ]; then printf ' would run: %s\n' "$*"; else "$@"; fi; }
|
||||||
|
|
||||||
|
VERSION="$(grep -m1 "^ \* Version:" plugins/sirius-press-core/sirius-press-core.php | awk '{print $3}')"
|
||||||
|
[ -n "$VERSION" ] || die "could not read the version out of sirius-press-core.php"
|
||||||
|
|
||||||
|
ARCHIVE="dist/sirius-press-${VERSION}.zip"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- build
|
||||||
|
|
||||||
|
if [ ! -f "$ARCHIVE" ]; then
|
||||||
|
say "building $VERSION"
|
||||||
|
[ "$DRY" -eq 1 ] && { echo " would run: tools/build.sh --zip"; } || tools/build.sh --zip
|
||||||
|
fi
|
||||||
|
[ "$DRY" -eq 1 ] || [ -f "$ARCHIVE" ] || die "$ARCHIVE was not produced"
|
||||||
|
|
||||||
|
if [ -f "$ARCHIVE" ]; then
|
||||||
|
SHA="$(cat "${ARCHIVE}.sha256" 2>/dev/null || sha256sum "$ARCHIVE" | cut -d' ' -f1)"
|
||||||
|
say "$(basename "$ARCHIVE") — $(du -h "$ARCHIVE" | cut -f1), sha256 ${SHA}"
|
||||||
|
else
|
||||||
|
SHA="(not built)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- manifest
|
||||||
|
|
||||||
|
MANIFEST="dist/releases.json"
|
||||||
|
say "writing $(basename "$MANIFEST")"
|
||||||
|
if [ "$DRY" -eq 0 ]; then
|
||||||
|
cat > "$MANIFEST" <<EOF
|
||||||
|
{
|
||||||
|
"product": "sirius-press",
|
||||||
|
"latest": "${VERSION}",
|
||||||
|
"updated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||||
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"zip": "sirius-press-${VERSION}.zip",
|
||||||
|
"sha256": "${SHA}",
|
||||||
|
"wordpress": "$(grep -m1 '^WP_VERSION=' tools/wordpress.lock | cut -d= -f2)",
|
||||||
|
"install": "https://silentmode.st/sirius-press/install.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------- mirror 1: the VPS
|
||||||
|
|
||||||
|
say "publishing to silentmode.st"
|
||||||
|
command -v ssh >/dev/null || die "ssh is required"
|
||||||
|
run ssh "$SSH_HOST" "mkdir -p ${WEB_ROOT}"
|
||||||
|
run scp "$ARCHIVE" "${SSH_HOST}:${WEB_ROOT}/"
|
||||||
|
run scp "${ARCHIVE}.sha256" "${SSH_HOST}:${WEB_ROOT}/"
|
||||||
|
run scp "$MANIFEST" "${SSH_HOST}:${WEB_ROOT}/releases.json"
|
||||||
|
run scp install.sh "${SSH_HOST}:${WEB_ROOT}/install.sh"
|
||||||
|
|
||||||
|
# --------------------------------------------------------- mirror 2: Sia
|
||||||
|
|
||||||
|
say "publishing to silentmode.bch"
|
||||||
|
if ! command -v aws >/dev/null; then
|
||||||
|
warn "the aws CLI is not installed, so the Sia mirror was skipped.
|
||||||
|
This release is NOT shipped until it is on both. Install awscli and run:
|
||||||
|
aws --profile ${AWS_PROFILE_NAME} --endpoint-url ${S3_ENDPOINT} s3 cp ${ARCHIVE} ${S3_PREFIX}/"
|
||||||
|
else
|
||||||
|
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||||
|
s3 cp "$ARCHIVE" "${S3_PREFIX}/" --only-show-errors
|
||||||
|
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||||
|
s3 cp "${ARCHIVE}.sha256" "${S3_PREFIX}/" --only-show-errors
|
||||||
|
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||||
|
s3 cp "$MANIFEST" "${S3_PREFIX}/releases.json" --only-show-errors
|
||||||
|
run aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||||
|
s3 cp install.sh "${S3_PREFIX}/install.sh" --only-show-errors
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ verify
|
||||||
|
|
||||||
|
if [ "$DRY" -eq 0 ]; then
|
||||||
|
say "checking both mirrors actually have it"
|
||||||
|
st_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 20 \
|
||||||
|
"https://silentmode.st/sirius-press/$(basename "$ARCHIVE")" 2>/dev/null || echo 000)"
|
||||||
|
[ "$st_code" = "200" ] \
|
||||||
|
&& echo " ok silentmode.st" \
|
||||||
|
|| warn "silentmode.st returned ${st_code} for the archive"
|
||||||
|
|
||||||
|
if command -v aws >/dev/null; then
|
||||||
|
aws --profile "$AWS_PROFILE_NAME" --endpoint-url "$S3_ENDPOINT" \
|
||||||
|
s3 ls "${S3_PREFIX}/$(basename "$ARCHIVE")" >/dev/null 2>&1 \
|
||||||
|
&& echo " ok silentmode.bch" \
|
||||||
|
|| warn "the archive is not listed on the Sia mirror"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Sirius Press ${VERSION} published.
|
||||||
|
|
||||||
|
https://silentmode.st/sirius-press/
|
||||||
|
silentmode.bch/sirius-press/
|
||||||
|
|
||||||
|
Still to do by hand:
|
||||||
|
- tag the forge repo: git tag -a v${VERSION} -m "Sirius Press ${VERSION}"
|
||||||
|
- attach $(basename "$ARCHIVE") to the release on
|
||||||
|
https://code.silentmode.st/silentmode/sirius-press/releases
|
||||||
|
|
||||||
|
EOF
|
||||||
211
tools/update-wordpress.sh
Normal file
211
tools/update-wordpress.sh
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# update-wordpress.sh — move the fork onto a new WordPress release.
|
||||||
|
#
|
||||||
|
# tools/update-wordpress.sh 7.1.2
|
||||||
|
# tools/update-wordpress.sh 7.1.2 --refresh # also rewrite the patch series
|
||||||
|
# tools/update-wordpress.sh 7.1.2 --diff wp-admin/install.php
|
||||||
|
#
|
||||||
|
# Fetches the release, verifies it against the SHA-1 wordpress.org publishes,
|
||||||
|
# applies the patch series, and reports whether the fork still fits.
|
||||||
|
#
|
||||||
|
# Nothing is committed and tools/wordpress.lock is only rewritten once the
|
||||||
|
# series has actually applied — so a failed run leaves the fork building the
|
||||||
|
# last known-good version rather than a broken one.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$here"
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
REFRESH=0
|
||||||
|
DIFF_FILE=""
|
||||||
|
|
||||||
|
shift || true
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--refresh) REFRESH=1 ;;
|
||||||
|
--diff) shift; DIFF_FILE="${1:-}" ;;
|
||||||
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
say() { printf '\033[1m→\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ -n "$VERSION" ] || die "usage: tools/update-wordpress.sh <version> [--refresh] [--diff <file>]"
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || die "'$VERSION' does not look like a WordPress version"
|
||||||
|
|
||||||
|
# shellcheck source=wordpress.lock
|
||||||
|
source tools/wordpress.lock
|
||||||
|
CURRENT="$WP_VERSION"
|
||||||
|
|
||||||
|
WORK="$here/dist/.upstream"
|
||||||
|
CACHE="$here/dist/.cache"
|
||||||
|
mkdir -p "$WORK" "$CACHE"
|
||||||
|
|
||||||
|
URL="https://wordpress.org/wordpress-${VERSION}.tar.gz"
|
||||||
|
TARBALL="$CACHE/wordpress-${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- fetch
|
||||||
|
|
||||||
|
if [ ! -f "$TARBALL" ]; then
|
||||||
|
say "downloading WordPress $VERSION"
|
||||||
|
curl -fsSL -o "$TARBALL.part" "$URL" || die "could not download $URL — does that version exist?"
|
||||||
|
mv "$TARBALL.part" "$TARBALL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "verifying against wordpress.org's published SHA-1"
|
||||||
|
published="$(curl -fsSL "${URL}.sha1" 2>/dev/null || true)"
|
||||||
|
actual_sha1="$(sha1sum "$TARBALL" | cut -d' ' -f1)"
|
||||||
|
if [ -z "$published" ]; then
|
||||||
|
warn "wordpress.org did not serve a .sha1 for this release; continuing on the SHA-256 recorded below"
|
||||||
|
elif [ "$published" != "$actual_sha1" ]; then
|
||||||
|
rm -f "$TARBALL"
|
||||||
|
die "SHA-1 mismatch — the download does not match what wordpress.org publishes.
|
||||||
|
published $published
|
||||||
|
got $actual_sha1"
|
||||||
|
fi
|
||||||
|
NEW_SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ unpack
|
||||||
|
|
||||||
|
say "unpacking"
|
||||||
|
rm -rf "${WORK:?}/new" "${WORK:?}/pristine" "${WORK:?}/wordpress"
|
||||||
|
tar -xzf "$TARBALL" -C "$WORK"
|
||||||
|
[ -d "$WORK/wordpress" ] || die "the tarball did not contain a wordpress/ directory"
|
||||||
|
mv "$WORK/wordpress" "$WORK/new"
|
||||||
|
cp -R "$WORK/new" "$WORK/pristine"
|
||||||
|
|
||||||
|
# --------------------------------------------------------- what changed?
|
||||||
|
|
||||||
|
if [ -n "$DIFF_FILE" ]; then
|
||||||
|
old_tarball="$CACHE/wordpress-${CURRENT}.tar.gz"
|
||||||
|
if [ ! -f "$old_tarball" ]; then
|
||||||
|
say "fetching $CURRENT for comparison"
|
||||||
|
curl -fsSL -o "$old_tarball" "$WP_URL"
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK/old"
|
||||||
|
mkdir -p "$WORK/old"
|
||||||
|
tar -xzf "$old_tarball" -C "$WORK/old"
|
||||||
|
say "upstream's own changes to $DIFF_FILE between $CURRENT and $VERSION:"
|
||||||
|
diff -u "$WORK/old/wordpress/$DIFF_FILE" "$WORK/new/$DIFF_FILE" || true
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ the series
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
patches=(patches/*.patch)
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
fuzzed=0
|
||||||
|
|
||||||
|
if [ ${#patches[@]} -eq 0 ]; then
|
||||||
|
say "no patches to apply"
|
||||||
|
else
|
||||||
|
say "applying ${#patches[@]} patch(es) to WordPress $VERSION"
|
||||||
|
for patch in "${patches[@]}"; do
|
||||||
|
name="$(basename "$patch")"
|
||||||
|
output="$(cd "$WORK/new" && patch -p1 -F3 --forward < "$here/$patch" 2>&1)" && rc=0 || rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo " FAILED $name"
|
||||||
|
echo "$output" | sed 's/^/ /'
|
||||||
|
failed=1
|
||||||
|
elif echo "$output" | grep -q 'with fuzz'; then
|
||||||
|
echo " fuzz $name"
|
||||||
|
echo "$output" | grep 'with fuzz' | sed 's/^/ /'
|
||||||
|
fuzzed=1
|
||||||
|
else
|
||||||
|
echo " ok $name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ "$failed" -eq 1 ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
$(printf '\033[31mThe patch series no longer fits WordPress %s.\033[0m' "$VERSION")
|
||||||
|
|
||||||
|
Upstream has rewritten code the fork patches. Nothing has changed here — the
|
||||||
|
lock file still points at $CURRENT and builds still produce a working fork.
|
||||||
|
|
||||||
|
To fix it:
|
||||||
|
1. The unpacked tree is at dist/.upstream/new — edit it by hand.
|
||||||
|
2. Diff it against the pristine copy:
|
||||||
|
diff -u dist/.upstream/pristine/<file> dist/.upstream/new/<file>
|
||||||
|
3. Replace the hunks in the failing patch with the result, keeping its
|
||||||
|
Subject: header and explanation.
|
||||||
|
4. Run this script again.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ refresh
|
||||||
|
|
||||||
|
if [ "$REFRESH" -eq 1 ]; then
|
||||||
|
say "regenerating the patch series against $VERSION"
|
||||||
|
for patch in "${patches[@]}"; do
|
||||||
|
file="$(grep -m1 '^--- a/' "$patch" | sed 's|^--- a/||')"
|
||||||
|
[ -n "$file" ] || { warn "$(basename "$patch") has no file header; left alone"; continue; }
|
||||||
|
header="$(sed -n '1,/^--- a\//p' "$patch" | sed '$d')"
|
||||||
|
{
|
||||||
|
echo "$header" | sed "s/^Applies to: WordPress .*/Applies to: WordPress $VERSION/"
|
||||||
|
diff -u "$WORK/pristine/$file" "$WORK/new/$file" \
|
||||||
|
| sed -e "1s|.*|--- a/$file|" -e "2s|.*|+++ b/$file|"
|
||||||
|
} > "$patch.new"
|
||||||
|
mv "$patch.new" "$patch"
|
||||||
|
echo " refreshed $(basename "$patch")"
|
||||||
|
done
|
||||||
|
elif [ "$fuzzed" -eq 1 ]; then
|
||||||
|
warn "the series applied with fuzz. Run again with --refresh so the next release starts clean."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- the lock
|
||||||
|
|
||||||
|
say "updating tools/wordpress.lock"
|
||||||
|
python3 - "$VERSION" "$URL" "$NEW_SHA256" "$actual_sha1" <<'PY'
|
||||||
|
import io, sys
|
||||||
|
version, url, sha256, sha1 = sys.argv[1:5]
|
||||||
|
path = 'tools/wordpress.lock'
|
||||||
|
out = []
|
||||||
|
for line in io.open(path, encoding='utf-8'):
|
||||||
|
if line.startswith('WP_VERSION='):
|
||||||
|
out.append(f'WP_VERSION={version}\n')
|
||||||
|
elif line.startswith('WP_URL='):
|
||||||
|
out.append(f'WP_URL={url}\n')
|
||||||
|
elif line.startswith('WP_SHA256='):
|
||||||
|
out.append(f'WP_SHA256={sha256}\n')
|
||||||
|
elif line.startswith('WP_SHA1='):
|
||||||
|
out.append(f'WP_SHA1={sha1}\n')
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
io.open(path, 'w', encoding='utf-8', newline='').write(''.join(out))
|
||||||
|
PY
|
||||||
|
|
||||||
|
# The compose defaults mirror the lock so a plain `docker compose build`
|
||||||
|
# cannot drift onto a different core.
|
||||||
|
sed -i \
|
||||||
|
-e "s|WP_VERSION: \${WP_VERSION:-.*}|WP_VERSION: \${WP_VERSION:-$VERSION}|" \
|
||||||
|
-e "s|WP_URL: \${WP_URL:-.*}|WP_URL: \${WP_URL:-$URL}|" \
|
||||||
|
-e "s|WP_SHA256: \${WP_SHA256:-.*}|WP_SHA256: \${WP_SHA256:-$NEW_SHA256}|" \
|
||||||
|
docker/docker-compose.yml
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
$(printf '\033[32mSirius Press now builds on WordPress %s.\033[0m' "$VERSION")
|
||||||
|
|
||||||
|
tools/wordpress.lock $CURRENT -> $VERSION
|
||||||
|
docker/docker-compose.yml build args updated
|
||||||
|
|
||||||
|
Next:
|
||||||
|
tests/run.sh
|
||||||
|
tools/build.sh
|
||||||
|
then walk through wp-admin/install.php once — the setup wizard is the only
|
||||||
|
patched file, so it is the only thing this can have broken.
|
||||||
|
|
||||||
|
EOF
|
||||||
14
tools/wordpress.lock
Normal file
14
tools/wordpress.lock
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# The exact WordPress this fork is built against.
|
||||||
|
#
|
||||||
|
# Sirius Press does not vendor a copy of WordPress. It pins one, verifies it,
|
||||||
|
# and patches it — see docs/upstream-merges.md for why, and for how to move
|
||||||
|
# this pin when a security release lands.
|
||||||
|
#
|
||||||
|
# sha256 was computed from the official tarball after checking it against the
|
||||||
|
# SHA-1 wordpress.org publishes alongside it. Both are recorded so a future
|
||||||
|
# reader can re-do that check without trusting this file alone.
|
||||||
|
|
||||||
|
WP_VERSION=7.1.1
|
||||||
|
WP_URL=https://wordpress.org/wordpress-7.1.1.tar.gz
|
||||||
|
WP_SHA256=3996fee13448ef12e07e9f0c77db2f655ffa1b7cde71c80a4965d3bf1fb956b3
|
||||||
|
WP_SHA1=2a9d68474e8703aa3a66f80427b325e0941b6a1e
|
||||||
Loading…
Add table
Reference in a new issue