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:
parent
5465b65756
commit
ddf49a5523
20 changed files with 1078 additions and 96 deletions
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -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/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
|
||||
|
||||
- The plugin compatibility matrix in docs is reasoned from each plugin's setup
|
||||
path, not yet confirmed against a running install.
|
||||
- No instance has been stood up end to end against a live chipnet name, so the
|
||||
full publish path is verified by unit and interop tests rather than by a
|
||||
round trip through the gateway.
|
||||
- `install.sh` and the Docker stack are written and syntax-checked but have
|
||||
not been run on a clean Ubuntu box.
|
||||
- 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.
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -124,7 +124,7 @@ mu-plugins/ the bits that must load before plugins do
|
|||
patches/ the core diff — currently one file
|
||||
docker/ compose stack: MariaDB, PHP-FPM, nginx
|
||||
tools/ build, upstream update, release
|
||||
tests/ 132 checks, no framework, runs in under a second
|
||||
tests/ 138 unit checks plus a live end-to-end suite
|
||||
docs/
|
||||
```
|
||||
|
||||
|
|
@ -160,15 +160,34 @@ the procedure and how to switch to a vendored subtree if you would rather.
|
|||
## Testing
|
||||
|
||||
```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
|
||||
`tests/interop.mjs`, which pins the browser wallet against the PHP one: both
|
||||
implement secp256k1, RFC 6979, BIP-32 and BIP-137 independently, and every
|
||||
vector in the suite came from libauth — the library the Sirius portal wallet
|
||||
and the BNS gateway both use. A signature made in a browser verifies on the
|
||||
server and at the gateway, or the suite fails.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -30,43 +30,46 @@ messages end up.
|
|||
Mail addressed to a real domain is passed straight through. If you configured
|
||||
SMTP, it sends. If you did not, it fails exactly as stock WordPress fails.
|
||||
|
||||
`is_email()` is left alone, except for one narrow case: addresses under **this
|
||||
site's own** placeholder domain are accepted, because a setup wizard that
|
||||
validates its own default and refuses to advance is a wizard you cannot get
|
||||
past. Every other `.invalid` address is still correctly rejected — a contact
|
||||
form that silently accepted `someone@example.invalid` from a visitor would be
|
||||
collecting addresses nobody can ever reply to.
|
||||
`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
|
||||
|
||||
**Status: predicted, not yet verified.** The table below is derived from
|
||||
reading each plugin's setup and activation paths against what this fork
|
||||
changes — not from installing them. Sirius Press 0.1.0 has not been run
|
||||
against a live plugin set yet, and until it has, treat every row as a claim
|
||||
awaiting a test rather than a result.
|
||||
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.
|
||||
|
||||
The rows are written to be falsifiable: each says what should happen, so a
|
||||
first real run either confirms it or produces a bug report. If you install one
|
||||
of these and it behaves differently, that is worth reporting — the shim it
|
||||
needs is usually three lines.
|
||||
| Plugin | 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. |
|
||||
|
||||
| Plugin | Installs | Activates | Notes |
|
||||
|---|---|---|---|
|
||||
| **Yoast SEO** | yes | yes | Works unchanged. Its XML sitemaps export to your name with everything else. Reads `admin_email` for schema output; gets the placeholder. |
|
||||
| **Contact Form 7** | yes | yes | Forms build, save and submit. The default recipient is the placeholder, so submissions land in the inbox — fine for a small site, not what you want for a real contact form. Set a real address and configure SMTP. |
|
||||
| **WooCommerce** | yes | yes | Store, products and checkout all work. Order emails to customers use the address the customer typed, so they send once SMTP is configured. The setup wizard's store-address field is pre-filled with a placeholder so it will advance. |
|
||||
| **Elementor** | yes | yes | No email dependency. Pages built with it export normally. |
|
||||
| **Wordfence** | yes | yes | Alert emails go to the inbox unless you set a real address. Its login-security features overlap with wallet auth; two-factor on top of a signature is redundant, and its "email me a code" option cannot work. |
|
||||
| **WP Super Cache / W3 Total Cache** | yes | yes | Compatible, but consider whether you need them: the static export already serves cached HTML from the name, which is the harder-working cache. |
|
||||
| **Akismet** | yes | yes | Needs an API key, which is obtained on akismet.com with a real address of yours. Nothing to do with site accounts. |
|
||||
| **Jetpack** | yes | partly | Connection requires a WordPress.com account and works, but the modules built around subscriber email lists cannot do anything useful here. Not recommended. |
|
||||
| **UpdraftPlus** | yes | yes | Backups work. Report emails go to the inbox. Back up `wp-config.php` too, or you lose `SIRIUS_PRESS_KEY` and with it the stored publishing phrase. |
|
||||
| **Advanced Custom Fields** | yes | yes | No email dependency at all. |
|
||||
|
||||
"Activates" means the expectation is: no fatal, no failed setup wizard, and
|
||||
the plugin's own status screen reporting itself healthy.
|
||||
"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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
152
docs/testing.md
Normal file
152
docs/testing.md
Normal 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.
|
||||
|
|
@ -48,6 +48,7 @@
|
|||
|
||||
.sirius-wallet__phrase-input,
|
||||
.sirius-wallet__signature,
|
||||
.sirius-wallet__address-input,
|
||||
#sirius_derive_phrase {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
|
@ -157,3 +158,10 @@
|
|||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sirius-wallet__address-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,9 +40,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
function setSignature(block, signature) {
|
||||
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) {
|
||||
|
|
@ -63,7 +77,7 @@
|
|||
prefix: CONFIG.prefix,
|
||||
path: CONFIG.path,
|
||||
});
|
||||
setSignature(block, await wallet.sign(message));
|
||||
setSignature(block, await wallet.sign(message), wallet.address);
|
||||
} catch (err) {
|
||||
setStatus(block, err.message || String(err), "error");
|
||||
return;
|
||||
|
|
@ -85,7 +99,7 @@
|
|||
const message = block.querySelector(".sirius-wallet__message").value;
|
||||
setStatus(block, strings.signing || "Signing…");
|
||||
try {
|
||||
setSignature(block, await external.sign(message));
|
||||
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.
|
||||
|
|
@ -97,6 +111,10 @@
|
|||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -15,11 +15,27 @@
|
|||
* nonce, so the same signature cannot be replayed.
|
||||
*
|
||||
* **The message is reconstructed, never trusted.** The client sends back the
|
||||
* nonce and what it was for; this class rebuilds the exact text from those
|
||||
* and verifies against the rebuilt copy. A client that lies about the purpose
|
||||
* is verifying against a message the user never signed, so it fails. That is
|
||||
* also why a login signature cannot be replayed to create an account: the
|
||||
* purpose is inside the signed bytes.
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -146,9 +162,14 @@ final class SPA_Challenge {
|
|||
* @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 ) {
|
||||
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' ) );
|
||||
}
|
||||
|
|
@ -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' ) );
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ final class SPA_Login {
|
|||
}
|
||||
$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' );
|
||||
|
|
@ -77,7 +82,7 @@ final class SPA_Login {
|
|||
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 ) ) {
|
||||
return $address;
|
||||
}
|
||||
|
|
@ -206,6 +211,18 @@ final class SPA_Login {
|
|||
<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
|
||||
|
|
@ -252,6 +269,21 @@ final class SPA_Login {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -121,13 +121,31 @@ final class SPA_Profile {
|
|||
}
|
||||
$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.
|
||||
}
|
||||
|
||||
$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 ) ) {
|
||||
SP_Inbox::add( $user_id, __( 'Wallet not attached', 'sirius-press' ), $address->get_error_message(), 'auth' );
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -90,13 +90,28 @@ final class SPA_Register {
|
|||
$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' ) );
|
||||
}
|
||||
|
||||
$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 ) ) {
|
||||
return $address;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,10 +167,22 @@ final class SPA_REST {
|
|||
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
|
||||
SPA_Challenge::PURPOSE_REGISTER,
|
||||
$claimed
|
||||
);
|
||||
if ( is_wp_error( $address ) ) {
|
||||
return self::with_status( $address, 400 );
|
||||
|
|
|
|||
|
|
@ -140,34 +140,20 @@ add_filter(
|
|||
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
|
||||
* reserved precisely so that it fails. That correctness is a problem for a
|
||||
* setup wizard that refuses to advance, so `.invalid` addresses under *this
|
||||
* site's own* placeholder domain are allowed through, and nothing else is.
|
||||
* Narrowing it to our own domain matters: a contact form that accepts
|
||||
* `someone@example.invalid` from a visitor would silently collect addresses
|
||||
* that can never be replied to.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -99,8 +99,6 @@ register_activation_hook(
|
|||
add_action(
|
||||
'plugins_loaded',
|
||||
function () {
|
||||
load_plugin_textdomain( 'sirius-press', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
|
||||
|
||||
if ( (int) get_option( 'sirius_press_inbox_version', 0 ) < SP_Inbox::VERSION ) {
|
||||
SP_Inbox::install();
|
||||
}
|
||||
|
|
@ -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(
|
||||
'admin_notices',
|
||||
function () {
|
||||
|
|
|
|||
|
|
@ -213,6 +213,22 @@ final class SPE_Admin {
|
|||
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' ),
|
||||
|
|
|
|||
|
|
@ -114,16 +114,20 @@ final class SPE_Renderer {
|
|||
// are normalised to absolute first, so the pass below only has one
|
||||
// shape to match.
|
||||
//
|
||||
// The lookbehind is load-bearing: without it this also matches the
|
||||
// `//example.test/` sitting inside every `https://example.test/`,
|
||||
// and every absolute URL on the page gains a second scheme.
|
||||
foreach ( array_unique( array_filter( array(
|
||||
(string) wp_parse_url( $home, PHP_URL_HOST ),
|
||||
(string) wp_parse_url( $site, PHP_URL_HOST ),
|
||||
) ) ) as $host ) {
|
||||
// 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( $host, '#' ) . '#',
|
||||
$home,
|
||||
'#(?<!:)' . preg_quote( $authority, '#' ) . '#',
|
||||
$base,
|
||||
$html
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,8 +89,16 @@ if ( ! function_exists( 'wp_parse_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 = '' ) {
|
||||
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
416
tests/live.mjs
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/—/g, "—")
|
||||
.replace(/’/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
153
tests/mock-gateway.mjs
Normal 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}`);
|
||||
});
|
||||
|
|
@ -121,6 +121,38 @@ T::is(
|
|||
);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -119,11 +119,32 @@ say "built dist/sirius-press ($(du -sh "$TARGET" | cut -f1))"
|
|||
# --------------------------------------------------------------------- zip
|
||||
|
||||
if [ "$MAKE_ZIP" -eq 1 ]; then
|
||||
command -v zip >/dev/null || die "zip is required for --zip"
|
||||
archive="$DIST/sirius-press-$VERSION.zip"
|
||||
say "packing $(basename "$archive")"
|
||||
rm -f "$archive"
|
||||
(cd "$DIST" && zip -qr "$archive" sirius-press)
|
||||
|
||||
# `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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue