Compare commits

...

No commits in common. "master" and "sirius-press/wordpress-upstream" have entirely different histories.

3848 changed files with 12 additions and 11699 deletions

29
.gitattributes vendored
View file

@ -1,29 +0,0 @@
# Line endings, pinned where getting them wrong breaks something.
#
# Git on Windows defaults to core.autocrlf=true, which rewrites LF to CRLF on
# checkout. For most files that is harmless. For these it is not:
# Shell scripts run on Linux. A CRLF checkout that is copied to a server
# fails with `bash: \r: command not found`, which tells you almost nothing
# about what is wrong.
*.sh text eol=lf
install.sh text eol=lf
# Patch files must survive byte for byte: `patch` and `git apply` both care,
# and tools/refresh-patches.sh --check compares them against freshly
# generated LF output.
*.patch -text
# Container and CI inputs are read by Linux tooling.
Dockerfile text eol=lf
*.yml text eol=lf
.env.example text eol=lf
docker/nginx/* text eol=lf
# PHP and JS are served, not executed by a shell, so either ending works —
# but keeping them LF means a diff between a Windows and a Linux checkout is
# about the code rather than about the line endings.
*.php text eol=lf
*.js text eol=lf
*.css text eol=lf
*.md text eol=lf

7
.gitignore vendored
View file

@ -1,7 +0,0 @@
# 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

View file

@ -1,128 +0,0 @@
# 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 change, 75 lines, in `wp-admin/install.php`: the setup wizard asks for a
wallet address instead of an email address, and the address is optional.
- WordPress is vendored at `wordpress/` as a git subtree, already patched.
Upstream releases arrive through `git subtree merge` against
`sirius-press/wordpress-upstream`, a branch of pristine imports, so a
release that touches code near the fork's change is three-way merged rather
than re-derived by hand. `tools/update-wordpress.sh <version>` runs the
whole thing.
- `patches/` is now generated from the tree by `tools/refresh-patches.sh`
(with `--check` for CI) and exists to answer "what does this fork change in
core?" without reading a 3,800-file log. It is documentation, not the build
mechanism.
### 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.
### Fixed while testing against a live instance
- **Registration and wallet-linking accepted a signature over the wrong text.**
Public-key recovery always succeeds — it returns a different key rather than
failing — so a mismatched signature silently bound an account to an address
nobody could sign for. Both paths now require the claimed address and
compare it to the recovered one. Sign-in was never exposed to this, because
a wrong address simply matches no account.
- URL rewriting mangled links on any site whose URL carries a port: the
protocol-relative pass matched inside absolute URLs and doubled the scheme,
and a host-only match left the port stranded. Both covered by tests now.
- Translations loaded on `plugins_loaded`, which WordPress 6.7+ warns about on
every request. Moved to `init`.
- The Publishing screen now refuses to be quiet about plain permalinks, which
would collapse an entire site onto one exported file.
- Removed an `is_email()` filter that rested on a false premise: WordPress
validates syntax, not whether a domain can exist, so `.invalid` addresses
already pass and the filter never fired. The documentation said otherwise
and has been corrected.
### Fixed after opening it in a browser
- Wallet sign-in did not work in a browser at all. WordPress marks its
username and password inputs `required`; the wallet path leaves both empty
on purpose, so the form refused to submit a valid signature and pointed a
validation bubble at a field the visitor is not meant to fill in.
- The `hidden` attribute was being overridden by WordPress's own button
styles, so controls this plugin ships hidden were visible regardless —
offering a browser wallet that is not there, and showing a JavaScript-only
button to visitors without JavaScript.
### WordPress 7.1.2
Taken through `tools/update-wordpress.sh` and merged cleanly; upstream did not
touch the one file this fork patches. Two bugs in the update tool surfaced
doing it: a Windows path-subtraction that produced an absolute `--prefix`, and
git-subtree's refusal to run in a repository with any uncommitted file. The
merge now runs in a scratch worktree and a failed run can be retried.
### Fixed after deploying to a real VPS
- **Static export could never work in the bundled Docker stack.** The exporter
fetches each page over HTTP, and a container cannot reach the host's own
published port on most Docker hosts — every export died with a cURL
timeout while the site itself was plainly up. `SIRIUS_PRESS_LOOPBACK_URL`
now points the exporter at the nginx service on the compose network, with
the site's real `Host` header so the rendered page is unchanged and the
internal address never reaches the output. The stack sets it by default.
### Known gaps
- No byte has yet been written to the real gateway under a registered name.
The signing is verified against a transcription of the gateway's own
verifier, and the live instance reads the correct on-chain owner from
`navigate.st`, but the upload itself is untested.
- Manual-mode publishing (browser signs and uploads) has not been clicked
through; the server-signing path has.
- Publishing is verified against a transcription of the gateway's own
verification logic, not against `navigate.st` with a registered name.
- Seven of the ten rows in the plugin compatibility matrix are reasoned rather
than tested; the three named in the ship criteria were installed and run.

204
README.md
View file

@ -1,204 +0,0 @@
# 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
wordpress/ WordPress itself, vendored as a subtree, patched
patches/ a generated record of the core diff — one file
docker/ compose stack: MariaDB, PHP-FPM, nginx
tools/ build, upstream update, release
tests/ 138 unit checks plus a live end-to-end suite
docs/
```
### Where WordPress itself is
In `wordpress/`, as a git subtree, already patched. That is what gets built
and what gets shipped — there is no download step and no checksum to trust at
build time.
Upstream releases arrive through `git subtree merge` against a branch of
pristine imports, which three-way merges them against the fork's own commit
on top. `tools/update-wordpress.sh <version>` does the whole thing;
`patches/` keeps a generated, readable record of the 75 lines that differ
from stock WordPress, so nobody has to read a 3,800-file log to find out what
this fork changes in core.
The trade is repository size — WordPress is about 149 MB. Worth it for a fork
that can absorb a security release in a minute.
[docs/upstream-merges.md](docs/upstream-merges.md) has the procedure.
---
## 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 # 138 checks, no framework, about a second
node tests/live.mjs # 40 more, against a running instance
```
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 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.
### What has actually been run
On WordPress 7.1.1, against a live instance:
- The patched setup wizard asks for a wallet and installs with or without one.
- Sign-in, registration, replay refusal, wrong-key refusal, altered-text
refusal and purpose separation — 40 checks, all through real HTTP with real
session cookies.
- Yoast SEO 28.6, Contact Form 7 6.1.7 and WooCommerce 11.1.1 install,
activate, and leave every Sirius Press screen rendering cleanly.
- Publishing: 29 files signed by the server and accepted by a gateway running
the real verification logic, read back intact, with a wrong key refused 403.
- Signing in **in an actual browser**, which found two bugs the HTTP suites
structurally could not — see docs/testing.md.
- Taking WordPress **7.1.2** through `tools/update-wordpress.sh`: imported,
three-way merged into the subtree, clean, tests still green.
- **`install.sh` on a real Ubuntu 24.04 VPS**, start to finish: Docker stack
built, MariaDB healthy, the patched wizard served, plugins activated, and
the 40-check auth suite run against it over the public internet.
Not yet run: an upload to the real gateway under a registered name — the
signing is verified, the byte has not been written.
[docs/testing.md](docs/testing.md) says how to do it and what it proves.
---
## Licence
GPL-2.0-or-later, inherited from WordPress.

View file

@ -1,37 +0,0 @@
# 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=
# WordPress itself is vendored in the repository (wordpress/) and baked into
# the image, so there is no version or checksum to configure here. See
# docs/upstream-merges.md for how a new upstream release gets in.

View file

@ -1,77 +0,0 @@
# Sirius Press — PHP-FPM image with the patched WordPress baked in.
#
# Core comes from this repository's `wordpress/` subtree, not from a download
# and not from the official `wordpress` image. That image ships whatever
# version it was tagged with, and the fork's core patch is pinned to an exact
# one; applying a fork's patch to a different core is how a setup wizard ends
# up half rewritten. Copying the vendored tree makes the image contain exactly
# what `git log wordpress/` describes, with nothing fetched at build time.
#
# Core is staged at /opt/sirius-press/core, not at 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
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
ca-certificates curl \
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, from the subtree --------------------------------------------------
COPY wordpress/ /opt/sirius-press/core/
RUN set -eux; \
test -f /opt/sirius-press/core/wp-includes/version.php; \
sed -n "s/.*wp_version = '\\(.*\\)'.*/\\1/p" \
/opt/sirius-press/core/wp-includes/version.php \
> /opt/sirius-press/core/.sirius-core-version; \
echo "vendored WordPress $(cat /opt/sirius-press/core/.sirius-core-version)"
# --- the fork -----------------------------------------------------------------
#
# The patch is already applied in the subtree, so there is nothing to patch
# here. patches/ is carried for auditing, not for building.
WORKDIR /opt/sirius-press/core
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"]

View file

@ -1,93 +0,0 @@
# 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 comes from the
# repository's `wordpress/` subtree, is baked into the image, and is refilled
# into the document root on every start — so `docker compose build && 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
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:-}
# nginx on this compose network. The exporter fetches pages here because
# a container cannot reach the host's own published port.
SIRIUS_PRESS_LOOPBACK_URL: ${SIRIUS_PRESS_LOOPBACK_URL:-http://web}
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:

View file

@ -1,171 +0,0 @@
#!/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
echo "// Where the static exporter fetches this site's own pages."
echo "// A container cannot reach the host's public address on most Docker"
echo "// hosts — the request times out instead of arriving at nginx — so the"
echo "// exporter goes to the web container directly and carries the site's"
echo "// real Host header, which keeps the rendered page identical."
echo "define( 'SIRIUS_PRESS_LOOPBACK_URL', '${SIRIUS_PRESS_LOOPBACK_URL:-http://web}' );"
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 "$@"

View file

@ -1,84 +0,0 @@
# 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;
}
}

View file

@ -1,178 +0,0 @@
# 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.

View file

@ -1,104 +0,0 @@
# 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.

View file

@ -1,169 +0,0 @@
# 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
```
Copies the vendored WordPress out of `wordpress/`, adds the plugins, and
writes `dist/sirius-press-<version>.zip` alongside its checksum. Nothing is
downloaded — core is in the repository.
---
## 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 and the fork's plugins both come from the image, so this upgrades both.
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).

View file

@ -1,107 +0,0 @@
# 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 completely alone, and it is worth saying why, because
the obvious guess is wrong. WordPress validates an address's *syntax*, not
whether its domain could ever exist — so `.invalid` addresses already pass
`is_email()` unchanged. No shim is needed to make the placeholders acceptable
to plugins that validate, and none is shipped. (Verified against WordPress
7.1.1; an earlier draft of this fork carried a filter based on the wrong
assumption, and it never fired.)
The flip side is that `is_email()` will also accept `someone@example.invalid`
typed into a contact form by a visitor. That is stock WordPress behaviour, not
something this fork introduced, and it is the contact form's business to care
about.
---
## The top ten
Three of these were installed and activated on a Sirius Press 0.1.0 instance
running WordPress 7.1.1, with all four fork plugins active, and the whole
wallet sign-in suite re-run with them loaded. Those rows say **tested**. The
rest are reasoned from each plugin's setup and activation path and say
**expected** — treat them as claims awaiting a test, and report anything that
behaves differently.
| Plugin | Status | Notes |
|---|---|---|
| **Yoast SEO** 28.6 | tested — activates cleanly | No errors on any admin screen with it loaded. Reads `admin_email` for schema output and gets the placeholder. Its XML sitemaps export to your name along with everything else. |
| **Contact Form 7** 6.1.7 | tested — activates cleanly | Forms build and save. 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** 11.1.1 | tested — activates cleanly | Store and admin work. Order emails to customers use the address the customer typed, so they send once SMTP is configured. One behaviour worth knowing: WooCommerce redirects subscriber-role accounts away from wp-admin, so a newly registered reader lands on the shop rather than their profile. That is WooCommerce's own setting, not this fork's. |
| **Elementor** | expected to work | No email dependency. Pages built with it export normally. |
| **Wordfence** | expected to work | 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** | expected to work | Compatible, but consider whether you need them: the static export already serves cached HTML from the name, which is the harder-working cache. |
| **Akismet** | expected to work | Needs an API key, obtained on akismet.com with a real address of yours. Nothing to do with site accounts. |
| **Jetpack** | expected to work partly | Connection requires a WordPress.com account. The modules built around subscriber email lists cannot do anything useful here. Not recommended. |
| **UpdraftPlus** | expected to work | 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** | expected to work | No email dependency at all. |
"Activates cleanly" means the plugin activated without a fatal or a WP_Error,
and every Sirius Press admin screen plus Users, Profile and Plugins rendered
with no PHP diagnostic while it was loaded.
---
## 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).

View file

@ -1,219 +0,0 @@
# 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.
---
## When the site cannot reach itself
The exporter renders each page by asking the site for it over HTTP. That
normally just works — and then you put it in a container.
On most Docker hosts a container cannot reach the host's own published port:
the PHP container asking for `http://203.0.113.10:8081/` times out, even
though that is exactly where visitors arrive. Nothing is misconfigured; there
is simply no route back in. The symptom is every export failing with
`cURL error 28 ... Timeout was reached`, while the site itself is plainly up.
The fix is a second address for the exporter to use:
```php
define( 'SIRIUS_PRESS_LOOPBACK_URL', 'http://web' );
```
The bundled Docker stack sets this for you — `web` is the nginx service on the
compose network. Elsewhere, point it at whatever PHP can reach:
`http://127.0.0.1`, `http://localhost:8080`, a socket-backed vhost.
The request still carries the site's real `Host` header, so WordPress renders
exactly the page a visitor would get and does not redirect the exporter to a
canonical URL it cannot follow. The internal address never appears in the
output.
**A related question that trips people up:** what should `WP_HOME` be when the
site's public address is a BCNR name? Not the name. Set it to whatever the
server is actually reachable at — an IP and port is fine. The exported pages
use document-relative links, so they work under the name regardless of what
`WP_HOME` said when they were rendered.
---
## 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 );
```

View file

@ -1,98 +0,0 @@
# 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.

View file

@ -1,183 +0,0 @@
# Testing
Four suites, in rough order of how fast they run and how much they prove.
```bash
tests/run.sh # unit + interop, ~1 second, no services
node tests/live.mjs # end to end against a running instance
```
---
## The fast suites
```bash
tests/run.sh
```
Needs PHP 7.4+ with GMP or BCMath, and Node 18+. About a second, no database
and no server.
| Suite | Checks | What it pins |
|---|---|---|
| `test-crypto.php` | 23 | secp256k1 against published vectors, RFC 6979 determinism, low-S, recovery round trips |
| `test-identity.php` | 44 | CashAddress encode/decode/normalise, BIP-39/32 derivation, both signing envelopes, canonical JSON |
| `test-export.php` | 49 | URL-to-path mapping, document-relative rewriting, gateway path rules |
| `interop.mjs` | 22 | the browser wallet against the PHP one, byte for byte |
**`interop.mjs` is the one that matters most.** Sirius Press implements the
same cryptography twice — PHP verifies, JavaScript signs — and every vector in
that file came from libauth, the library the Sirius portal wallet and the BNS
gateway both use. If the two implementations disagree by a byte, nobody can
log in, and the error looks like a rejected password rather than a hash
mismatch. This suite makes that a test failure instead of a bad evening.
---
## A throwaway instance
You do not need Docker, MySQL or a VPS to run a real Sirius Press. PHP's
built-in server plus the official SQLite drop-in is enough, and it starts in
seconds.
```bash
# 1. a complete tree (copied out of the vendored wordpress/, plugins added)
tools/build.sh
cp -R dist/sirius-press /tmp/wpsite
# 2. SQLite instead of MySQL
curl -fsSL -o /tmp/sqlite.zip \
https://downloads.wordpress.org/plugin/sqlite-database-integration.zip
unzip -q /tmp/sqlite.zip -d /tmp/wpsite/wp-content/plugins/
cp /tmp/wpsite/wp-content/plugins/sqlite-database-integration/db.copy \
/tmp/wpsite/wp-content/db.php
mkdir -p /tmp/wpsite/wp-content/database
# 3. a config (any salts will do for a throwaway)
cp /tmp/wpsite/wp-config-sample.php /tmp/wpsite/wp-config.php
# edit it: DB_* values are ignored by the SQLite drop-in, but add
# define( 'WP_HOME', 'http://127.0.0.1:8760' );
# define( 'WP_SITEURL', 'http://127.0.0.1:8760' );
# 4. run it
php -S 127.0.0.1:8760 -t /tmp/wpsite
```
Then open `http://127.0.0.1:8760/wp-admin/install.php`. The setup screen asks
for a wallet address instead of an email one — that alone confirms the core
patch applied.
PHP needs `pdo_sqlite`, `sqlite3`, `gd`, `mbstring` and one of GMP or BCMath.
**Set pretty permalinks before testing publishing.** With plain permalinks
every post's URL is `/?p=N`, whose path is `/`, so the whole site maps to
`index.html` and each page overwrites the last. The Publishing screen refuses
to let this pass quietly, but a script driving the queue directly will not see
the warning.
---
## End to end
```bash
WALLET=/tmp/admin-wallet.json BASE=http://127.0.0.1:8760 node tests/live.mjs
```
`WALLET` is a JSON file holding an administrator's phrase and address:
```json
{ "phrase": "twelve words …", "address": "bchtest:qq…" }
```
40 checks covering sign-in, replay refusal, a stranger's signature, a
signature over altered text, purpose separation, registration, the recovery
page and the REST endpoints — against a real WordPress, with real sessions.
It creates one account per run, so point it at a throwaway.
### Running it with the ecosystem loaded
The suite is worth far more with third-party plugins active, because it then
also proves the fork's admin screens survive them:
```bash
for p in wordpress-seo contact-form-7 woocommerce; do
curl -fsSL -o "/tmp/$p.zip" "https://downloads.wordpress.org/plugin/$p.zip"
unzip -q -o "/tmp/$p.zip" -d /tmp/wpsite/wp-content/plugins/
done
# activate them, then re-run tests/live.mjs
```
If you hit a 429, the rate limiter is doing its job — it caps signature
verification per client. Clear it between runs:
```sql
DELETE FROM wp_options WHERE option_name LIKE '%_transient_%sirius_rl_%';
```
---
## Publishing, without a registered name
The upload path cannot be tested end to end without a BCNR name and the key
that owns it. What *can* be tested is the part that actually breaks — the
bytes being signed — against the real verifier:
```bash
node tests/mock-gateway.mjs --owner bchtest:qq… --port 8799
```
This implements `PUT /api/site/<name>/<path>` with the signature check
transcribed from `Argus/src/gateway/public-gateway.mjs`: same envelope, same
digest, same recovery, same comparison against the owner. A request it accepts
is one the real gateway accepts.
Point the site at it — **Sirius Press → Settings**, gateway
`http://127.0.0.1:8799` — store the matching phrase, publish a post, and watch
the queue drain. Signing with any other key gets the same 403 the real gateway
returns.
It needs `@bitauth/libauth` resolvable from the script, which is the point: it
verifies with the same library the gateway does, not with ours.
---
## In a browser
Everything above drives the site over HTTP, which is fast and repeatable and
misses a whole class of bug. Two got through it and were only found by opening
`wp-login.php` in a browser and clicking the button:
- WordPress marks its username and password inputs `required`. The wallet path
leaves both empty on purpose, so `requestSubmit()` failed constraint
validation and refused to send a perfectly good signature — with a bubble
pointing at a field the visitor is not meant to fill in.
- The `hidden` attribute does not survive WordPress's own stylesheet.
`.wp-core-ui .button { display: inline-block }` outranks the UA rule that
makes `hidden` work, so buttons this plugin ships hidden were on screen
anyway, inverting the progressive-enhancement design.
So: **after changing anything in `plugins/sirius-press-auth/assets/`, open the
login page and click the button.** The HTTP suites will not tell you.
Serve the throwaway instance and point a browser at
`http://127.0.0.1:8760/wp-login.php`. `127.0.0.1` counts as a secure context,
so WebCrypto works over plain HTTP there — on any other host the in-page
wallet needs real HTTPS, and the script says so and falls back to the paste
box.
Worth checking by hand, in this order: the sign-in button appears, the
browser-wallet button does *not* (unless you are in Theseus), typing the
phrase and clicking signs and lands you on the dashboard, and the challenge
text in the box matches what your wallet shows you.
---
## What is not covered
- **A real gateway upload.** The signing is verified against the transcribed
verifier above, and against the real gateway's answer about who owns a name,
but no byte has yet been written to `navigate.st` under a registered name.
- **Manual-mode publishing in a browser.** The server-signing path is
exercised end to end; the browser-signs-and-uploads path in
`assets/export.js` has not been clicked through.

View file

@ -1,156 +0,0 @@
# 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 lives in this repository, at `wordpress/`, as a **git subtree**. The
fork's own change sits on top of it as an ordinary commit.
Three pieces make that work:
| | |
|---|---|
| `wordpress/` | The vendored tree, patched. This is what gets built and shipped. |
| `sirius-press/wordpress-upstream` | A branch holding **pristine** upstream releases, one commit each, never edited. The other side of the merge. |
| `patches/` | A readable record of what the fork changes in core. Generated, not maintained. |
The entire core diff is **one file, 75 lines**: `wp-admin/install.php`, the
setup wizard. Everything else Sirius Press does is plugins and hooks.
### Why a subtree and not a patch series
Because a three-way merge understands something a patch does not. When
upstream edits lines near the fork's change, `git subtree merge` merges them
and moves on; `patch` either applies with fuzz and hopes, or fails and hands
you the job of re-deriving the change by hand. A conflict from the merge is
resolved once, in the file, and stays resolved — the next release merges
against the resolution.
The cost is repository size: WordPress is about 149 MB and 3,800 files. That
is the price of a fork that can take a security release in a minute, and it is
the right trade for a project whose whole argument is that it should outlive
its maintainers' attention.
---
## Taking a new release
```bash
tools/update-wordpress.sh 7.1.2
```
It downloads the release, checks it against the SHA-1 wordpress.org publishes,
imports the pristine tree onto `sirius-press/wordpress-upstream`, and merges
that branch into `wordpress/`. Then it updates `tools/wordpress.lock` and
regenerates `patches/`.
Two outcomes.
**Clean.** The merge went through. Run the tests, build, commit. Usually a
minute of work.
**Conflicted.** Upstream changed the same lines the fork changes — in
practice, `wp-admin/install.php`. Git leaves the conflict in the file:
```bash
git status
$EDITOR wordpress/wp-admin/install.php
git add wordpress/wp-admin/install.php
git commit
tools/refresh-patches.sh
```
Then set `WP_VERSION`, `WP_URL`, `WP_SHA256` and `WP_SHA1` in
`tools/wordpress.lock` by hand, since the script stopped before it got there.
Either way the fork is **not broken while you work**: the merge is in your
working tree, and until you commit it, `wordpress/` still holds the last
version that worked.
---
## After any bump
```bash
tests/run.sh
tools/build.sh
```
The suite does not test core, but it tests every assumption the fork makes
about it. Then walk through `wp-admin/install.php` once in a browser — 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.
[testing.md](testing.md) has a recipe for a throwaway instance that needs no
database server.
---
## Keeping `patches/` honest
`patches/` is documentation. It answers the question anyone auditing this fork
asks first — *what exactly did you change inside WordPress?* — in a minute,
which `git log wordpress/` cannot, because that log is mostly upstream
imports.
It is generated from the tree, never edited:
```bash
tools/refresh-patches.sh # rewrite it
tools/refresh-patches.sh --check # fail if it has drifted (for CI)
```
Generated documentation stays true. A hand-maintained record of a fork's core
diff drifts, and a stale one is worse than none, because people trust it.
---
## Changing core yourself
Edit the file under `wordpress/` and commit it like any other change. The next
upstream merge will three-way it.
Before you do: check whether a plugin hook can carry the change instead. Every
line added to `wordpress/` is a line that can conflict with upstream forever,
and the reason this fork's merges are cheap is that there are only 75 of them.
The registration form, the password-reset page and the mail pipeline were all
replaced from plugins precisely so they would never appear in this directory.
---
## Where this repository lives
`code.silentmode.st/silentmode/sirius-press` is a **mirror**. Sirius Press is
developed inside the Silent Mode monorepo and split out with
`scripts/push-split.sh`, which force-pushes `master` each time. History on the
forge is real — file blame points at the actual commits — but it is rewritten
on every push, so a pull request against it will not survive.
What that means in practice:
- **Silent Mode takes upstream releases in the monorepo**, where the subtree's
merge history is intact, and the result is mirrored here.
- **A downstream fork** that clones from the forge and wants to bump WordPress
itself should treat its clone as the source of truth from that point on and
stop pulling the mirror, because the next mirror push will not merge with
its work.
Patches and bug reports are welcome as issues; code is easiest to take as a
diff rather than as a branch.
---
## If the upstream branch is missing
A clone made with `--single-branch` will not have
`sirius-press/wordpress-upstream`, and `tools/update-wordpress.sh` will say so
rather than guessing. Fetch it:
```bash
git fetch origin sirius-press/wordpress-upstream:sirius-press/wordpress-upstream
```
Building and running do not need it — only taking a new upstream release does.

View file

@ -1,157 +0,0 @@
#!/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"
# No WordPress version or checksum here: core is vendored in the
# repository and baked into the image, so there is nothing to pin at
# deploy time.
{
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)"
} > .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

View file

@ -1,131 +0,0 @@
<?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'
);
}
}

View file

@ -1,82 +0,0 @@
Subject: [PATCH] wp-admin/install.php
The fork's change to this file, regenerated by tools/refresh-patches.sh.
It is a record, not the mechanism: core is vendored under wordpress/ and
this diff is what distinguishes it from pristine upstream.
Applies to: WordPress 7.1.2
--- a/wp-admin/install.php
+++ b/wp-admin/install.php
@@ -101,6 +101,8 @@ function display_setup_form( $error = null ) {
$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 @@ function display_setup_form( $error = null ) {
</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 &mdash; 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 @@ switch ( $step ) {
$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 @@ switch ( $step ) {
// 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>

File diff suppressed because one or more lines are too long

View file

@ -1,185 +0,0 @@
/*
* 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.
*/
/*
* Make the `hidden` attribute actually hide things.
*
* `hidden` works through a UA rule `[hidden] { display: none }` which any
* author rule carrying a `display` outranks. WordPress ships exactly such a
* rule: `.wp-core-ui .button { display: inline-block }` in buttons.css, which
* matches every button in this block.
*
* Without this the whole progressive-enhancement story silently inverts. The
* markup ships with the sign-in button, the browser-wallet button and the
* phrase field hidden, and the script reveals only the ones that can work.
* Defeat `hidden` and a visitor with JavaScript off is offered a button that
* does nothing, and everyone is offered a browser wallet that is not there.
*/
.sirius-wallet [hidden] {
display: none !important;
}
.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-wallet__address-input,
#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;
}
}
.sirius-wallet__address-label {
display: block;
font-weight: 600;
margin: 10px 0 4px;
font-size: 13px;
}

View file

@ -1,211 +0,0 @@
// 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;
// WordPress marks its username and password inputs `required`, and the
// wallet path deliberately leaves both empty — the signature is the
// credential. Constraint validation would otherwise refuse the submit and
// point a "Please fill out this field" bubble at an input the visitor is
// not supposed to touch, with the signature already sitting in the form.
//
// Only reached from the wallet buttons, so password sign-in keeps its
// validation.
form.noValidate = true;
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.submit();
}
}
function setSignature(block, signature, address) {
const field = block.querySelector(".sirius-wallet__signature");
if (field) field.value = signature;
// The address the signature claims. The server compares it against the
// one it recovers, which is the only way a signature over the wrong text
// becomes an error instead of a different identity.
const claim = block.querySelector(".sirius-wallet__address");
if (claim && address) claim.value = address;
}
/** On the paste path the visitor supplies the address themselves. */
function adoptTypedAddress(block) {
const typed = block.querySelector(".sirius-wallet__address-input");
const claim = block.querySelector(".sirius-wallet__address");
if (typed && claim && typed.value.trim() && !claim.value) {
claim.value = typed.value.trim();
}
}
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), wallet.address);
} 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), await external.address());
} 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) {
// Whatever happens next, a hand-typed address must reach the server.
const form = block.closest("form");
if (form) form.addEventListener("submit", () => adoptTypedAddress(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();
}
})();

View file

@ -1,511 +0,0 @@
// 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 },
};
})();

View file

@ -1,294 +0,0 @@
<?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
* recovers the signer from the rebuilt copy.
*
* **Recovery is not verification.** This is the subtle part, and getting it
* wrong is how a wallet-auth system quietly breaks. Public-key recovery
* always succeeds: given any well-formed signature and any digest, it returns
* *a* key just not the signer's, unless the digest is the one that was
* actually signed. So a signature over the wrong text does not produce an
* error, it produces a stranger's address.
*
* At sign-in that is harmless: the wrong address matches no account and the
* attempt fails. Anywhere the outcome *binds* an address to an account
* registration, attaching a wallet it is not harmless at all, because the
* account would be bound to an address nobody can sign for, and the person
* would only discover it the next time they tried to sign in.
*
* So those callers pass the address the signer claims, and verification means
* "the recovered address is that one". A mismatch is then a clear error
* instead of a broken account, and a login signature genuinely cannot be
* replayed to register: it recovers to a different address than the one the
* request claims.
*
* @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
* @param string $claimed The address the caller says signed. Required by
* any caller that will bind the result to an
* account; see the note on recovery above. When
* given, a recovered address that differs is an
* error rather than a new identity.
* @return string|WP_Error Normalised CashAddress.
*/
public static function verify( $nonce, $signature, $purpose = self::PURPOSE_LOGIN, $claimed = '' ) {
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' ) );
}
if ( '' !== $claimed ) {
$want = SP_CashAddr::normalize( $claimed );
if ( '' === $want ) {
return new WP_Error( 'sirius_bad_address', __( 'That is not a valid Bitcoin Cash address.', 'sirius-press' ) );
}
if ( ! hash_equals( $want, $address ) ) {
// The signature is well formed but over different bytes than
// this site asked for — a copy that lost a line, a wallet that
// rewrote the text, or a signature meant for something else.
return new WP_Error(
'sirius_address_mismatch',
__( 'That signature does not match the text this site asked you to sign. Copy the text again exactly as shown, including the blank lines, and sign it once more.', '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;
}
}

View file

@ -1,316 +0,0 @@
<?php
/**
* Signing in.
*
* The whole substitution happens in one filter. WordPress asks its
* `authenticate` chain "who is this?", and this plugin answers first: if the
* request carries a wallet signature over a challenge this site issued, the
* signer's address identifies the account, and a `WP_User` comes back. Core
* then sets its ordinary auth cookie, and from that point on every
* capability, role check, nonce and REST permission works exactly as it does
* on stock WordPress. Nothing downstream knows the login was different.
*
* The form degrades honestly. With JavaScript, a button signs the challenge
* in the page or hands it to the browser's own wallet. Without it, the
* challenge is printed in a box next to a field for the signature which is
* precisely the "Sign message" workflow every desktop BCH wallet already has.
* That path is not a courtesy to the JavaScript-averse; it is the path for
* someone whose keys live on a machine that never touches this site.
*
* @package SiriusPress
*/
defined( 'ABSPATH' ) || exit;
final class SPA_Login {
const OPT_ALLOW_PASSWORDS = 'sirius_press_allow_password_login';
public static function hooks() {
add_filter( 'authenticate', array( __CLASS__, 'authenticate' ), 5, 3 );
add_action( 'login_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
add_action( 'login_form', array( __CLASS__, 'render_form' ) );
add_filter( 'login_message', array( __CLASS__, 'login_message' ) );
add_action( 'login_footer', array( __CLASS__, 'footer_config' ) );
if ( ! self::passwords_allowed() ) {
// Pull core's password checks out of the chain entirely rather
// than letting them run and fail: a site that has turned
// passwords off should not have a password oracle on its login
// page at all.
remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );
remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );
add_action( 'login_head', array( __CLASS__, 'hide_password_fields' ) );
}
}
/** Whether username+password sign-in is still accepted. */
public static function passwords_allowed() {
return (bool) get_option( self::OPT_ALLOW_PASSWORDS, true );
}
/**
* The substitution itself.
*
* @param null|WP_User|WP_Error $user
* @param string $username
* @param string $password
* @return null|WP_User|WP_Error
*/
public static function authenticate( $user, $username, $password ) {
if ( $user instanceof WP_User ) {
return $user;
}
// Nonce checking is not applicable here: the signed challenge *is* the
// anti-forgery token, and it is stronger than one — it is bound to a
// key, single-use, and expires.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( empty( $_POST['sirius_signature'] ) || empty( $_POST['sirius_nonce'] ) ) {
return $user;
}
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) );
// Optional at sign-in: the recovered address is looked up against real
// accounts, so a wrong one simply finds nothing. When the page does
// send it, checking it turns "no account uses that wallet" into the
// more useful "that signature is not over the text we asked for".
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
$limited = SPA_Challenge::check_rate_limit( 'login' );
if ( is_wp_error( $limited ) ) {
return $limited;
}
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LOGIN, $claimed );
if ( is_wp_error( $address ) ) {
return $address;
}
$found = SP_Identity::user_by_address( $address );
if ( ! $found ) {
if ( SP_Settings::open_registration() ) {
return new WP_Error(
'sirius_no_account',
sprintf(
/* translators: %s: URL of the registration page. */
__( 'No account here uses that wallet yet. <a href="%s">Create one</a> — it takes one more signature.', 'sirius-press' ),
esc_url( SPA_Register::url() )
)
);
}
return new WP_Error(
'sirius_no_account',
__( 'No account on this site uses that wallet, and registration is closed.', 'sirius-press' )
);
}
/**
* Fires after a wallet signature has been accepted for a user.
*
* A plugin that wants a second factor can return a WP_Error from the
* `authenticate` chain at a later priority; this is the hook that
* tells it a wallet proof already succeeded.
*
* @param WP_User $found
* @param string $address
*/
do_action( 'sirius_press_wallet_authenticated', $found, $address );
return $found;
}
// ------------------------------------------------------------------- UI
public static function enqueue() {
self::enqueue_wallet();
wp_enqueue_style(
'sirius-press-login',
SIRIUS_PRESS_AUTH_URL . 'assets/login.css',
array(),
SIRIUS_PRESS_AUTH_VERSION
);
}
/** Shared by the login screen, the registration screen and the profile. */
public static function enqueue_wallet() {
wp_enqueue_script(
'sirius-press-bip39',
SIRIUS_PRESS_AUTH_URL . 'assets/bip39-en.js',
array(),
SIRIUS_PRESS_AUTH_VERSION,
true
);
wp_enqueue_script(
'sirius-press-wallet',
SIRIUS_PRESS_AUTH_URL . 'assets/wallet.js',
array( 'sirius-press-bip39' ),
SIRIUS_PRESS_AUTH_VERSION,
true
);
wp_enqueue_script(
'sirius-press-login-js',
SIRIUS_PRESS_AUTH_URL . 'assets/login.js',
array( 'sirius-press-wallet' ),
SIRIUS_PRESS_AUTH_VERSION,
true
);
}
/** Configuration the scripts need, printed once. */
public static function footer_config() {
self::print_config();
}
public static function print_config() {
static $printed = false;
if ( $printed ) {
return;
}
$printed = true;
printf(
'<script>window.SIRIUS_PRESS = %s;</script>',
wp_json_encode(
array(
'prefix' => SP_Settings::prefix(),
'path' => SP_Settings::derivation_path(),
'network' => SP_Settings::network(),
'site' => home_url( '/' ),
'strings' => array(
'signing' => __( 'Signing…', 'sirius-press' ),
'noWallet' => __( 'No wallet found in this browser.', 'sirius-press' ),
'generated' => __( 'Write these words down. They are the only way back into this account — nobody, including this site, can reset them for you.', 'sirius-press' ),
),
)
)
);
}
/**
* The wallet block on wp-login.php.
*
* Rendered by `login_form`, which puts it directly under the password
* field, so the wallet path reads as the primary one and passwords as the
* leftover they are.
*/
public static function render_form() {
$nonce = SPA_Challenge::issue();
$message = SPA_Challenge::message( $nonce, SPA_Challenge::PURPOSE_LOGIN );
self::render_signing_block( $nonce, $message, SPA_Challenge::PURPOSE_LOGIN, __( 'Sign in with your wallet', 'sirius-press' ) );
}
/**
* The signing widget, shared by login and registration.
*
* @param string $nonce
* @param string $message Exact text to be signed.
* @param string $purpose
* @param string $button Label for the primary action.
*/
public static function render_signing_block( $nonce, $message, $purpose, $button ) {
?>
<div class="sirius-wallet" data-sirius-purpose="<?php echo esc_attr( $purpose ); ?>">
<input type="hidden" name="sirius_nonce" value="<?php echo esc_attr( $nonce ); ?>" />
<input type="hidden" name="sirius_purpose" value="<?php echo esc_attr( $purpose ); ?>" />
<?php
/*
* The address the signer claims. Filled in by the script after
* signing; typed by hand on the paste-a-signature path.
*
* Not decoration: recovering a key from a signature always
* succeeds, so without something to compare against, a signature
* over the wrong text yields a stranger's address instead of an
* error. See the note in class-spa-challenge.php.
*/
?>
<input type="hidden" name="sirius_address" class="sirius-wallet__address" value="" />
<textarea
class="sirius-wallet__message"
readonly
rows="8"
aria-label="<?php esc_attr_e( 'The exact text to sign', 'sirius-press' ); ?>"
><?php echo esc_textarea( $message ); ?></textarea>
<div class="sirius-wallet__actions">
<button type="button" class="button button-primary sirius-wallet__sign" hidden>
<?php echo esc_html( $button ); ?>
</button>
<button type="button" class="button sirius-wallet__external" hidden>
<?php esc_html_e( 'Use the browser wallet', 'sirius-press' ); ?>
</button>
</div>
<div class="sirius-wallet__phrase" hidden>
<label for="sirius_phrase_<?php echo esc_attr( $purpose ); ?>">
<?php esc_html_e( 'Recovery phrase', 'sirius-press' ); ?>
</label>
<textarea
id="sirius_phrase_<?php echo esc_attr( $purpose ); ?>"
class="sirius-wallet__phrase-input"
rows="2"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
></textarea>
<p class="sirius-wallet__hint">
<?php esc_html_e( 'Typed here, the phrase never leaves this page — only the signature is sent. If you would rather not type it, sign the text above in your own wallet and paste the result below.', 'sirius-press' ); ?>
</p>
</div>
<details class="sirius-wallet__manual">
<summary><?php esc_html_e( 'Paste a signature instead', 'sirius-press' ); ?></summary>
<p class="sirius-wallet__hint">
<?php esc_html_e( 'Sign the text above in any Bitcoin Cash wallet — Electron Cash calls it “Sign message” — and paste what it gives you.', 'sirius-press' ); ?>
</p>
<textarea
name="sirius_signature"
class="sirius-wallet__signature"
rows="3"
autocomplete="off"
spellcheck="false"
placeholder="<?php esc_attr_e( 'base64 signature', 'sirius-press' ); ?>"
></textarea>
<label class="sirius-wallet__address-label" for="sirius_address_<?php echo esc_attr( $purpose ); ?>">
<?php esc_html_e( 'The address you signed with', 'sirius-press' ); ?>
</label>
<input
type="text"
id="sirius_address_<?php echo esc_attr( $purpose ); ?>"
class="sirius-wallet__address-input"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
placeholder="<?php echo esc_attr( SP_Settings::prefix() ); ?>:…"
/>
<p class="sirius-wallet__hint">
<?php esc_html_e( 'Your wallet shows this next to the signature. It is checked against the signature, so a mistyped address is caught rather than quietly creating the wrong account.', 'sirius-press' ); ?>
</p>
</details>
<p class="sirius-wallet__status" role="status" aria-live="polite"></p>
</div>
<?php
}
public static function login_message( $message ) {
if ( ! empty( $_GET['action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $message;
}
$intro = '<p class="message sirius-intro">'
. esc_html__( 'This site has no passwords to forget and no email to confirm. Your wallet is the account.', 'sirius-press' )
. '</p>';
return $intro . $message;
}
/**
* Visually retire the password field when passwords are off.
*
* The submit button stays. It is what the paste-a-signature path the one
* for people whose keys are on another machine uses to send the form,
* and hiding it would leave that path with no way to submit at all.
*/
public static function hide_password_fields() {
echo '<style>'
. '#user_pass, label[for="user_pass"], .user-pass-wrap, .forgetmenot + p { display:none !important; }'
. '</style>';
}
}

View file

@ -1,173 +0,0 @@
<?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'] ) );
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( get_current_user_id() !== (int) $user_id ) {
return; // Only the account holder attaches their own key here.
}
/*
* This is the most damaging place to get it wrong. Recovery always
* yields some address, so a signature over slightly different text
* would move the account to an address nobody holds locking the
* person out of their own site at the next sign-in. Require the claim
* and compare.
*/
if ( '' === $claimed ) {
SP_Inbox::add(
$user_id,
__( 'Wallet not attached', 'sirius-press' ),
esc_html__( 'The address you signed with was not sent, so the signature could not be checked against it. Nothing was changed.', 'sirius-press' ),
'auth'
);
return;
}
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LINK, $claimed );
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'
);
}
}

View file

@ -1,104 +0,0 @@
<?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;
}
}

View file

@ -1,178 +0,0 @@
<?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 ) : '';
$claimed = isset( $_POST['sirius_address'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_address'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' === $signature || '' === $nonce ) {
return new WP_Error( 'sirius_missing', __( 'Sign the text above before submitting.', 'sirius-press' ) );
}
/*
* The claimed address is required here, unlike at sign-in. Recovering
* a key from a signature always succeeds, so without something to
* compare against, a signature over the wrong text would create an
* account bound to an address the registrant cannot sign for and
* they would only find out the next time they tried to get in.
*/
if ( '' === $claimed ) {
return new WP_Error(
'sirius_missing_address',
__( 'Tell us which address you signed with, so the signature can be checked against it.', 'sirius-press' )
);
}
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_REGISTER, $claimed );
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();
}
}

View file

@ -1,260 +0,0 @@
<?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;
}
// Required, for the reason set out in class-spa-challenge.php: recovery
// alone would happily mint an account for an address the caller cannot
// sign with.
$claimed = (string) $request->get_param( 'address' );
if ( '' === $claimed ) {
return new WP_Error(
'sirius_missing_address',
__( 'Send the address you signed with, so the signature can be checked against it.', 'sirius-press' ),
array( 'status' => 400 )
);
}
$address = SPA_Challenge::verify(
(string) $request->get_param( 'nonce' ),
(string) $request->get_param( 'signature' ),
SPA_Challenge::PURPOSE_REGISTER,
$claimed
);
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;
}
}

View file

@ -1,143 +0,0 @@
<?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,
)
);
}
}

View file

@ -1,75 +0,0 @@
<?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 ),
);
}

View file

@ -1,178 +0,0 @@
<?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
);
/*
* Note on is_email(): no filter is needed.
*
* It would be reasonable to assume WordPress rejects a `.invalid` address,
* since RFC 2606 reserves that TLD precisely so it can never resolve and an
* earlier version of this file carried a filter to force such addresses
* through on that assumption. The assumption is wrong. `is_email()` validates
* syntax, not whether a domain could ever exist, so `noreply@…​.invalid`
* already passes and the filter never fired.
*
* Leaving it in would have been worse than useless: a filter that appears to
* relax a validation rule, but does not, is exactly the kind of thing someone
* later reasons from. Verified against WordPress 7.1.1.
*/
/**
* 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 ] )
);
}
);

View file

@ -1,325 +0,0 @@
<?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 );
}
}

View file

@ -1,182 +0,0 @@
<?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' );
}
}

View file

@ -1,269 +0,0 @@
<?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;
}
}

View file

@ -1,265 +0,0 @@
<?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;
}
}

View file

@ -1,179 +0,0 @@
<?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 '';
}
}

View file

@ -1,282 +0,0 @@
<?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()
);
}
}
}

View file

@ -1,298 +0,0 @@
<?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 );
}
}

View file

@ -1,175 +0,0 @@
<?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;
}
}

View file

@ -1,375 +0,0 @@
<?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 );
}
}
}

View file

@ -1,237 +0,0 @@
<?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 OPT_LOOPBACK = 'sirius_press_loopback';
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';
}
/**
* Where the exporter should fetch this site's own pages from.
*
* Normally empty, meaning "the site's own URL". It exists because a
* container cannot always reach the address the outside world uses: on a
* Docker host without NAT hairpinning, the PHP container asking for
* `http://203.0.113.10:8081/` simply times out, even though that is
* exactly where visitors arrive.
*
* Set this to something reachable from where PHP runs in the bundled
* stack, `http://web`, the nginx service on the compose network. The
* request still carries the site's real Host header, so WordPress renders
* the same page it would for a visitor and does not bounce the exporter
* to its canonical URL.
*
* @return string '' to fetch the public URL directly.
*/
public static function loopback_base() {
$configured = trim( (string) get_option( self::OPT_LOOPBACK, '' ) );
if ( '' === $configured && defined( 'SIRIUS_PRESS_LOOPBACK_URL' ) ) {
$configured = trim( (string) SIRIUS_PRESS_LOOPBACK_URL );
}
return '' === $configured ? '' : untrailingslashit( $configured );
}
/** 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;
}
}

View file

@ -1,145 +0,0 @@
<?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 () {
if ( (int) get_option( 'sirius_press_inbox_version', 0 ) < SP_Inbox::VERSION ) {
SP_Inbox::install();
}
SP_Identity::hooks();
SP_Inbox::hooks();
SP_Admin::hooks();
}
);
/*
* Translations load on `init`, not `plugins_loaded`. WordPress 6.7 started
* warning about the earlier hook because the locale is not settled yet, and a
* notice on every request is the kind of thing that trains people to ignore
* their logs.
*/
add_action(
'init',
function () {
load_plugin_textdomain( 'sirius-press', false, dirname( plugin_basename( SIRIUS_PRESS_CORE_FILE ) ) . '/languages' );
}
);
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' )
);
}
}
);

View file

@ -1,190 +0,0 @@
// 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();
}
})();

View file

@ -1,327 +0,0 @@
<?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;
}
/*
* Plain permalinks are fatal to a static export and the failure is
* silent: every post's URL is `/?p=N`, whose path is `/`, so every
* page in the site maps to index.html and each one overwrites the
* last. The queue looks healthy the whole time.
*/
if ( '' === (string) get_option( 'permalink_structure' ) ) {
printf(
'<div class="notice notice-error"><p><strong>%s</strong> %s <a href="%s">%s</a></p></div>',
esc_html__( 'This site uses plain permalinks.', 'sirius-press' ),
esc_html__( 'Every page would be exported to the same file and overwrite the one before it, so nothing useful can be published until that is changed.', 'sirius-press' ),
esc_url( admin_url( 'options-permalink.php' ) ),
esc_html__( 'Choose a permalink structure', 'sirius-press' )
);
}
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 &nbsp; <strong>%3$d</strong> %4$s &nbsp; <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>';
}
}

View file

@ -1,144 +0,0 @@
<?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' );

View file

@ -1,156 +0,0 @@
<?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'
);
}
}
}

View file

@ -1,221 +0,0 @@
<?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 );
}
}

View file

@ -1,217 +0,0 @@
<?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' ) );
}
}

View file

@ -1,261 +0,0 @@
<?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 );
$headers = array(
'user-agent' => 'SiriusPress/' . SIRIUS_PRESS_EXPORT_VERSION . ' (static export)',
'accept-encoding' => 'identity',
);
/*
* Send the request somewhere PHP can actually reach, while still
* claiming the site's real hostname.
*
* Needed whenever the address visitors use is not routable from where
* PHP runs the normal case in Docker, where a container asking for
* the host's own public IP gets a timeout rather than its own nginx.
* The Host header is what keeps this honest: WordPress builds the
* page exactly as it would for a visitor, and does not redirect the
* exporter to a canonical URL it cannot follow.
*/
$loopback = SP_Settings::loopback_base();
if ( '' !== $loopback ) {
$parts = wp_parse_url( $request_url );
if ( ! empty( $parts['host'] ) ) {
$headers['host'] = $parts['host'] . ( empty( $parts['port'] ) ? '' : ':' . $parts['port'] );
$request_url = $loopback
. ( isset( $parts['path'] ) ? $parts['path'] : '/' )
. ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
}
}
$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' => $headers,
)
);
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]*(&amp;|&)?/', '$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.
//
// Two details, both of which produced mangled links when they were
// missing. The lookbehind stops this from also matching the
// `//example.test` sitting inside every `https://example.test`, which
// would give every absolute URL on the page a second scheme. And the
// authority has to include the port: replacing `//host` alone inside
// `//host:8760/x` leaves the port behind, producing `//host:8760:8760/x`.
foreach ( array_unique( array_filter( array( $home, $site ) ) ) as $base ) {
$authority = preg_replace( '#^https?:#', '', $base ); // '//host[:port]'
if ( '' === $authority ) {
continue;
}
$html = preg_replace(
'#(?<!:)' . preg_quote( $authority, '#' ) . '#',
$base,
$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
);
}
}

View file

@ -1,209 +0,0 @@
<?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;
}
}

View file

@ -1,73 +0,0 @@
<?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();
}
);

View file

@ -1,165 +0,0 @@
<?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' ) ) {
/**
* The site's URL.
*
* Overridable through $GLOBALS['sirius_test_home'] so a test can exercise
* an installation whose URL carries a port, which is where the URL
* rewriting has historically gone wrong.
*/
function home_url( $path = '' ) {
$base = isset( $GLOBALS['sirius_test_home'] ) ? $GLOBALS['sirius_test_home'] : 'https://example.test';
return $base . ( '' !== $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';

View file

@ -1,174 +0,0 @@
// 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`);

View file

@ -1,416 +0,0 @@
// End-to-end checks against a running Sirius Press instance.
//
// The other suites prove the cryptography in isolation. This one proves the
// thing that actually matters: that a signature made in a browser gets a real
// WordPress session out of a real WordPress, and that the failure paths fail.
//
// node tests/live.mjs
//
// Expects:
// BASE the site's URL (default http://127.0.0.1:8760)
// WALLET a JSON file holding {"phrase": "...", "address": "bchtest:..."}
// for an existing administrator on that site
//
// docs/testing.md has the recipe for standing up a throwaway instance with
// SQLite and PHP's built-in server — no database server, no Docker.
//
// Nothing here is destructive except that it creates one account per run, in
// a site you were already willing to point a test at.
import { readFileSync } from "node:fs";
globalThis.window = globalThis;
const ASSETS = new URL("../plugins/sirius-press-auth/assets/", import.meta.url);
new Function(readFileSync(new URL("bip39-en.js", ASSETS), "utf8"))();
new Function(readFileSync(new URL("wallet.js", ASSETS), "utf8"))();
const BASE = (process.env.BASE || "http://127.0.0.1:8760").replace(/\/$/, "");
const WALLET = process.env.WALLET;
if (!WALLET) {
console.error("set WALLET to a JSON file with the administrator's phrase and address");
process.exit(2);
}
const admin = JSON.parse(readFileSync(WALLET, "utf8"));
const W = window.SiriusWallet;
/**
* Where the REST API lives.
*
* A site without pretty permalinks serves it at ?rest_route= rather than
* /wp-json/, and a fresh install has exactly that. Probing both is the
* difference between testing the API and testing the permalink setting.
*/
const restBase = await (async () => {
const pretty = await fetch(`${BASE}/wp-json/`).catch(() => null);
if (pretty && pretty.ok && (pretty.headers.get("content-type") || "").includes("json")) {
return (path) => `${BASE}/wp-json${path}`;
}
return (path) => `${BASE}/index.php?rest_route=${encodeURIComponent(path)}`;
})();
const restUrl = (path, query = "") => {
const url = restBase(path);
return query ? url + (url.includes("?") ? "&" : "?") + query : url;
};
let pass = 0;
let fail = 0;
function check(condition, what, extra = "") {
if (condition) {
pass++;
console.log(` ok ${what}`);
} else {
fail++;
console.log(` FAIL ${what}`);
if (extra) console.log(` ${extra}`);
}
}
function group(name) {
console.log(`\n ${name}`);
}
const decode = (s) =>
s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&#8212;/g, "—")
.replace(/&#8217;/g, "");
/** A cookie jar and a fetch that uses it — one browser, in effect. */
function Session() {
const jar = new Map([["wordpress_test_cookie", "WP%20Cookie%20check"]]);
return {
loggedIn: () => [...jar.keys()].some((k) => k.startsWith("wordpress_logged_in_")),
async go(path, options = {}) {
const res = await fetch(BASE + path, {
redirect: "manual",
...options,
headers: {
cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
...(options.headers || {}),
},
});
for (const raw of res.headers.getSetCookie?.() ?? []) {
const pair = raw.split(";")[0];
const eq = pair.indexOf("=");
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1));
}
return { res, body: await res.text() };
},
};
}
const post = (fields) => ({
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fields),
});
/** Fetch a page and pull out the challenge it is offering. */
async function challenge(session, path) {
const { body } = await session.go(path);
const message = /class="sirius-wallet__message"[^>]*>([\s\S]*?)<\/textarea>/.exec(body);
return {
body,
nonce: /name="sirius_nonce" value="([^"]+)"/.exec(body)?.[1],
message: message ? decode(message[1]) : null,
};
}
const errorIn = (html) =>
(/<div id="login_error">([\s\S]*?)<\/div>/.exec(html)?.[1] || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
// ---------------------------------------------------------------- sign in
group("wallet sign-in");
{
const s = Session();
const c = await challenge(s, "/wp-login.php");
check(Boolean(c.nonce && c.message), "the login page issues a challenge");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
check(wallet.address === admin.address, "the phrase derives the administrator's address");
const { res } = await s.go(
"/wp-login.php",
post({
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: await wallet.sign(c.message),
sirius_address: wallet.address,
"wp-submit": "Log In",
redirect_to: `${BASE}/wp-admin/`,
testcookie: "1",
}),
);
check(res.status === 302, `a valid signature signs you in (${res.status})`);
check(s.loggedIn(), "an ordinary WordPress session cookie is issued");
const dash = await s.go("/wp-admin/");
check(dash.res.status === 200 && /Dashboard/i.test(dash.body), "wp-admin loads with that session");
// Every screen the fork adds has to render without a PHP diagnostic.
const screens = [
["Settings", "/wp-admin/admin.php?page=sirius-press"],
["Sign-in", "/wp-admin/admin.php?page=sirius-press-auth"],
["Publishing", "/wp-admin/admin.php?page=sirius-press-export"],
["Inbox", "/wp-admin/admin.php?page=sirius-inbox"],
["Profile", "/wp-admin/profile.php"],
["Users", "/wp-admin/users.php"],
["Plugins", "/wp-admin/plugins.php"],
];
for (const [label, path] of screens) {
const page = await s.go(path);
// Match PHP's own diagnostic output, not the word "Warning" wherever it
// happens to appear. PHP always appends " in <file> on line <n>", and
// with html_errors on it wraps the label in <b>. Without that anchor this
// check trips over plugins whose translation strings contain the word —
// Yoast ships several.
const LABEL = "Fatal error|Parse error|Warning|Notice|Deprecated";
const diagnostic =
new RegExp(`<b>(?:${LABEL})</b>:[^<]{0,200}`).exec(page.body) ||
new RegExp(`(?:${LABEL}):[^\n<]{0,200}? in [^\n<]{0,200}? on line \d+`).exec(page.body);
check(
page.res.status === 200 && !diagnostic,
`${label} renders cleanly (${page.res.status})`,
diagnostic ? diagnostic[0] : "",
);
}
const settings = await s.go("/wp-admin/admin.php?page=sirius-press");
check(/Publishing address/.test(settings.body), "settings shows the publishing status block");
const profile = await s.go("/wp-admin/profile.php");
check(profile.body.includes(admin.address), "the profile shows the attached wallet");
const users = await s.go("/wp-admin/users.php");
check(/Wallet/.test(users.body), "the users list has a Wallet column");
check(!/noreply\+/.test(users.body), "and does not show placeholder email addresses");
}
// ----------------------------------------------------- one signature, one use
group("a signature is worth one use");
{
const c = await challenge(Session(), "/wp-login.php");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
const signature = await wallet.sign(c.message);
const fields = {
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: signature,
sirius_address: wallet.address,
"wp-submit": "Log In",
testcookie: "1",
};
const first = await Session().go("/wp-login.php", post(fields));
check(first.res.status === 302, "the first use is accepted");
const second = await Session().go("/wp-login.php", post(fields));
check(second.res.status === 200, "replaying it does not sign anyone in");
check(/already been used/i.test(second.body), "and the page says why", errorIn(second.body));
}
// ------------------------------------------------------------ wrong wallet
group("a stranger's signature");
{
const s = Session();
const c = await challenge(s, "/wp-login.php");
const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const { res, body } = await s.go(
"/wp-login.php",
post({
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: await stranger.sign(c.message),
sirius_address: stranger.address,
"wp-submit": "Log In",
testcookie: "1",
}),
);
check(res.status === 200, "an unknown wallet is not signed in");
check(/No account/i.test(body), "and is told no account uses it", errorIn(body));
}
// ------------------------------------------------------- tampered message
group("a signature over different text");
{
const s = Session();
const c = await challenge(s, "/wp-login.php");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
const { res, body } = await s.go(
"/wp-login.php",
post({
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: await wallet.sign(c.message + " "),
sirius_address: wallet.address,
"wp-submit": "Log In",
testcookie: "1",
}),
);
check(res.status === 200, "a signature over altered text is refused");
check(/does not match/i.test(body), "and says the text does not match", errorIn(body));
}
// ------------------------------------------- purposes are not interchangeable
group("a login signature cannot create an account");
{
const s = Session();
const login = await challenge(s, "/wp-login.php");
const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const { body } = await s.go(
"/wp-login.php?action=sirius_register",
post({
user_login: "",
sirius_nonce: login.nonce,
sirius_purpose: "register",
sirius_signature: await stranger.sign(login.message),
sirius_address: stranger.address,
}),
);
check(
/does not match/i.test(body),
"signing the login text does not register an account",
errorIn(body),
);
}
// ------------------------------------------------------------- registration
group("registration");
{
const s = Session();
const c = await challenge(s, "/wp-login.php?action=sirius_register");
check(Boolean(c.nonce && c.message), "the registration page issues its own challenge");
check(/create an account/i.test(c.message || ""), "the challenge says what it is for",
(c.message || "").split("\n")[0]);
check(!/name="user_email"|type="email"/.test(c.body), "there is no email field on it");
const wallet = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const suffix = Math.random().toString(36).slice(2, 8);
const { res } = await s.go(
"/wp-login.php?action=sirius_register",
post({
user_login: `reader_${suffix}`,
sirius_nonce: c.nonce,
sirius_purpose: "register",
sirius_signature: await wallet.sign(c.message),
sirius_address: wallet.address,
}),
);
check(res.status === 302, `one signature creates the account and signs it in (${res.status})`);
check(s.loggedIn(), "the new account has a session straight away");
/*
* Prove the account was bound to the right key by signing in again with it,
* rather than by loading wp-admin. A subscriber cannot necessarily reach
* wp-admin at all WooCommerce redirects them away by default and that
* would make this assertion a test of whichever plugins happen to be
* installed instead of a test of registration.
*/
const fresh = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login"));
const freshJson = await fresh.json().catch(() => ({}));
const back = await fetch(restUrl("/sirius-press/v1/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: freshJson.nonce, signature: await wallet.sign(freshJson.message) }),
});
const backJson = await back.json().catch(() => ({}));
check(
back.status === 200 && backJson.address === wallet.address,
"the new account signs in again with the same wallet",
`status ${back.status}, address ${backJson.address}`,
);
const s2 = Session();
const c2 = await challenge(s2, "/wp-login.php?action=sirius_register");
const again = await s2.go(
"/wp-login.php?action=sirius_register",
post({
user_login: "",
sirius_nonce: c2.nonce,
sirius_purpose: "register",
sirius_signature: await wallet.sign(c2.message),
sirius_address: wallet.address,
}),
);
check(
again.res.status === 200 && /already has an account/i.test(again.body),
"the same wallet cannot register twice",
errorIn(again.body),
);
}
// ------------------------------------------------------------ recovery page
group("the recovery page");
{
const { res, body } = await Session().go("/wp-login.php?action=lostpassword");
check(res.status === 200, "it loads");
check(/cannot reset your account/i.test(body), "it says the site cannot reset anything");
check(!/Get New Password/i.test(body), "and offers no reset form");
}
// -------------------------------------------------------------- REST surface
group("the REST endpoints");
{
const res = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login"));
const json = await res.json().catch(() => ({}));
check(res.status === 200, `challenge returns 200 (${res.status})`);
check(Boolean(json.nonce && json.message), "and carries a nonce and a message");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
const login = await fetch(restUrl("/sirius-press/v1/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: json.nonce, signature: await wallet.sign(json.message) }),
});
const out = await login.json().catch(() => ({}));
check(login.status === 200 && out.ok === true, `login returns a session (${login.status})`);
check(out.address === admin.address, "and reports the address that signed");
const bad = await fetch(restUrl("/sirius-press/v1/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: json.nonce, signature: "not-a-signature" }),
});
check(bad.status >= 400, `a bad signature is rejected with an error status (${bad.status})`);
// Registration through the API must insist on the claimed address for the
// same reason the form does.
const reg = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=register"));
const regJson = await reg.json().catch(() => ({}));
if (regJson.nonce) {
const orphan = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const noAddr = await fetch(restUrl("/sirius-press/v1/register"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: regJson.nonce, signature: await orphan.sign(regJson.message) }),
});
check(noAddr.status === 400, `register without an address is refused (${noAddr.status})`);
}
}
console.log(`\n ${pass + fail} checks, ${fail ? `${fail} FAILED` : "all passed"}\n`);
process.exit(fail ? 1 : 0);

View file

@ -1,153 +0,0 @@
// A stand-in for the BNS gateway, for testing the publishing path.
//
// It implements exactly one thing, and implements it the way the real gateway
// does: the signature check on `PUT /api/site/<name>/<path>`. The verification
// below is transcribed from Argus/src/gateway/public-gateway.mjs — same
// envelope, same digest, same recovery, same comparison against the name's
// owner — so a request this accepts is one the real gateway accepts, and a
// request it rejects would have been rejected there too.
//
// That is the whole point. Publishing cannot be tested end to end without a
// registered name and a funded key, but the part that actually breaks — the
// bytes being signed — can be checked against the real verifier.
//
// node tests/mock-gateway.mjs --owner bchtest:qq… [--port 8799]
//
// Needs @bitauth/libauth resolvable from this file — deliberately, because
// verifying with the same library the real gateway uses is what makes this
// worth running at all. In the Silent Mode monorepo, copy it next to
// Argus/package.json and run it there; standalone, `npm i @bitauth/libauth`
// in this directory.
//
// Uploads are kept in memory and listed on GET /api/site/<name>. Nothing is
// written to disk and nothing leaves the machine.
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { secp256k1, sha256, ripemd160, encodeCashAddress, base64ToBin, utf8ToBin } from "@bitauth/libauth";
const args = process.argv.slice(2);
const argOf = (name, fallback) => {
const i = args.indexOf(`--${name}`);
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
};
const OWNER = argOf("owner", "");
const PORT = Number(argOf("port", "8799"));
if (!OWNER) {
console.error("usage: node tests/mock-gateway.mjs --owner <cashaddress> [--port 8799]");
process.exit(2);
}
const PREFIX = OWNER.split(":")[0] || "bchtest";
/** path -> {bytes, type} */
const store = new Map();
let accepted = 0;
let rejected = 0;
/**
* The real gateway's check, transcribed.
*
* digest = sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
* then recover the compressed public key from the 65-byte signature and
* compare the address it controls with the name's current on-chain owner.
*/
function verify({ name, path, body, ts, sigB64 }) {
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) {
return "x-bns-ts missing or more than 10 minutes off";
}
let sig;
try {
sig = base64ToBin(sigB64);
} catch {
return "x-bns-sig is not valid base64";
}
if (sig.length !== 65) return `x-bns-sig must decode to 65 bytes (got ${sig.length})`;
const bodyHex = createHash("sha256").update(body).digest("hex");
const digest = sha256.hash(utf8ToBin(`BNS-SITE1\n${name}\n${path}\n${bodyHex}\n${ts}`));
const recovered = secp256k1.recoverPublicKeyCompressed(sig.slice(1), (sig[0] - 27) & 3, digest);
if (typeof recovered === "string") return `signature recovery failed: ${recovered}`;
const encoded = encodeCashAddress({
prefix: PREFIX,
type: "p2pkh",
payload: ripemd160.hash(sha256.hash(recovered)),
});
const derived = typeof encoded === "string" ? encoded : encoded?.address ?? "";
if (derived !== OWNER) {
return `signature does not match current on-chain NFT owner (derived ${derived})`;
}
return null;
}
const server = createServer((req, res) => {
const json = (code, obj) => {
res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" });
res.end(JSON.stringify(obj));
};
const url = new URL(req.url, "http://x");
if (!url.pathname.startsWith("/api/site/")) {
return json(404, { error: "only /api/site is implemented" });
}
const rest = url.pathname.slice("/api/site/".length);
const slash = rest.indexOf("/");
const name = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash));
const path = slash < 0 ? "" : decodeURIComponent(rest.slice(slash + 1));
if (req.method === "GET" && !path) {
return json(200, {
name,
prefix: `bns/${name}/`,
files: [...store].map(([p, v]) => ({ path: p, size: v.bytes.length, modified: null })),
});
}
if (req.method === "GET") {
const hit = store.get(path);
if (!hit) return json(404, { error: "not found" });
res.writeHead(200, { "content-type": hit.type });
return res.end(hit.bytes);
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
const problem = verify({
name,
path,
body: req.method === "PUT" ? body : Buffer.alloc(0),
ts: Number(req.headers["x-bns-ts"] || 0),
sigB64: String(req.headers["x-bns-sig"] || ""),
});
if (problem) {
rejected++;
console.log(` reject ${req.method} ${path}${problem}`);
return json(problem.includes("owner") ? 403 : 401, { error: problem });
}
if (req.method === "DELETE") {
store.delete(path);
accepted++;
console.log(` delete ${path}`);
return json(200, { ok: true, name, path, deleted: true });
}
store.set(path, { bytes: body, type: String(req.headers["content-type"] || "") });
accepted++;
console.log(` accept ${path} (${body.length} bytes)`);
return json(200, { ok: true, name, path, bytes: body.length, sia_key: `bns/${name}/${path}` });
});
});
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => {
console.log(`\naccepted ${accepted}, rejected ${rejected}, holding ${store.size} files`);
process.exit(0);
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`mock gateway on http://127.0.0.1:${PORT}, owner ${OWNER}`);
});

View file

@ -1,82 +0,0 @@
#!/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
# The fork's own PHP, plus every core file the fork patches. Sweeping all
# of wordpress/ would lint 3,800 files of upstream code that upstream
# already tests, and turn a one-second suite into a minute of waiting.
lint_targets() {
find "$here/../plugins" "$here/../mu-plugins" "$here" \
-name '*.php' -not -path '*/node_modules/*' 2>/dev/null
for patch in "$here"/../patches/*.patch; do
[ -e "$patch" ] || continue
sed -n 's|^--- a/||p' "$patch" | while IFS= read -r rel; do
case "$rel" in *.php) echo "$here/../wordpress/$rel" ;; esac
done
done
}
while IFS= read -r file; do
[ -f "$file" ] || continue
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 < <(lint_targets)
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/../plugins" "$here" -name '*.js' -not -path '*/node_modules/*' 2>/dev/null)
fi
[ "$syntax_ok" -eq 1 ] && echo " ok the fork's PHP and JS parses, including every patched core file"
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"

View file

@ -1,138 +0,0 @@
<?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() );

View file

@ -1,163 +0,0 @@
<?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&eacute;/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' );
T::group( 'rewriting when the site URL carries a port' );
/*
* A local instance, a staging box behind a port, anything not on 80 or 443.
* This case is worth its own group because two separate bugs lived here: a
* naive protocol-relative pass that matched inside every absolute URL and
* gave each one a second scheme, and a host-only match that left the port
* stranded as `//host:8760:8760/`. Both produced links that looked almost
* right and went nowhere.
*/
// Swap the stub's idea of where the site is for the length of this group.
$GLOBALS['sirius_test_home'] = 'http://127.0.0.1:8760';
$with_port =
'<a href="http://127.0.0.1:8760/?feed=rss2">feed</a>'
. '<a href="http://127.0.0.1:8760/">home</a>'
. '<a href="http://127.0.0.1:8760/about/">about</a>'
. '<img src="//127.0.0.1:8760/wp-content/x.png">';
$ported_out = SPE_Renderer::rewrite( $with_port, 'index.html' )['html'];
T::ok( false === strpos( $ported_out, 'http:http' ), 'no doubled scheme' );
T::ok( false === strpos( $ported_out, ':8760:8760' ), 'no doubled port' );
T::ok( false === strpos( $ported_out, '127.0.0.1' ), 'no absolute self-links remain' );
T::ok( false !== strpos( $ported_out, 'href="index.html?feed=rss2"' ), 'a query survives on the home page' );
T::ok( false !== strpos( $ported_out, 'href="about/index.html"' ), 'an internal link is relative' );
T::ok( false !== strpos( $ported_out, 'src="wp-content/x.png"' ), 'a protocol-relative asset keeps its port stripped cleanly' );
unset( $GLOBALS['sirius_test_home'] );
T::group( 'the export marker' );
// 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() );

View file

@ -1,197 +0,0 @@
<?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() );

View file

@ -1,108 +0,0 @@
#!/usr/bin/env bash
# build.sh — assemble a complete Sirius Press tree.
#
# Copies the vendored WordPress from wordpress/, drops this repository's
# plugins and must-use plugins into it, 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.
#
# There is no download and no checksum step, because there is nothing to
# fetch: core is in the repository, already patched, and what you build is
# exactly what you can read in `git log wordpress/`. Moving to a new upstream
# release is tools/update-wordpress.sh, not a flag here.
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"
SOURCE="$here/wordpress"
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; }
[ -f "$SOURCE/wp-includes/version.php" ] \
|| die "wordpress/ is missing or empty — check the whole repository was cloned."
vendored="$(grep -m1 'wp_version = ' "$SOURCE/wp-includes/version.php" | sed "s/.*'\(.*\)'.*/\1/")"
if [ "$vendored" != "$WP_VERSION" ]; then
die "wordpress/ holds $vendored but tools/wordpress.lock says $WP_VERSION.
One of them is stale; tools/update-wordpress.sh keeps them in step."
fi
# ------------------------------------------------------------------ assemble
say "copying WordPress $vendored out of wordpress/"
rm -rf "$TARGET"
mkdir -p "$TARGET"
# The trailing /. copies the contents rather than nesting another directory.
cp -a "$SOURCE/." "$TARGET/"
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/"
say "built dist/sirius-press ($(du -sh "$TARGET" | cut -f1))"
# --------------------------------------------------------------------- zip
if [ "$MAKE_ZIP" -eq 1 ]; then
archive="$DIST/sirius-press-$VERSION.zip"
say "packing $(basename "$archive")"
rm -f "$archive"
# `zip` is the obvious tool and is missing often enough — minimal container
# images, Git Bash on Windows — that falling back to Python's zipfile is
# worth nine lines. Both produce the same archive as far as anyone
# unpacking it is concerned.
if command -v zip >/dev/null; then
( cd "$DIST" && zip -qr "$archive" sirius-press )
elif command -v python3 >/dev/null || command -v python >/dev/null; then
py="$(command -v python3 || command -v python)"
"$py" - "$DIST" "$archive" <<'PYZIP'
import os, sys, zipfile
root, archive = sys.argv[1], sys.argv[2]
base = os.path.join(root, 'sirius-press')
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
for folder, _dirs, files in os.walk(base):
for name in files:
full = os.path.join(folder, name)
z.write(full, os.path.relpath(full, root).replace(os.sep, '/'))
PYZIP
else
die "--zip needs either the zip command or Python."
fi
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, on WordPress $vendored.
dist/sirius-press/ a complete tree, ready to serve
$( [ "$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

View file

@ -1,141 +0,0 @@
#!/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

View file

@ -1,112 +0,0 @@
#!/usr/bin/env bash
# refresh-patches.sh — regenerate patches/ from what the fork actually changed.
#
# Since core is vendored, `patches/` is no longer how the build works — it is
# documentation. It answers the question anyone auditing this fork asks first:
# *what exactly did you change inside WordPress?* A directory of readable
# diffs answers that in a minute; `git log wordpress/` does not, because it is
# mostly upstream imports.
#
# Documentation that is generated stays true. Documentation that is maintained
# by hand drifts, and a stale record of a fork's core diff is worse than none,
# because people trust it.
#
# tools/refresh-patches.sh # rewrite patches/ from the tree
# tools/refresh-patches.sh --check # fail if it would change anything (CI)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
repo="$(git -C "$here" rev-parse --show-toplevel)"
# Ask git where we are rather than subtracting paths: on Windows
# --show-toplevel answers "D:/..." while $PWD is "/d/...", and the
# subtraction silently leaves an absolute path behind.
prefix="$(cd "$here" && git rev-parse --show-prefix)wordpress"
UPSTREAM_BRANCH="sirius-press/wordpress-upstream"
CHECK=0
for arg in "$@"; do
case "$arg" in
--check) CHECK=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; }
git -C "$repo" rev-parse --verify --quiet "$UPSTREAM_BRANCH" >/dev/null \
|| die "the branch $UPSTREAM_BRANCH is missing; there is nothing to diff against."
# shellcheck source=wordpress.lock
source "$here/tools/wordpress.lock"
staging="$here/dist/.patch-refresh"
rm -rf "$staging"
mkdir -p "$staging"
say "comparing ${prefix}/ against pristine WordPress ${WP_VERSION}"
# Every path that differs between the vendored tree and the pristine import.
#
# Compared tree to tree, not commit to commit: the upstream branch keeps
# WordPress at its root while the subtree keeps it under a prefix, so a
# plain `git diff BRANCH HEAD -- <prefix>` compares two different path
# spaces and reports the whole distribution as added.
UPSTREAM_TREE="${UPSTREAM_BRANCH}^{tree}"
VENDORED_TREE="HEAD:${prefix}"
mapfile -t changed < <(
git -C "$repo" diff --name-only "$UPSTREAM_TREE" "$VENDORED_TREE"
)
if [ ${#changed[@]} -eq 0 ]; then
say "the fork changes nothing in core — writing an empty series"
fi
index=0
for file in "${changed[@]}"; do
[ -n "$file" ] || continue
index=$((index + 1))
slug="$(echo "$file" | tr '/.' '--' | tr -cd 'A-Za-z0-9-')"
out="$staging/$(printf '%04d' "$index")-${slug}.patch"
{
echo "Subject: [PATCH] ${file}"
echo
echo "The fork's change to this file, regenerated by tools/refresh-patches.sh."
echo "It is a record, not the mechanism: core is vendored under wordpress/ and"
echo "this diff is what distinguishes it from pristine upstream."
echo
echo "Applies to: WordPress ${WP_VERSION}"
git -C "$repo" diff "$UPSTREAM_TREE" "$VENDORED_TREE" -- "$file" | tail -n +3
} > "$out"
done
if [ "$CHECK" -eq 1 ]; then
# --strip-trailing-cr because a Windows checkout may hold these with CRLF
# while what we just generated has LF. .gitattributes marks *.patch as
# binary to stop that happening, but a clone made before that landed — or
# with the file copied around by hand — would otherwise report drift that
# is not there, and a check that cries wolf gets switched off.
if diff -rq --strip-trailing-cr "$here/patches" "$staging" >/dev/null 2>&1; then
say "patches/ matches the tree"
rm -rf "$staging"
exit 0
fi
diff -ru --strip-trailing-cr "$here/patches" "$staging" || true
rm -rf "$staging"
die "patches/ is out of date. Run tools/refresh-patches.sh and commit the result."
fi
rm -f "$here/patches"/*.patch
if [ "$index" -gt 0 ]; then
cp "$staging"/*.patch "$here/patches/"
fi
rm -rf "$staging"
say "wrote ${index} patch file(s) describing the fork's core diff"
for f in "$here/patches"/*.patch; do
[ -e "$f" ] || continue
printf ' %s (%s lines)\n' "$(basename "$f")" "$(grep -c '' "$f")"
done

View file

@ -1,228 +0,0 @@
#!/usr/bin/env bash
# update-wordpress.sh — bring a new WordPress release into the fork.
#
# tools/update-wordpress.sh 7.1.2
#
# Two steps, and the second is the one that matters:
#
# 1. Import the pristine release onto the `sirius-press/wordpress-upstream`
# branch — one commit per version, nothing but upstream, never edited.
# 2. `git subtree merge` that branch into wordpress/, which three-way merges
# it against the fork's own commits on top of the last import.
#
# That second step is why core is vendored rather than patched at build time.
# A three-way merge understands that upstream changed lines A and B while the
# fork changed line C, and only stops when they overlap. It also leaves a
# conflict you resolve once, in the file, instead of a patch you re-derive
# every release.
#
# The merge happens in a temporary worktree, not in your checkout. git-subtree
# refuses to run at all when anything in the repository is modified, and in a
# monorepo shared with other work that is the normal state — the merge would
# be blocked by uncommitted files it does not touch. A worktree is clean by
# construction, and the result comes back as one ordinary merge commit.
#
# Nothing is pushed. Run it, look at the merge, run the tests, then push.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
repo="$(git -C "$here" rev-parse --show-toplevel)"
# Ask git for the path rather than subtracting strings: on Windows
# --show-toplevel answers "D:/…" while $PWD is "/d/…", and the subtraction
# silently leaves an absolute path, which --prefix then rejects.
prefix="$(cd "$here" && git rev-parse --show-prefix)wordpress"
VERSION="${1:-}"
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>"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || die "'$VERSION' does not look like a WordPress version"
# shellcheck source=wordpress.lock
source "$here/tools/wordpress.lock"
CURRENT="$WP_VERSION"
[ "$VERSION" != "$CURRENT" ] || die "the fork is already on WordPress $VERSION"
UPSTREAM_BRANCH="sirius-press/wordpress-upstream"
git -C "$repo" rev-parse --verify --quiet "$UPSTREAM_BRANCH" >/dev/null \
|| die "the branch $UPSTREAM_BRANCH is missing. It holds the pristine upstream
imports that the subtree merges against; without it this fork cannot take an
upstream release. See docs/upstream-merges.md."
# The merge lands on top of whatever is committed, so anything staged or
# modified under the subtree itself would be lost or fought over.
if ! git -C "$repo" diff --quiet -- "$prefix" 2>/dev/null; then
die "${prefix}/ has uncommitted changes. Commit or stash them first."
fi
CACHE="$here/dist/.cache"
mkdir -p "$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 "checking it against the SHA-1 wordpress.org publishes"
published="$(curl -fsSL "${URL}.sha1" 2>/dev/null || true)"
actual_sha1="$(sha1sum "$TARBALL" | cut -d' ' -f1)"
if [ -z "$published" ]; then
warn "wordpress.org served no .sha1 for this release; recording the sha256 below unverified"
elif [ "$published" != "$actual_sha1" ]; then
rm -f "$TARBALL"
die "SHA-1 mismatch — the download is not what wordpress.org publishes.
published $published
got $actual_sha1"
fi
NEW_SHA256="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
# ------------------------------------------- import onto the upstream branch
IMPORT_TREE="$here/dist/.vendor-import"
MERGE_TREE="$here/dist/.vendor-merge"
MERGE_BRANCH="sirius-press/_merge-${VERSION}"
drop_worktree() {
git -C "$repo" worktree remove --force "$1" >/dev/null 2>&1 || true
}
say "importing the pristine tree onto $UPSTREAM_BRANCH"
drop_worktree "$IMPORT_TREE"
rm -rf "$IMPORT_TREE"
git -C "$repo" worktree add --quiet "$IMPORT_TREE" "$UPSTREAM_BRANCH"
trap 'drop_worktree "$IMPORT_TREE"' EXIT
# Replace the tree wholesale: files upstream deleted have to disappear, or the
# subtree merge would keep resurrecting them.
find "$IMPORT_TREE" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
tar -xzf "$TARBALL" -C "$IMPORT_TREE" --strip-components=1
git -C "$IMPORT_TREE" add -A
if git -C "$IMPORT_TREE" diff --cached --quiet; then
# Already imported — a previous run got this far and then stopped. Carry on
# to the merge rather than refusing, so a failure is retryable.
say "WordPress $VERSION was already imported; continuing to the merge"
else
git -C "$IMPORT_TREE" \
-c user.name="Silent Mode" -c user.email="hephaestus@silentmode.st" \
commit -q -m "WordPress ${VERSION}
Pristine upstream, unpacked from the official wordpress.org tarball.
${URL}
sha1 ${actual_sha1} (published by wordpress.org)
sha256 ${NEW_SHA256}
This branch carries nothing but upstream releases, one commit each, and is
never edited."
say "imported as $(git -C "$IMPORT_TREE" rev-parse --short HEAD)"
fi
drop_worktree "$IMPORT_TREE"
trap - EXIT
# ------------------------------------------------------------ subtree merge
say "merging into ${prefix}/ (in a scratch worktree)"
drop_worktree "$MERGE_TREE"
rm -rf "$MERGE_TREE"
git -C "$repo" branch -D "$MERGE_BRANCH" >/dev/null 2>&1 || true
git -C "$repo" worktree add --quiet -b "$MERGE_BRANCH" "$MERGE_TREE" HEAD
if git -C "$MERGE_TREE" \
-c user.name="Silent Mode" -c user.email="hephaestus@silentmode.st" \
subtree merge --prefix="$prefix" "$UPSTREAM_BRANCH" \
-m "merge: WordPress ${VERSION} into the vendored subtree"
then
say "merged cleanly"
else
cat >&2 <<EOF
$(printf '\033[31mThe merge did not complete.\033[0m')
Usually this means upstream changed code the fork also changes — in practice
wp-admin/install.php, the only file this fork patches. Your own checkout is
untouched and still on WordPress ${CURRENT}; the half-done merge is in a
scratch worktree:
cd ${MERGE_TREE}
git status # what conflicted
\$EDITOR ${prefix}/wp-admin/install.php
git add ${prefix}/wp-admin/install.php
git commit # finish the merge
Then bring it back and finish up:
cd ${repo}
git merge --no-ff ${MERGE_BRANCH}
git worktree remove ${MERGE_TREE}
git branch -d ${MERGE_BRANCH}
${here}/tools/refresh-patches.sh
# set WP_VERSION / WP_URL / WP_SHA256 / WP_SHA1 in tools/wordpress.lock
EOF
exit 1
fi
# Bring the merge home. This touches only ${prefix}/, so unrelated modified
# files elsewhere in the repository are not in its way.
say "bringing the merge into the working branch"
git -C "$repo" merge --no-ff -q \
-m "merge: WordPress ${VERSION} into ${prefix}/" "$MERGE_BRANCH" \
|| die "could not merge ${MERGE_BRANCH} into the working branch. It is still
there, with the subtree merge already done — resolve and merge it by hand."
drop_worktree "$MERGE_TREE"
git -C "$repo" branch -D "$MERGE_BRANCH" >/dev/null 2>&1 || true
# --------------------------------------------------------------- the lock
say "updating tools/wordpress.lock"
python3 - "$VERSION" "$URL" "$NEW_SHA256" "$actual_sha1" "$here/tools/wordpress.lock" <<'PY'
import io, sys
version, url, sha256, sha1, path = sys.argv[1:6]
fields = {
'WP_VERSION=': version,
'WP_URL=': url,
'WP_SHA256=': sha256,
'WP_SHA1=': sha1,
}
out = []
for line in io.open(path, encoding='utf-8'):
for key, value in fields.items():
if line.startswith(key):
line = f'{key}{value}\n'
break
out.append(line)
io.open(path, 'w', encoding='utf-8', newline='').write(''.join(out))
PY
if [ -x "$here/tools/refresh-patches.sh" ]; then
say "refreshing patches/ so it still describes the fork's core diff"
"$here/tools/refresh-patches.sh" >/dev/null || warn "could not refresh patches/ — do it by hand"
fi
cat <<EOF
$(printf '\033[32mSirius Press now builds on WordPress %s.\033[0m' "$VERSION")
${CURRENT} -> ${VERSION}
The subtree merge is committed. tools/wordpress.lock and patches/ are changed
but not committed — look at them, then commit. Before pushing:
tests/run.sh
tools/build.sh
then walk through wp-admin/install.php once — the setup wizard is the only
file this fork patches, so it is the only thing this can have broken.
EOF

View file

@ -1,14 +0,0 @@
# 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.2
WP_URL=https://wordpress.org/wordpress-7.1.2.tar.gz
WP_SHA256=c0c666689d66b870d8825500bb8e402ed04de52602c61a6f516c6236b8c9ac67
WP_SHA1=761b8101538f0631a0bfc4fba7bc4abeea92f81c

Some files were not shown because too many files have changed in this diff Show more