fix(sirius-press): recovering a key is not the same as verifying a signature

Running the fork against a live WordPress found a real hole in registration,
and it is the kind that only shows up when you actually try it.

ECDSA 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 signed. The auth flow leaned on that as if a wrong message
would fail. It does not; it quietly yields a stranger's address.

At sign-in this was harmless, because the wrong address matches no account
and the attempt fails. Registration and wallet-linking were another matter:
both took the recovered address and bound it to an account, so a signature
over slightly different text — a challenge copied without its blank line, a
wallet that rewrote the text, a login signature replayed at the registration
form — created an account keyed to an address nobody could sign for. The
person would see "success" and discover the truth the next time they tried to
get in. Wallet-linking was worse still: it would move an existing account onto
a dead address and lock its owner out of their own site.

Both paths now require the address the signer claims and compare it to the
recovered one, which is what verification actually means. Sign-in accepts the
claim when the page sends it and uses it to turn "no account uses that wallet"
into the more useful "that signature is not over the text we asked for".

Also from running it:

URL rewriting mangled every link on a site whose URL carries a port. The
protocol-relative pass matched inside absolute URLs and gave each one a second
scheme, and matching the host without its port left the port stranded as
`//host:8760:8760/`. Local and staging installs would have exported a site of
broken links.

Plain permalinks silently collapse an entire site onto one exported file,
because every post's URL is `/?p=N` and its path is `/`. The queue looks
healthy the whole time. The Publishing screen now says so.

Translations loaded on `plugins_loaded`, which WordPress 6.7 warns about on
every request — the kind of noise that trains people to stop reading logs.

And one deletion: an `is_email()` filter written on the assumption that
WordPress rejects `.invalid` addresses. It does not — `is_email()` validates
syntax, not whether a domain could exist — so the filter never fired. A filter
that appears to relax a rule but does not is worse than no filter, because
someone later reasons from it. The documentation made the same claim and has
been corrected.

Verification added rather than asserted: tests/live.mjs drives a real instance
over HTTP (40 checks), and tests/mock-gateway.mjs answers uploads with the
signature check transcribed from the gateway's own source, so the publishing
path can be exercised without a registered name.
This commit is contained in:
Silent Mode 2026-09-21 02:35:32 +02:00
parent 5465b65756
commit ddf49a5523
20 changed files with 1078 additions and 96 deletions

View file

@ -58,10 +58,31 @@ static copies of its pages to a BCNR name.
- `tools/update-wordpress.sh` to move onto a new upstream release. - `tools/update-wordpress.sh` to move onto a new upstream release.
- `tools/publish-release.sh` to ship to both mirrors. - `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.
### Known gaps ### Known gaps
- The plugin compatibility matrix in docs is reasoned from each plugin's setup - `install.sh` and the Docker stack are written and syntax-checked but have
path, not yet confirmed against a running install. not been run on a clean Ubuntu box.
- No instance has been stood up end to end against a live chipnet name, so the - Publishing is verified against a transcription of the gateway's own
full publish path is verified by unit and interop tests rather than by a verification logic, not against `navigate.st` with a registered name.
round trip through the gateway. - 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.

View file

@ -124,7 +124,7 @@ mu-plugins/ the bits that must load before plugins do
patches/ the core diff — currently one file patches/ the core diff — currently one file
docker/ compose stack: MariaDB, PHP-FPM, nginx docker/ compose stack: MariaDB, PHP-FPM, nginx
tools/ build, upstream update, release tools/ build, upstream update, release
tests/ 132 checks, no framework, runs in under a second tests/ 138 unit checks plus a live end-to-end suite
docs/ docs/
``` ```
@ -160,15 +160,34 @@ the procedure and how to switch to a vendored subtree if you would rather.
## Testing ## Testing
```bash ```bash
tests/run.sh tests/run.sh # 138 checks, no framework, about a second
node tests/live.mjs # 40 more, against a running instance
``` ```
132 checks, no framework, about a second. The suite that matters most is The suite that matters most is `tests/interop.mjs`, which pins the browser
`tests/interop.mjs`, which pins the browser wallet against the PHP one: both wallet against the PHP one. Both implement secp256k1, RFC 6979, BIP-32 and
implement secp256k1, RFC 6979, BIP-32 and BIP-137 independently, and every BIP-137 independently, and every vector came from libauth — the library the
vector in the suite came from libauth — the library the Sirius portal wallet Sirius portal wallet and the BNS gateway both use. A signature made in a
and the BNS gateway both use. A signature made in a browser verifies on the browser verifies on the server and at the gateway, or the suite fails.
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.
- An upstream version bump through `tools/update-wordpress.sh`, reapplying the
patch series.
Not yet run: `install.sh` on a clean Ubuntu box, and an upload to the real
gateway with a registered name. [docs/testing.md](docs/testing.md) says how to
do both and what each proves.
--- ---

View file

@ -30,43 +30,46 @@ messages end up.
Mail addressed to a real domain is passed straight through. If you configured 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. SMTP, it sends. If you did not, it fails exactly as stock WordPress fails.
`is_email()` is left alone, except for one narrow case: addresses under **this `is_email()` is left completely alone, and it is worth saying why, because
site's own** placeholder domain are accepted, because a setup wizard that the obvious guess is wrong. WordPress validates an address's *syntax*, not
validates its own default and refuses to advance is a wizard you cannot get whether its domain could ever exist — so `.invalid` addresses already pass
past. Every other `.invalid` address is still correctly rejected — a contact `is_email()` unchanged. No shim is needed to make the placeholders acceptable
form that silently accepted `someone@example.invalid` from a visitor would be to plugins that validate, and none is shipped. (Verified against WordPress
collecting addresses nobody can ever reply to. 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 ## The top ten
**Status: predicted, not yet verified.** The table below is derived from Three of these were installed and activated on a Sirius Press 0.1.0 instance
reading each plugin's setup and activation paths against what this fork running WordPress 7.1.1, with all four fork plugins active, and the whole
changes — not from installing them. Sirius Press 0.1.0 has not been run wallet sign-in suite re-run with them loaded. Those rows say **tested**. The
against a live plugin set yet, and until it has, treat every row as a claim rest are reasoned from each plugin's setup and activation path and say
awaiting a test rather than a result. **expected** — treat them as claims awaiting a test, and report anything that
behaves differently.
The rows are written to be falsifiable: each says what should happen, so a | Plugin | Status | Notes |
first real run either confirms it or produces a bug report. If you install one |---|---|---|
of these and it behaves differently, that is worth reporting — the shim it | **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. |
needs is usually three lines. | **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. |
| Plugin | Installs | Activates | Notes | "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
| **Yoast SEO** | yes | yes | Works unchanged. Its XML sitemaps export to your name with everything else. Reads `admin_email` for schema output; gets the placeholder. | with no PHP diagnostic while it was loaded.
| **Contact Form 7** | yes | yes | Forms build, save and submit. The default recipient is the placeholder, so submissions land in the inbox — fine for a small site, not what you want for a real contact form. Set a real address and configure SMTP. |
| **WooCommerce** | yes | yes | Store, products and checkout all work. Order emails to customers use the address the customer typed, so they send once SMTP is configured. The setup wizard's store-address field is pre-filled with a placeholder so it will advance. |
| **Elementor** | yes | yes | No email dependency. Pages built with it export normally. |
| **Wordfence** | yes | yes | Alert emails go to the inbox unless you set a real address. Its login-security features overlap with wallet auth; two-factor on top of a signature is redundant, and its "email me a code" option cannot work. |
| **WP Super Cache / W3 Total Cache** | yes | yes | Compatible, but consider whether you need them: the static export already serves cached HTML from the name, which is the harder-working cache. |
| **Akismet** | yes | yes | Needs an API key, which is obtained on akismet.com with a real address of yours. Nothing to do with site accounts. |
| **Jetpack** | yes | partly | Connection requires a WordPress.com account and works, but the modules built around subscriber email lists cannot do anything useful here. Not recommended. |
| **UpdraftPlus** | yes | yes | Backups work. Report emails go to the inbox. Back up `wp-config.php` too, or you lose `SIRIUS_PRESS_KEY` and with it the stored publishing phrase. |
| **Advanced Custom Fields** | yes | yes | No email dependency at all. |
"Activates" means the expectation is: no fatal, no failed setup wizard, and
the plugin's own status screen reporting itself healthy.
--- ---

152
docs/testing.md Normal file
View file

@ -0,0 +1,152 @@
# 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 patched tree
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.
---
## What is not covered
- **A fresh VPS install.** `install.sh` and the Docker stack are written and
syntax-checked but have not been run against a clean Ubuntu box.
- **A real gateway upload.** Verified against the transcribed verifier above,
not against `navigate.st` with a registered name.
- **Browser UI.** The signing scripts are exercised through Node, which runs
the same code, but nobody has clicked the buttons in a browser.

View file

@ -48,6 +48,7 @@
.sirius-wallet__phrase-input, .sirius-wallet__phrase-input,
.sirius-wallet__signature, .sirius-wallet__signature,
.sirius-wallet__address-input,
#sirius_derive_phrase { #sirius_derive_phrase {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@ -157,3 +158,10 @@
background: transparent; background: transparent;
} }
} }
.sirius-wallet__address-label {
display: block;
font-weight: 600;
margin: 10px 0 4px;
font-size: 13px;
}

View file

@ -40,9 +40,23 @@
} }
} }
function setSignature(block, signature) { function setSignature(block, signature, address) {
const field = block.querySelector(".sirius-wallet__signature"); const field = block.querySelector(".sirius-wallet__signature");
if (field) field.value = 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) { async function signWithPhrase(block) {
@ -63,7 +77,7 @@
prefix: CONFIG.prefix, prefix: CONFIG.prefix,
path: CONFIG.path, path: CONFIG.path,
}); });
setSignature(block, await wallet.sign(message)); setSignature(block, await wallet.sign(message), wallet.address);
} catch (err) { } catch (err) {
setStatus(block, err.message || String(err), "error"); setStatus(block, err.message || String(err), "error");
return; return;
@ -85,7 +99,7 @@
const message = block.querySelector(".sirius-wallet__message").value; const message = block.querySelector(".sirius-wallet__message").value;
setStatus(block, strings.signing || "Signing…"); setStatus(block, strings.signing || "Signing…");
try { try {
setSignature(block, await external.sign(message)); setSignature(block, await external.sign(message), await external.address());
} catch (err) { } catch (err) {
// A user declining the wallet's approval dialog is a normal outcome, not // A user declining the wallet's approval dialog is a normal outcome, not
// an error worth shouting about. // an error worth shouting about.
@ -97,6 +111,10 @@
} }
function enhance(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 signButton = block.querySelector(".sirius-wallet__sign");
const externalButton = block.querySelector(".sirius-wallet__external"); const externalButton = block.querySelector(".sirius-wallet__external");
const phraseArea = block.querySelector(".sirius-wallet__phrase"); const phraseArea = block.querySelector(".sirius-wallet__phrase");

View file

@ -15,11 +15,27 @@
* nonce, so the same signature cannot be replayed. * nonce, so the same signature cannot be replayed.
* *
* **The message is reconstructed, never trusted.** The client sends back the * **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 * nonce and what it was for; this class rebuilds the exact text from those and
* and verifies against the rebuilt copy. A client that lies about the purpose * recovers the signer from the rebuilt copy.
* is verifying against a message the user never signed, so it fails. That is *
* also why a login signature cannot be replayed to create an account: the * **Recovery is not verification.** This is the subtle part, and getting it
* purpose is inside the signed bytes. * 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 * @package SiriusPress
*/ */
@ -146,9 +162,14 @@ final class SPA_Challenge {
* @param string $nonce * @param string $nonce
* @param string $signature Base64, 65 bytes. * @param string $signature Base64, 65 bytes.
* @param string $purpose * @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. * @return string|WP_Error Normalised CashAddress.
*/ */
public static function verify( $nonce, $signature, $purpose = self::PURPOSE_LOGIN ) { public static function verify( $nonce, $signature, $purpose = self::PURPOSE_LOGIN, $claimed = '' ) {
if ( ! self::is_known_purpose( $purpose ) ) { if ( ! self::is_known_purpose( $purpose ) ) {
return new WP_Error( 'sirius_bad_purpose', __( 'Unknown sign-in purpose.', 'sirius-press' ) ); return new WP_Error( 'sirius_bad_purpose', __( 'Unknown sign-in purpose.', 'sirius-press' ) );
} }
@ -176,6 +197,22 @@ final class SPA_Challenge {
return new WP_Error( 'sirius_bad_signature', __( 'That signature could not be turned into an address.', 'sirius-press' ) ); 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 ); self::spend( $nonce );
return $address; return $address;
} }

View file

@ -70,6 +70,11 @@ final class SPA_Login {
} }
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) ); $signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) ); $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 // phpcs:enable WordPress.Security.NonceVerification.Missing
$limited = SPA_Challenge::check_rate_limit( 'login' ); $limited = SPA_Challenge::check_rate_limit( 'login' );
@ -77,7 +82,7 @@ final class SPA_Login {
return $limited; return $limited;
} }
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LOGIN ); $address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LOGIN, $claimed );
if ( is_wp_error( $address ) ) { if ( is_wp_error( $address ) ) {
return $address; return $address;
} }
@ -206,6 +211,18 @@ final class SPA_Login {
<div class="sirius-wallet" data-sirius-purpose="<?php echo esc_attr( $purpose ); ?>"> <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_nonce" value="<?php echo esc_attr( $nonce ); ?>" />
<input type="hidden" name="sirius_purpose" value="<?php echo esc_attr( $purpose ); ?>" /> <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 <textarea
class="sirius-wallet__message" class="sirius-wallet__message"
readonly readonly
@ -252,6 +269,21 @@ final class SPA_Login {
spellcheck="false" spellcheck="false"
placeholder="<?php esc_attr_e( 'base64 signature', 'sirius-press' ); ?>" placeholder="<?php esc_attr_e( 'base64 signature', 'sirius-press' ); ?>"
></textarea> ></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> </details>
<p class="sirius-wallet__status" role="status" aria-live="polite"></p> <p class="sirius-wallet__status" role="status" aria-live="polite"></p>

View file

@ -121,13 +121,31 @@ final class SPA_Profile {
} }
$signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) ); $signature = sanitize_text_field( wp_unslash( $_POST['sirius_signature'] ) );
$nonce = sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) ); $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 // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( get_current_user_id() !== (int) $user_id ) { if ( get_current_user_id() !== (int) $user_id ) {
return; // Only the account holder attaches their own key here. return; // Only the account holder attaches their own key here.
} }
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_LINK ); /*
* 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 ) ) { if ( is_wp_error( $address ) ) {
SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $address->get_error_message(), 'auth' ); SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $address->get_error_message(), 'auth' );
return; return;

View file

@ -90,13 +90,28 @@ final class SPA_Register {
$nonce = isset( $_POST['sirius_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['sirius_nonce'] ) ) : ''; $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'] ) ) : ''; $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 ) : ''; $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 // phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' === $signature || '' === $nonce ) { if ( '' === $signature || '' === $nonce ) {
return new WP_Error( 'sirius_missing', __( 'Sign the text above before submitting.', 'sirius-press' ) ); return new WP_Error( 'sirius_missing', __( 'Sign the text above before submitting.', 'sirius-press' ) );
} }
$address = SPA_Challenge::verify( $nonce, $signature, SPA_Challenge::PURPOSE_REGISTER ); /*
* 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 ) ) { if ( is_wp_error( $address ) ) {
return $address; return $address;
} }

View file

@ -167,10 +167,22 @@ final class SPA_REST {
if ( is_wp_error( $limited ) ) { if ( is_wp_error( $limited ) ) {
return $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( $address = SPA_Challenge::verify(
(string) $request->get_param( 'nonce' ), (string) $request->get_param( 'nonce' ),
(string) $request->get_param( 'signature' ), (string) $request->get_param( 'signature' ),
SPA_Challenge::PURPOSE_REGISTER SPA_Challenge::PURPOSE_REGISTER,
$claimed
); );
if ( is_wp_error( $address ) ) { if ( is_wp_error( $address ) ) {
return self::with_status( $address, 400 ); return self::with_status( $address, 400 );

View file

@ -140,34 +140,20 @@ add_filter(
2 2
); );
/** /*
* Plugins that check "is this a real email?" during their own setup. * Note on is_email(): no filter is needed.
* *
* `is_email()` correctly rejects a `.invalid` address the domain is * It would be reasonable to assume WordPress rejects a `.invalid` address,
* reserved precisely so that it fails. That correctness is a problem for a * since RFC 2606 reserves that TLD precisely so it can never resolve and an
* setup wizard that refuses to advance, so `.invalid` addresses under *this * earlier version of this file carried a filter to force such addresses
* site's own* placeholder domain are allowed through, and nothing else is. * through on that assumption. The assumption is wrong. `is_email()` validates
* Narrowing it to our own domain matters: a contact form that accepts * syntax, not whether a domain could ever exist, so `noreply@…​.invalid`
* `someone@example.invalid` from a visitor would silently collect addresses * already passes and the filter never fired.
* that can never be replied to. *
* 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.
*/ */
add_filter(
'is_email',
function ( $is_email, $email ) {
if ( $is_email || ! class_exists( 'SP_Settings' ) ) {
return $is_email;
}
$domain = '@' . strtolower( SP_Settings::stub_email_domain() );
if ( substr( strtolower( (string) $email ), -strlen( $domain ) ) !== $domain ) {
return $is_email;
}
// Same local-part rules core applies, minus the domain check it fails.
$local = substr( (string) $email, 0, strlen( (string) $email ) - strlen( $domain ) );
return (bool) preg_match( '/^[a-zA-Z0-9!#$%&\'*+\/=?^_`{|}~\.\-]+$/', $local );
},
10,
2
);
/** /**
* A short explanation on the plugins screen, next to anything known to want * A short explanation on the plugins screen, next to anything known to want

View file

@ -99,8 +99,6 @@ register_activation_hook(
add_action( add_action(
'plugins_loaded', 'plugins_loaded',
function () { function () {
load_plugin_textdomain( 'sirius-press', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
if ( (int) get_option( 'sirius_press_inbox_version', 0 ) < SP_Inbox::VERSION ) { if ( (int) get_option( 'sirius_press_inbox_version', 0 ) < SP_Inbox::VERSION ) {
SP_Inbox::install(); SP_Inbox::install();
} }
@ -111,6 +109,19 @@ add_action(
} }
); );
/*
* 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( add_action(
'admin_notices', 'admin_notices',
function () { function () {

View file

@ -213,6 +213,22 @@ final class SPE_Admin {
return; 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( printf(
'<p>%s <code>%s</code>%s</p>', '<p>%s <code>%s</code>%s</p>',
esc_html__( 'Publishing to', 'sirius-press' ), esc_html__( 'Publishing to', 'sirius-press' ),

View file

@ -114,16 +114,20 @@ final class SPE_Renderer {
// are normalised to absolute first, so the pass below only has one // are normalised to absolute first, so the pass below only has one
// shape to match. // shape to match.
// //
// The lookbehind is load-bearing: without it this also matches the // Two details, both of which produced mangled links when they were
// `//example.test/` sitting inside every `https://example.test/`, // missing. The lookbehind stops this from also matching the
// and every absolute URL on the page gains a second scheme. // `//example.test` sitting inside every `https://example.test`, which
foreach ( array_unique( array_filter( array( // would give every absolute URL on the page a second scheme. And the
(string) wp_parse_url( $home, PHP_URL_HOST ), // authority has to include the port: replacing `//host` alone inside
(string) wp_parse_url( $site, PHP_URL_HOST ), // `//host:8760/x` leaves the port behind, producing `//host:8760:8760/x`.
) ) ) as $host ) { foreach ( array_unique( array_filter( array( $home, $site ) ) ) as $base ) {
$authority = preg_replace( '#^https?:#', '', $base ); // '//host[:port]'
if ( '' === $authority ) {
continue;
}
$html = preg_replace( $html = preg_replace(
'#(?<!:)//' . preg_quote( $host, '#' ) . '#', '#(?<!:)' . preg_quote( $authority, '#' ) . '#',
$home, $base,
$html $html
); );
} }

View file

@ -89,8 +89,16 @@ if ( ! function_exists( 'wp_parse_url' ) ) {
} }
if ( ! function_exists( 'home_url' ) ) { 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 = '' ) { function home_url( $path = '' ) {
return 'https://example.test' . ( '' !== $path ? $path : '' ); $base = isset( $GLOBALS['sirius_test_home'] ) ? $GLOBALS['sirius_test_home'] : 'https://example.test';
return $base . ( '' !== $path ? $path : '' );
} }
} }

416
tests/live.mjs Normal file
View file

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

153
tests/mock-gateway.mjs Normal file
View file

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

@ -121,6 +121,38 @@ T::is(
); );
T::ok( ! isset( $result['assets']['about/index.html'] ), 'a page is not collected as an asset' ); 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 // 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 // internal link would carry it, and the published copy would then be asking
// for the export-rendered variant of every page. // for the export-rendered variant of every page.

View file

@ -119,11 +119,32 @@ say "built dist/sirius-press ($(du -sh "$TARGET" | cut -f1))"
# --------------------------------------------------------------------- zip # --------------------------------------------------------------------- zip
if [ "$MAKE_ZIP" -eq 1 ]; then if [ "$MAKE_ZIP" -eq 1 ]; then
command -v zip >/dev/null || die "zip is required for --zip"
archive="$DIST/sirius-press-$VERSION.zip" archive="$DIST/sirius-press-$VERSION.zip"
say "packing $(basename "$archive")" say "packing $(basename "$archive")"
rm -f "$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 ) ( 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" sha256sum "$archive" | cut -d' ' -f1 > "$archive.sha256"
say "$(basename "$archive")$(du -h "$archive" | cut -f1), sha256 $(cat "$archive.sha256")" say "$(basename "$archive")$(du -h "$archive" | cut -f1), sha256 $(cat "$archive.sha256")"
fi fi