From 17b965a50d8dfa41ac54f1c78394eea40e839b2b Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 14:21:01 -0500 Subject: [PATCH 1/3] feat(wordpress): add browser-local signing --- README.md | 61 +- docs/developer-guide.md | 9 +- docs/html-protocol.md | 37 +- wordpress/CHANGELOG.md | 20 +- wordpress/README.md | 75 ++ ...-content-signing-admin-author-profiles.php | 99 ++- ...ss-content-signing-admin-post-meta-box.php | 28 +- .../class-content-signing-admin-settings.php | 13 +- .../admin/js/content-signing-post-meta-box.js | 245 +++--- wordpress/composer.json | 4 +- wordpress/composer.lock | 68 +- wordpress/content-signing.php | 6 +- wordpress/docker/test.Dockerfile | 4 +- wordpress/docs/local-signing-audit.md | 316 ++++---- .../class-content-signing-activator.php | 8 +- .../class-content-signing-api-client.php | 7 +- .../includes/class-content-signing-hooks.php | 66 +- .../class-content-signing-scheduler.php | 6 +- .../class-content-signing-signing-service.php | 748 ++++++++++++++---- .../includes/db/class-content-signing-db.php | 79 +- .../public/class-content-signing-display.php | 90 +++ .../public/class-content-signing-public.php | 50 +- ...s-content-signing-api-client-test-case.php | 5 + .../class-content-signing-db-test-case.php | 4 +- ...-content-signing-admin-author-profiles.php | 118 +++ .../tests/test-content-signing-api-client.php | 13 +- .../test-content-signing-browser-assets.php | 28 + wordpress/tests/test-content-signing-db.php | 16 +- .../test-content-signing-integration.php | 170 +++- .../tests/test-content-signing-scheduler.php | 4 +- .../test-content-signing-signing-service.php | 582 ++++++++++++-- 31 files changed, 2284 insertions(+), 695 deletions(-) create mode 100644 wordpress/README.md create mode 100644 wordpress/tests/test-content-signing-admin-author-profiles.php create mode 100644 wordpress/tests/test-content-signing-browser-assets.php diff --git a/README.md b/README.md index 74095ea..042be8a 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,14 @@ The WordPress plugin and Hugo build integration are runnable. Drupal, Joomla, an ## WordPress prerequisites - WordPress 5.0+ -- PHP 7.2+ at runtime -- PHP Intl extension +- PHP 8.5+ +- PHP sodium, DOM, Intl, mbstring, and OpenSSL extensions - Composer -- A running [HTMLTrust trust directory server](https://github.com/HTMLTrust/htmltrust-server-reference) +- HTTPS for published URLs -The plugin's Composer runtime constraint is PHP `>=7.2`. Development and test -dependencies are newer: the lock file currently resolves PHPUnit 9.6.34, which -requires PHP `>=7.3`. The Docker test image uses PHP 8.3 as the supported test -baseline. +The plugin uses the v1 `htmltrust/canonicalization` dependency from its Git +repository until the v1 package is released. Browser-local signing does not +need a trust directory during publication. ## Quick start @@ -30,7 +29,7 @@ cd wordpress/ composer install ``` -Symlink the `wordpress/` directory into `wp-content/plugins/`, or zip it and install it through the WordPress admin. Configure a server profile, link a WordPress user to a registered author identity, and enable signing for the post types you want to publish. +Symlink the `wordpress/` directory into `wp-content/plugins/`, or zip it and install it through the WordPress admin. Link the post author to a signing profile and enable signing for the post types you want to publish. ### Hugo @@ -41,10 +40,11 @@ Copy the partials from `hugo/layouts/partials/` into your Hugo project, then fol When an author publishes content, the plugin: - **Canonicalizes** rendered content, including signed semantic attributes, and computes a SHA-256 content hash -- **Builds** direct-child claims, computes their canonical claims hash, and binds both hashes to the publication origin and signed-at timestamp -- **Requests** a compatibility signature from the HTMLTrust trust directory using the configured author API credential; the server performs signing for the registered author identity +- **Builds** direct-child claims, computes their canonical claims hash, and builds the frozen v1 RFC 8785 signing payload with profile, algorithm, key ID, scope, location, hashes, and timestamp +- **Queues** headless and scheduled publications for a later author browser session because those contexts have no local private key +- **Signs** in the author's browser with a non-extractable IndexedDB Ed25519 key, then verifies the returned signature in PHP before persistence - **Embeds** the signature, key reference, algorithm, content hash, signed-at claim, and direct-child claims into the published HTML -- **Supports** multiple author profiles, endorser profiles, and claim metadata (content type, license, AI involvement, etc.) +- **Retains** legacy remote author and endorser records for migration, alongside claim metadata (content type, license, AI involvement, etc.) - **Displays** signature status on the frontend with verification controls ## Architecture @@ -94,10 +94,10 @@ Then either: ### Configuration 1. Navigate to **Settings → Content Signing** in the WordPress admin -2. Add a **Server Profile** pointing to your HTMLTrust trust directory server URL -3. Create **Author Profiles** linking WordPress users to server-side author identities +2. Add an **Author Profile** for each post author. Choose **Browser-local only** for editor signing without a trust directory. It uses `local-wp-user-{ID}` and needs no API key. +3. Add a **Server Profile** only for legacy remote identities. Remote profiles keep their API key workflow and cannot be used by the local browser path. 4. Enable signing for your desired post types -5. Publish a post — it will be automatically signed +5. Publish a post, then open it as its author and select **Sign Now**. Browser-local profiles cannot be site endorsers. ### Running Tests @@ -108,7 +108,7 @@ repository root, run: ./wordpress/bin/test-docker.sh ``` -This builds a PHP 8.3 test image, starts MariaDB 11.8.2, waits for its health +This builds a PHP 8.5 test image, starts MariaDB 11.8.2, waits for its health check, installs the exact Composer lock file, downloads the WordPress 6.9.4 core and test suite into Docker-managed volumes, then runs PHPUnit. The image and database tags are pinned by digest. Generated WordPress assets @@ -122,14 +122,14 @@ Run the coding-standard check separately, or remove the cached test assets: ./wordpress/bin/test-docker.sh --clean ``` -The lock file resolves `htmltrust/canonicalization` v0.2.2. That is the -currently supported compatibility release for this plugin and is the version -covered by the Docker test path. +The lock file resolves the v1 API from the `htmltrust/canonicalization` Git +repository. Pin a released v1 package before distributing the plugin outside +this reference repository. The current checkout contains existing WordPress Coding Standards violations, so `--lint` reports a nonzero result after PHPUnit completes. Keeping that check explicit makes the default test command a reliable pass/fail signal for the -55-test suite. +current PHPUnit suite, which contains 80 tests in this checkout. ### Manual test setup @@ -154,25 +154,24 @@ database named by the first argument to exist or for the database user to be allowed to create it. For a development container, open this repository in VS Code Dev Containers. -The configuration provides PHP 8.3, Composer, Node 22, Go 1.25, and Hugo +The configuration provides PHP 8.5, Composer, Node 22, Go 1.25, and Hugo Extended 0.161.1. Run the same commands above from `wordpress/` after the container starts. -### Using the reference server +### Legacy compatibility -The WordPress plugin's signing client is compatible with the Node reference -server in `htmltrust-server-reference`. Start that server at -`http://localhost:3000`, then configure the plugin's server profile with that -URL. The plugin uses the server's author API key for `POST /api/content/sign` -and sends the publication origin as the `domain` field. Use an origin such as -`https://example.com`, including the scheme and optional port. +Existing remote signatures remain readable during migration. The publication +path does not call the remote signing endpoint. A future headless signer must +sign the same v1 payload and publish a resolver-compatible public key. ## The HTML Protocol Signed content is embedded with a `` wrapper around the actual signed content: ```html - @@ -203,9 +202,9 @@ This project is licensed under the [PolyForm Noncommercial License 1.0.0](https: ## Origin & Contributions -HTMLTrust is an idea I (Jason Grey) have been chewing on since 2024. I'm not an academic — I'm an engineer with a day job and a family — so the spec, the reference implementations, and most of this prose have been written with significant help from AI tools acting as research assistant, technical writer, and pair programmer. I wrote the original architectural sketches and reviewed every line; the assistants filled in the gaps and saved me from re-typing the same explanation for the hundredth time. +HTMLTrust is an idea I (Jason Grey) have been chewing on since 2024. I'm not an academic. I'm an engineer with a day job and a family, so the spec, the reference implementations, and most of this prose have been written with significant help from AI tools acting as research assistant, technical writer, and pair programmer. I wrote the original architectural sketches and reviewed every line; the assistants filled in the gaps and saved me from re-typing the same explanation for the hundredth time. -**Contributions are welcome — human or AI-assisted, doesn't matter to me.** What matters is whether the code, the spec text, or the conformance vectors move the project forward. Open a PR. +**Contributions are welcome, whether human or AI-assisted.** What matters is whether the code, the spec text, or the conformance vectors move the project forward. Open a PR. What this project is **not** a forum for: @@ -213,6 +212,6 @@ What this project is **not** a forum for: - Opinions on who is or isn't trustworthy on the web. - Politics, religion, professional practice, or personal philosophy. -HTMLTrust is a mechanism — a way for *anyone* to sign content they publish and for *anyone* to decide whom they trust, on their own terms. The project takes no position on what the right answers are; it just provides the tools. If you want to debate the answers, there are entire continents of the internet better suited to it. +HTMLTrust is a mechanism, a way for *anyone* to sign content they publish and for *anyone* to decide whom they trust on their own terms. The project takes no position on what the right answers are; it provides the tools. If you want to debate the answers, there are entire continents of the internet better suited to it. If this work is useful to you and you'd like to support it, see [GitHub Sponsors](https://github.com/sponsors/jt55401) or the other channels in [`.github/FUNDING.yml`](.github/FUNDING.yml). diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 1210a43..f62b13b 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -50,11 +50,13 @@ $servers = $db->get_servers(); ### ContentSigning_API_Client -Responsible for all communication with the external Content Signing API. +Responsible for legacy API reads and verification. Content signing happens in +the author's browser and the server-side signing method returns a disabled +error. ```php $api_client = new ContentSigning_API_Client($api_url, $api_key, $db); -$result = $api_client->sign_content($content_data, $author_api_key); +$result = $api_client->verify_content($verification_data); ``` ### ContentSigning_Signing_Service @@ -63,7 +65,8 @@ Orchestrates the signing process, determining when to sign, preparing data, and ```php $signing_service = new ContentSigning_Signing_Service($db, $api_client, $scheduler); -$result = $signing_service->sign_post($post_id); +$prepared = $signing_service->prepare_local_signing($post_id, $keyid); +$result = $signing_service->complete_local_signing($post_id, $signed_payload); ``` ### ContentSigning_Scheduler diff --git a/docs/html-protocol.md b/docs/html-protocol.md index fb25ee9..8a0750d 100644 --- a/docs/html-protocol.md +++ b/docs/html-protocol.md @@ -10,18 +10,23 @@ Signed content uses the `` custom HTML element, as defined in th ### Required Attributes -Per spec §2.1, the wrapper element carries exactly four required attributes: +Frozen v1 signatures carry the required identity, profile, scope, location, +and cryptographic attributes below. Older producers may emit only the legacy +four-attribute subset, but v1 producers and verifiers use the complete set. | Attribute | Description | Example | |---|---|---| | `keyid` | Identifies the signer; resolved per the rules in **Identity and Key Resolution** below. May be a DID, a direct URL to a public key document, or a trust-directory reference. | `keyid="did:web:author.example"` | -| `signature` | Base64-encoded (unpadded) cryptographic signature over the canonical binding string defined in **Signature Data Format** | `signature="aBcDeF123..."` | +| `signature` | Base64-encoded (unpadded) cryptographic signature over the canonical v1 payload defined in **Signature Data Format** | `signature="aBcDeF123..."` | | `content-hash` | Hash of the canonicalized content, prefixed with the hash algorithm and encoded as unpadded standard Base64 | `content-hash="sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"` | | `algorithm` | Signature algorithm. Required by the spec; implementations MAY default to `ed25519` when the attribute is omitted, but producers SHOULD always emit it explicitly. | `algorithm="ed25519"` | +| `profile` | Frozen signing profile used to construct the signed v1 payload | `profile="htmltrust-signature-v1"` | +| `signature-scope` | Scope value used in the signed payload | `signature-scope="url"` | +| `location` | Scope-derived document location bound into the signed payload | `location="https://example.com/article"` | -### Optional Attributes +### Legacy attributes -There are **no** optional attributes on the `` wrapper itself in this revision. All claim and contextual metadata (author name, signed-at timestamp, license, content type, AI assistance, etc.) belongs in inner `` elements as documented under **Inner Metadata** below. This keeps the wrapper's attribute surface narrow and easy to validate. +Legacy producers may omit the v1 profile, scope, and location attributes. Frozen v1 producers include them. Claim and contextual metadata such as author name, signed-at timestamp, license, content type, and AI assistance belongs in inner `` elements as documented under **Inner Metadata** below. Presentational attributes such as `style` and `class` SHOULD NOT be set inline on ``. Styling is the user agent's responsibility (see the **CSS** section at the bottom of this document); inline presentational attributes mix concerns and are unnecessary for protocol conformance. @@ -35,7 +40,7 @@ Presentational attributes such as `style` and `class` SHOULD NOT be set inline o ## Inner Metadata -The `` element MAY contain `` tags that describe the signature's claims and context. This makes signatures self-describing — a crawler or verifier can read the claims directly from the HTML without calling the trust directory API. +The `` element MAY contain `` tags that describe the signature's claims and context. This makes signatures self-describing. A crawler or verifier can read the claims directly from the HTML without calling the trust directory API. ### Standard Meta Names @@ -65,6 +70,9 @@ The `` element wraps the signed content: @@ -145,16 +153,21 @@ Future revisions MAY extend the signed attribute list. Verifiers for this revisi ## Signature Data Format -The signature binds four values, concatenated with `:` separators: +Frozen v1 signatures use an RFC 8785 JSON payload. The payload includes the +profile, content hash, claims hash, document URL, scope-derived location, +key identifier, algorithm, and exact signed-at timestamp. The canonical JSON +UTF-8 bytes are signed directly. + +Legacy signatures bind four values, concatenated with `:` separators: ``` {content-hash}:{claims-hash}:{domain}:{signed-at} ``` -- `content-hash` — hash of the canonicalized text content (see above) -- `claims-hash` — SHA-256 hash of the canonical serialization of all inner `` claim elements, ordered lexically by name (ensures tamper-evident claim metadata) -- `domain` — the serialized Web origin where the content is authoritatively published, using the legacy field name retained by the protocol -- `signed-at` — the ISO-8601 timestamp from the `` element +- `content-hash`: hash of the canonicalized text content (see above) +- `claims-hash`: SHA-256 hash of the canonical serialization of all inner `` claim elements, ordered lexically by name (ensures tamper-evident claim metadata) +- `domain`: the serialized Web origin where the content is authoritatively published, using the legacy field name retained by the protocol +- `signed-at`: the ISO-8601 timestamp from the `` element For example: ``` @@ -179,8 +192,8 @@ A verifying client (browser extension, crawler, library) performs these steps ** 4. **Canonicalize** the inner text content per the rules above and compute its hash 5. **Compare** the computed hash with the `content-hash` attribute (content integrity check) 6. **Compute** the `claims-hash` from the canonical serialization of inner `` claim elements -7. **Construct** the binding string `{content-hash}:{claims-hash}:{domain}:{signed-at}` -8. **Verify** the cryptographic signature over the binding string using the resolved public key and the declared `algorithm` +7. **Construct** the frozen v1 RFC 8785 payload from the profile, content hash, claims hash, document URL, scope, key ID, algorithm, and signed-at timestamp +8. **Verify** the cryptographic signature over the exact UTF-8 payload bytes using the resolved public key and the declared `algorithm` This layer produces a deterministic yes/no result: either the signature is cryptographically valid or it is not. No server or directory is required for this step beyond whatever key resolution demands. diff --git a/wordpress/CHANGELOG.md b/wordpress/CHANGELOG.md index 2a3f63c..1ddbce6 100644 --- a/wordpress/CHANGELOG.md +++ b/wordpress/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the Content Signing for WordPress plugin will be document The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-08-28 + +### Added +- Browser-local Ed25519 signing with non-extractable IndexedDB keys +- Two-pass server-rendered payload preparation and PHP verification +- Public local-key resolution through the WordPress REST API +- Durable queue entries for scheduled and headless posts +- Resolver-compatible SPKI public-key documents and rendered-byte drift checks +- Browser-local author profiles with server ID 0, deterministic local identity, + and no API key requirement +- One-time prepare tokens and immutable key ID to public key bindings + +### Changed +- Publication hooks no longer call the remote trust-server signing endpoint +- WebAuthn is documented as a separate artifact from HTMLTrust payload signatures. +- Only the WordPress post author can prepare or complete browser-local signing. +- Legacy server-side signing and endorsement execution are disabled. + ## [1.0.0] - 2025-05-05 ### Added @@ -45,4 +63,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Known Issues - Admin interface incomplete - No test coverage -- Limited documentation \ No newline at end of file +- Limited documentation diff --git a/wordpress/README.md b/wordpress/README.md new file mode 100644 index 0000000..2091eb3 --- /dev/null +++ b/wordpress/README.md @@ -0,0 +1,75 @@ +# HTMLTrust WordPress reference plugin + +This plugin signs WordPress content in the author's browser. PHP renders the +filtered post, computes the HTMLTrust hashes, and returns the exact signing +payload. The editor signs that payload with an Ed25519 Web Crypto key stored +in IndexedDB. PHP verifies the signature before saving it. + +The current v1 canonicalization dependency requires PHP 8.5 and the sodium, +DOM, intl, mbstring, and OpenSSL extensions. + +The private key is non-extractable and is never included in an AJAX request. +The public key is published through the read-only endpoint named by the +signature's `keyid`: + +``` +/wp-json/htmltrust/v1/keys/{key-id} +``` + +The endpoint returns `publicKeyEncoding: "spki-der"` and canonical unpadded +standard Base64 for the Ed25519 SubjectPublicKeyInfo bytes. Published v1 +sections use `signature-scope="url"` and bind the exact HTTPS document URL. + +## Install and test + +From this directory: + +```sh +composer install +composer test +composer phpcs +``` + +The integration suite needs a WordPress test installation. Configure the +standard `WP_TESTS_DIR` environment variable and run `composer test`. + +## Publish behavior + +In **Author Profiles**, select **Browser-local only** for an editor profile +that signs without a trust directory. The local identity defaults to +`local-wp-user-{ID}`. The profile has no API key and cannot be a site endorser. +Remote server and author API key fields remain available for legacy profile +migrations. + +Use **Sign Now** in the Content Signing post box. The plugin performs two +requests: + +1. PHP returns the filtered content hashes, claims, timestamp, and exact + frozen v1 RFC 8785 payload, including profile, key ID, algorithm, scope, + location, hashes, and timestamp. +2. The browser returns the signature and public key. PHP recomputes the + payload and verifies the Ed25519 signature before persistence. + +If the post changes between requests, PHP rejects the signature and asks the +editor to prepare a new one. A trust directory is not needed for signing. + +Scheduled, REST, XML-RPC, and mobile publishes do not have a browser key. +They remain unsigned and create an `awaiting-local-signature` queue record. +Open the post in the WordPress editor and select **Sign Now** after the post +is available. There is no server-side signing fallback. + +## Key rotation and recovery + +Select **Rotate local key** in the post box to generate a fresh key. Existing +signatures remain tied to their previous public key and continue to verify. +The current key is stored only in this browser profile. Clearing site data, +losing the browser profile, or moving to another device requires key rotation; +the old private key cannot be recovered by WordPress. + +Back up the published content and its signature history before rotating. A +future external signer can support headless publishing, but it must sign the +same payload and publish a resolvable public key. WebAuthn assertions are a +separate artifact and cannot replace the Ed25519 HTMLTrust payload signature. + +See [the local-signing audit](docs/local-signing-audit.md) for the threat +model, migration boundary, and remaining gates. diff --git a/wordpress/admin/class-content-signing-admin-author-profiles.php b/wordpress/admin/class-content-signing-admin-author-profiles.php index 3420f0d..6bd26a2 100644 --- a/wordpress/admin/class-content-signing-admin-author-profiles.php +++ b/wordpress/admin/class-content-signing-admin-author-profiles.php @@ -142,22 +142,44 @@ private function handle_add_author() { // Get and sanitize form data $wp_user_id = isset($_POST['wp_user_id']) ? intval($_POST['wp_user_id']) : 0; $signing_author_id = isset($_POST['signing_author_id']) ? sanitize_text_field($_POST['signing_author_id']) : ''; - $server_id = isset($_POST['server_id']) ? intval($_POST['server_id']) : 0; + $server_id = isset($_POST['server_id']) ? intval($_POST['server_id']) : -1; $author_api_key = isset($_POST['author_api_key']) ? sanitize_text_field($_POST['author_api_key']) : ''; $default_key_type = isset($_POST['default_key_type']) ? sanitize_text_field($_POST['default_key_type']) : 'HUMAN'; $default_claims = isset($_POST['default_claims']) ? $this->sanitize_claims($_POST['default_claims']) : array(); $is_site_endorser = isset($_POST['is_site_endorser']) ? 1 : 0; - // Validate required fields - if (!$wp_user_id || empty($signing_author_id) || !$server_id || empty($author_api_key)) { + if ($server_id === 0 && empty($signing_author_id) && $wp_user_id) { + $signing_author_id = $this->local_signing_author_id($wp_user_id); + } + + // Remote profiles keep their existing required API credentials. A + // browser-local profile has no server credential and uses a concrete + // local identity derived from the linked WordPress user. + if (!$wp_user_id || $server_id < 0 || empty($signing_author_id) || ($server_id !== 0 && empty($author_api_key))) { add_settings_error( 'content_signing_author', 'required_fields', - __('All fields are required.', 'content-signing'), + __('WordPress user, author identity, and a remote server API key are required for remote profiles.', 'content-signing'), + 'error' + ); + return; + } + + if ($server_id === 0 && $is_site_endorser) { + add_settings_error( + 'content_signing_author', + 'local_endorser', + __('Browser-local profiles cannot be site endorsers because endorsements require a remote server.', 'content-signing'), 'error' ); return; } + + if ($server_id === 0) { + // Local profiles never persist remote credentials. + $author_api_key = ''; + $is_site_endorser = 0; + } // Check if user already has an author profile $existing_author = $this->db->get_author_by_wp_user_id($wp_user_id); @@ -209,18 +231,38 @@ private function handle_edit_author() { // Get and sanitize form data $author_profile_id = isset($_POST['author_profile_id']) ? intval($_POST['author_profile_id']) : 0; $signing_author_id = isset($_POST['signing_author_id']) ? sanitize_text_field($_POST['signing_author_id']) : ''; - $server_id = isset($_POST['server_id']) ? intval($_POST['server_id']) : 0; + $server_id = isset($_POST['server_id']) ? intval($_POST['server_id']) : -1; $author_api_key = isset($_POST['author_api_key']) ? sanitize_text_field($_POST['author_api_key']) : ''; $default_key_type = isset($_POST['default_key_type']) ? sanitize_text_field($_POST['default_key_type']) : 'HUMAN'; $default_claims = isset($_POST['default_claims']) ? $this->sanitize_claims($_POST['default_claims']) : array(); $is_site_endorser = isset($_POST['is_site_endorser']) ? 1 : 0; - // Validate required fields - if (!$author_profile_id || empty($signing_author_id) || !$server_id) { + $existing_author = $author_profile_id ? $this->db->get_author($author_profile_id) : null; + if ($existing_author && $server_id === 0 && empty($signing_author_id)) { + $signing_author_id = $this->local_signing_author_id($existing_author->wp_user_id); + } + + // A remote profile may retain its existing key. Switching a local + // profile to a remote server requires a new remote credential. + $switching_to_remote_without_key = $existing_author + && (int) $existing_author->server_id === 0 + && $server_id !== 0 + && empty($author_api_key); + if (!$existing_author || $server_id < 0 || empty($signing_author_id) || $switching_to_remote_without_key) { add_settings_error( 'content_signing_author', 'required_fields', - __('Author profile ID, signing author ID, and server ID are required.', 'content-signing'), + __('Author profile, author identity, and a remote server API key are required when using a remote profile.', 'content-signing'), + 'error' + ); + return; + } + + if ($server_id === 0 && $is_site_endorser) { + add_settings_error( + 'content_signing_author', + 'local_endorser', + __('Browser-local profiles cannot be site endorsers because endorsements require a remote server.', 'content-signing'), 'error' ); return; @@ -234,6 +276,11 @@ private function handle_edit_author() { 'default_claims' => $default_claims, 'is_site_endorser' => $is_site_endorser, ); + + if ($server_id === 0) { + $data['author_api_key_encrypted'] = ''; + $data['is_site_endorser'] = 0; + } // Only update API key if provided if (!empty($author_api_key)) { @@ -500,7 +547,7 @@ private function render_author_form($author) {
-

+

@@ -547,12 +594,12 @@ private function render_author_form($author) { -

+

@@ -560,8 +607,8 @@ private function render_author_form($author) { - -

+ +

@@ -569,13 +616,13 @@ private function render_author_form($author) { - > +

@@ -612,10 +659,10 @@ private function render_author_form($author) {
-

+

@@ -780,7 +827,7 @@ private function render_authors_list($authors) { $user = get_userdata($author->wp_user_id); $display_name = $user ? $user->display_name : __('Unknown User', 'content-signing'); $server = $this->db->get_server($author->server_id); - $server_name = $server ? $server->name : __('Unknown Server', 'content-signing'); + $server_name = (int) $author->server_id === 0 ? __('Browser-local only', 'content-signing') : ($server ? $server->name : __('Unknown Server', 'content-signing')); ?> @@ -818,7 +865,7 @@ public function add_user_profile_fields($user) { db->get_server($author->server_id); - $server_name = $server ? $server->name : __('Unknown Server', 'content-signing'); + $server_name = (int) $author->server_id === 0 ? __('Browser-local only', 'content-signing') : ($server ? $server->name : __('Unknown Server', 'content-signing')); ?>

signing_author_id); ?>
@@ -886,6 +933,16 @@ private function sanitize_claims($claims_json) { return $sanitized; } + /** + * Derive the stable local identity used by a browser-local profile. + * + * @param int $wp_user_id WordPress user ID. + * @return string Local signing author identity. + */ + private function local_signing_author_id($wp_user_id) { + return 'local-wp-user-' . (int) $wp_user_id; + } + /** * Enqueue scripts and styles for the author profiles page. * @@ -895,4 +952,4 @@ private function sanitize_claims($claims_json) { public function enqueue_scripts() { // Enqueue author profiles-specific scripts and styles if needed } -} \ No newline at end of file +} diff --git a/wordpress/admin/class-content-signing-admin-post-meta-box.php b/wordpress/admin/class-content-signing-admin-post-meta-box.php index bc8e1d9..a9c2c13 100644 --- a/wordpress/admin/class-content-signing-admin-post-meta-box.php +++ b/wordpress/admin/class-content-signing-admin-post-meta-box.php @@ -83,6 +83,8 @@ public function render_meta_box($post) { // Check if the post author has a signing profile $author_profile = $this->db->get_author_by_wp_user_id($post->post_author); $has_author_profile = $author_profile !== null; + $is_local_profile = $has_author_profile && (int) $author_profile->server_id === 0; + $is_post_author = get_current_user_id() > 0 && (int) get_current_user_id() === (int) $post->post_author; // Get global signing settings $enable_signing = get_option('content_signing_enable_signing', true); @@ -116,7 +118,7 @@ public function render_meta_box($post) { $user = get_userdata($signature->wp_user_id); $user_name = $user ? $user->display_name : __('Unknown User', 'content-signing'); $server = $this->db->get_server($signature->server_id); - $server_name = $server ? $server->name : __('Unknown Server', 'content-signing'); + $server_name = (int) $signature->server_id === 0 ? __('Browser-local only', 'content-signing') : ($server ? $server->name : __('Unknown Server', 'content-signing')); $status_class = $signature->status === 'signed' ? 'signature-status-signed' : 'signature-status-' . $signature->status; ?>

  • @@ -139,11 +141,21 @@ public function render_meta_box($post) { - post_status === 'publish') : ?> + post_status, array('publish', 'future'), true)) : ?>

    +

    + +

    +

    + +

    + post_status, array('publish', 'future'), true)) : ?> +

    + post_status, array('publish', 'future'), true)) : ?> +

    wp_create_nonce('content_signing_post_' . $post_id), 'sign_post_confirm' => __('Are you sure you want to sign this post?', 'content-signing'), 'signing_text' => __('Signing...', 'content-signing'), + 'sign_post_text' => __('Sign Now', 'content-signing'), 'verifying_text' => __('Verifying...', 'content-signing'), + 'verify_text' => __('Verify', 'content-signing'), + 'valid_text' => __('Valid', 'content-signing'), + 'invalid_text' => __('Invalid', 'content-signing'), 'error_text' => __('Error:', 'content-signing'), + 'ajax_error' => __('The request failed.', 'content-signing'), + 'prepare_error' => __('Could not prepare the server-rendered payload.', 'content-signing'), + 'local_signing_error' => __('Local signing failed:', 'content-signing'), + 'rotate_confirm' => __('Rotate the local signing key? Existing signatures remain valid, but this browser will need the new key for future posts.', 'content-signing'), + 'author_id' => $post ? (int) $post->post_author : 0, + 'key_base_url' => trailingslashit(get_rest_url(null, 'htmltrust/v1/keys')), ) ); } -} \ No newline at end of file +} diff --git a/wordpress/admin/class-content-signing-admin-settings.php b/wordpress/admin/class-content-signing-admin-settings.php index 34bb557..5b27ad3 100644 --- a/wordpress/admin/class-content-signing-admin-settings.php +++ b/wordpress/admin/class-content-signing-admin-settings.php @@ -422,13 +422,8 @@ public function render_sign_days_after_publish_field() { * @return void */ public function render_enable_endorsements_field() { - $enable_endorsements = get_option('content_signing_enable_endorsements', false); ?> - -

    +


    -

    +

    db->get_author($profile_id); - if ($profile && $profile->is_site_endorser) { + if ($profile && $profile->is_site_endorser && (int) $profile->server_id > 0) { $sanitized[] = $profile_id; } } @@ -548,4 +543,4 @@ public function sanitize_endorser_profiles($input) { public function enqueue_scripts() { // Enqueue settings-specific scripts and styles if needed } -} \ No newline at end of file +} diff --git a/wordpress/admin/js/content-signing-post-meta-box.js b/wordpress/admin/js/content-signing-post-meta-box.js index 5112b71..4a77136 100644 --- a/wordpress/admin/js/content-signing-post-meta-box.js +++ b/wordpress/admin/js/content-signing-post-meta-box.js @@ -1,156 +1,153 @@ /** - * JavaScript for the post meta box functionality of the plugin. + * Browser-local signing for the post meta box. * - * @package Content_Signing - * @subpackage Content_Signing/admin/js + * WordPress supplies the authoritative, filtered payload. This file creates + * an Ed25519 key in Web Crypto, stores the non-extractable private key in + * IndexedDB, and sends only the public key plus signature bytes back to PHP. */ - (function($) { 'use strict'; - /** - * Initialize the post meta box scripts. - */ - function init() { - // Initialize sign post button - initSignPostButton(); + const config = content_signing_post_meta_box; + const databaseName = 'htmltrust-local-signing'; + const storeName = 'keys'; - // Initialize verify signature button - initVerifySignatureButton(); + function toBase64Url(bytes) { + let binary = ''; + bytes.forEach(function(byte) { binary += String.fromCharCode(byte); }); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } - /** - * Initialize the sign post button. - */ - function initSignPostButton() { - $('.content-signing-meta-box .sign-post').on('click', function(e) { - e.preventDefault(); - - const $button = $(this); - const $spinner = $button.siblings('.spinner'); - const postId = $button.data('post-id'); - - // Confirm before signing - if (!confirm(content_signing_post_meta_box.sign_post_confirm)) { + function openKeyStore() { + return new Promise(function(resolve, reject) { + if (!window.indexedDB) { + reject(new Error('This browser does not provide IndexedDB.')); return; } + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = function() { + request.result.createObjectStore(storeName); + }; + request.onsuccess = function() { resolve(request.result); }; + request.onerror = function() { reject(request.error || new Error('Could not open local key storage.')); }; + }); + } - // Disable button and show spinner - $button.prop('disabled', true); - $button.text(content_signing_post_meta_box.signing_text); - $spinner.addClass('is-active'); + function loadStoredKey(authorId, rotate) { + return openKeyStore().then(function(db) { + return new Promise(function(resolve, reject) { + const transaction = db.transaction(storeName, 'readonly'); + const store = transaction.objectStore(storeName); + const storageKey = 'author:' + authorId; + const request = store.get(storageKey); + request.onsuccess = function() { + if (request.result && !rotate) { + resolve(request.result); + return; + } + crypto.subtle.generateKey({name: 'Ed25519'}, false, ['sign', 'verify']).then(function(keyPair) { + // The private key is non-extractable from generation. + // Web Crypto keeps the public member exportable so it + // can be published as a resolver document. + return keyPair; + }).then(function(keyPair) { + const idPart = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); + const keyId = config.key_base_url + authorId + '.' + idPart; + const value = {keyId: keyId, keyPair: keyPair}; + const writeTransaction = db.transaction(storeName, 'readwrite'); + writeTransaction.objectStore(storeName).put(value, storageKey); + writeTransaction.oncomplete = function() { resolve(value); }; + writeTransaction.onerror = function() { reject(writeTransaction.error || new Error('Could not store local key.')); }; + }).catch(reject); + }; + request.onerror = function() { reject(request.error || new Error('Could not read local key.')); }; + }); + }); + } - // Send AJAX request + function exportPublicKey(key) { + return crypto.subtle.exportKey('raw', key).then(function(buffer) { + return toBase64Url(new Uint8Array(buffer)); + }); + } + + function ajax(data) { + return new Promise(function(resolve, reject) { $.ajax({ - url: content_signing_post_meta_box.ajax_url, + url: config.ajax_url, type: 'POST', - data: { - action: 'content_signing_sign_post', - nonce: content_signing_post_meta_box.nonce, - post_id: postId - }, + data: data, success: function(response) { if (response.success) { - // Reload the page to show the updated signature - location.reload(); + resolve(response.data && response.data.data ? response.data.data : response.data); } else { - // Show error message - const errorMessage = response.data && response.data.message ? response.data.message : content_signing_post_meta_box.error_text; - alert(content_signing_post_meta_box.error_text + ' ' + errorMessage); - - // Reset button - $button.prop('disabled', false); - $button.text(content_signing_post_meta_box.sign_post_text); - $spinner.removeClass('is-active'); + reject(new Error(response.data && response.data.message ? response.data.message : config.error_text)); } }, - error: function() { - // Show error message - alert(content_signing_post_meta_box.error_text + ' ' + content_signing_post_meta_box.ajax_error); - - // Reset button - $button.prop('disabled', false); - $button.text(content_signing_post_meta_box.sign_post_text); - $spinner.removeClass('is-active'); - } + error: function() { reject(new Error(config.ajax_error || config.error_text)); } }); }); } - /** - * Initialize the verify signature button. - */ - function initVerifySignatureButton() { - $('.content-signing-meta-box .verify-signature').on('click', function(e) { - e.preventDefault(); - - const $button = $(this); - const $listItem = $button.closest('li'); - const signatureId = $button.data('signature-id'); - const postId = $button.data('post-id'); - - // Remove any existing verification result - $listItem.find('.verify-result').remove(); - - // Disable button and show text - $button.prop('disabled', true); - $button.text(content_signing_post_meta_box.verifying_text); - - // Send AJAX request - $.ajax({ - url: content_signing_post_meta_box.ajax_url, - type: 'POST', - data: { - action: 'content_signing_verify_signature', - nonce: content_signing_post_meta_box.nonce, - signature_id: signatureId, - post_id: postId - }, - success: function(response) { - // Reset button - $button.prop('disabled', false); - $button.text(content_signing_post_meta_box.verify_text); + function signPost($button) { + const postId = $button.data('post-id'); + const $spinner = $button.siblings('.spinner'); + $button.prop('disabled', true).text(config.signing_text); + $spinner.addClass('is-active'); + + loadStoredKey(config.author_id, false).then(function(stored) { + return ajax({action: 'content_signing_prepare_local_signing', nonce: config.nonce, post_id: postId, keyid: stored.keyId}).then(function(prepared) { + const payload = new TextEncoder().encode(prepared.payload); + return crypto.subtle.sign({name: 'Ed25519'}, stored.keyPair.privateKey, payload).then(function(signature) { + return exportPublicKey(stored.keyPair.publicKey).then(function(publicKey) { + return ajax({ + action: 'content_signing_complete_local_signing', nonce: config.nonce, post_id: postId, + prepareToken: prepared.prepareToken, keyid: stored.keyId, publicKey: publicKey, signature: toBase64Url(new Uint8Array(signature)), + contentHash: prepared.contentHash, claimsHash: prepared.claimsHash, domain: prepared.domain, + signedAt: prepared.signedAt, payload: prepared.payload, profile: prepared.profile, + algorithm: prepared.algorithm, scope: prepared.scope, location: prepared.location, + sourceURL: prepared.sourceURL + }); + }); + }); + }); + }).then(function() { + window.location.reload(); + }).catch(function(error) { + alert(config.local_signing_error + ' ' + error.message); + $button.prop('disabled', false).text(config.sign_post_text); + $spinner.removeClass('is-active'); + }); + } - if (response.success) { - // Show success message - const resultClass = response.data.valid ? 'valid' : 'invalid'; - const resultText = response.data.valid ? - content_signing_post_meta_box.valid_text : - content_signing_post_meta_box.invalid_text; + function init() { + $('.content-signing-meta-box .sign-post').on('click', function(event) { + event.preventDefault(); + if (confirm(config.sign_post_confirm)) { signPost($(this)); } + }); - // Built with .text(): resultText is local, but the - // branch below carries trust-server strings and both - // paths must stay markup-free. - const $result = $('
    ') - .addClass('verify-result ' + resultClass) - .text(resultText); - $listItem.append($result); - } else { - // Show error message. response.data.message is relayed - // verbatim from the trust server and is untrusted. - const errorMessage = response.data && response.data.message ? response.data.message : content_signing_post_meta_box.error_text; - const $result = $('
    ') - .addClass('verify-result invalid') - .text(content_signing_post_meta_box.error_text + ' ' + errorMessage); - $listItem.append($result); - } - }, - error: function() { - // Reset button - $button.prop('disabled', false); - $button.text(content_signing_post_meta_box.verify_text); + $('.content-signing-meta-box .rotate-local-key').on('click', function(event) { + event.preventDefault(); + if (!confirm(config.rotate_confirm)) { return; } + loadStoredKey($(this).data('author-id'), true).then(function() { + alert('Local signing key rotated.'); + }).catch(function(error) { alert(config.local_signing_error + ' ' + error.message); }); + }); - // Show error message - const $result = $('
    ') - .addClass('verify-result invalid') - .text(content_signing_post_meta_box.error_text + ' ' + content_signing_post_meta_box.ajax_error); - $listItem.append($result); - } + $('.content-signing-meta-box .verify-signature').on('click', function(event) { + event.preventDefault(); + const $button = $(this); + const $listItem = $button.closest('li'); + $button.prop('disabled', true).text(config.verifying_text); + ajax({action: 'content_signing_verify_signature', nonce: config.nonce, signature_id: $button.data('signature-id'), post_id: $button.data('post-id')}).then(function(result) { + $listItem.append($('
    ').addClass('verify-result ' + (result.valid ? 'valid' : 'invalid')).text(result.valid ? config.valid_text : config.invalid_text)); + }).catch(function(error) { + $listItem.append($('
    ').addClass('verify-result invalid').text(config.error_text + ' ' + error.message)); + }).finally(function() { + $button.prop('disabled', false).text(config.verify_text); }); }); } - // Initialize when the DOM is ready $(document).ready(init); - -})(jQuery); \ No newline at end of file +}(jQuery)); diff --git a/wordpress/composer.json b/wordpress/composer.json index 5d27b3e..aa5f2ed 100644 --- a/wordpress/composer.json +++ b/wordpress/composer.json @@ -16,8 +16,8 @@ } ], "require": { - "php": ">=7.2", - "htmltrust/canonicalization": "^0.2.2" + "php": ">=8.5", + "htmltrust/canonicalization": "dev-main" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/wordpress/composer.lock b/wordpress/composer.lock index 872fa03..83ff545 100644 --- a/wordpress/composer.lock +++ b/wordpress/composer.lock @@ -4,30 +4,36 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8201c59f2499047b36fae8d8dbae4895", + "content-hash": "311debca10c976789080e49035b2b300", "packages": [ { "name": "htmltrust/canonicalization", - "version": "v0.2.2", + "version": "dev-main", "source": { "type": "git", "url": "https://github.com/HTMLTrust/htmltrust-canonicalization.git", - "reference": "79b0d52fecd958f8fc7ade713fe0799ca1e79626" + "reference": "760593d4a02e9fffa56dc4d002eb52ab2ade1b49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/HTMLTrust/htmltrust-canonicalization/zipball/79b0d52fecd958f8fc7ade713fe0799ca1e79626", - "reference": "79b0d52fecd958f8fc7ade713fe0799ca1e79626", + "url": "https://api.github.com/repos/HTMLTrust/htmltrust-canonicalization/zipball/760593d4a02e9fffa56dc4d002eb52ab2ade1b49", + "reference": "760593d4a02e9fffa56dc4d002eb52ab2ade1b49", "shasum": "" }, "require": { + "ext-dom": "*", "ext-intl": "*", + "ext-json": "*", "ext-mbstring": "*", - "php": ">=7.2" + "ext-openssl": "*", + "ext-sodium": "*", + "php": ">=8.5", + "root23/php-json-canonicalization": "1.0.1" }, "require-dev": { - "phpunit/phpunit": "^8.0 || ^9.0 || ^10.0" + "phpunit/phpunit": "10.5.64" }, + "default-branch": true, "type": "library", "autoload": { "psr-4": { @@ -44,7 +50,7 @@ ], "description": "HTMLTrust canonical text normalization for PHP", "support": { - "source": "https://github.com/HTMLTrust/htmltrust-canonicalization/tree/v0.2.2", + "source": "https://github.com/HTMLTrust/htmltrust-canonicalization/tree/main", "issues": "https://github.com/HTMLTrust/htmltrust-canonicalization/issues" }, "funding": [ @@ -53,7 +59,43 @@ "url": "https://github.com/jt55401" } ], - "time": "2026-08-27T21:56:42+00:00" + "time": "2026-08-28T10:29:49+00:00" + }, + { + "name": "root23/php-json-canonicalization", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/root23/php-json-canonicalization.git", + "reference": "be888e03a171c2b9667265d03924bd6bfc3fe85a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/root23/php-json-canonicalization/zipball/be888e03a171c2b9667265d03924bd6bfc3fe85a", + "reference": "be888e03a171c2b9667265d03924bd6bfc3fe85a", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.27.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Root23\\JsonCanonicalizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "description": "Serialize data into canonical way, based on RFC-8785.", + "support": { + "issues": "https://github.com/root23/php-json-canonicalization/issues", + "source": "https://github.com/root23/php-json-canonicalization/tree/1.0.1" + }, + "time": "2023-09-27T08:27:25+00:00" } ], "packages-dev": [ @@ -2315,12 +2357,14 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": { + "htmltrust/canonicalization": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.2" + "php": ">=8.5" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/wordpress/content-signing.php b/wordpress/content-signing.php index af8808c..8d1b1ea 100644 --- a/wordpress/content-signing.php +++ b/wordpress/content-signing.php @@ -3,8 +3,8 @@ * Plugin Name: Content Signing for WordPress * Plugin URI: https://example.com/content-signing * Description: Integrates WordPress with content signing services to verify content origin and authenticity. - * Version: 1.0.0 - * Requires PHP: 7.2 + * Version: 1.1.0 + * Requires PHP: 8.5 * Author: Jason Grey * Author URI: https://jason-grey.com * License: GPL-2.0+ @@ -19,7 +19,7 @@ } // Define plugin constants -define('CONTENT_SIGNING_VERSION', '1.0.0'); +define('CONTENT_SIGNING_VERSION', '1.1.0'); define('CONTENT_SIGNING_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('CONTENT_SIGNING_PLUGIN_URL', plugin_dir_url(__FILE__)); define('CONTENT_SIGNING_PLUGIN_BASENAME', plugin_basename(__FILE__)); diff --git a/wordpress/docker/test.Dockerfile b/wordpress/docker/test.Dockerfile index 289629a..1f78e94 100644 --- a/wordpress/docker/test.Dockerfile +++ b/wordpress/docker/test.Dockerfile @@ -1,5 +1,5 @@ -# The digest pins the multi-platform index for PHP 8.3 on Debian Bookworm. -FROM php:8.3-cli-bookworm@sha256:177529735599a8244b2c903522f029839dce1c2ac4be122fdc00ada4b45a20e4 +# The digest pins the multi-platform index for PHP 8.5 on Debian Bookworm. +FROM php:8.5-cli-bookworm@sha256:398d3816875b7209aad6982d52c16b4e073ccda6adb40fce7a6f44dd47b6b84f # Composer is copied from its pinned official image. The PHP extensions match # the plugin's runtime and the tools used by install-wp-tests.sh. diff --git a/wordpress/docs/local-signing-audit.md b/wordpress/docs/local-signing-audit.md index 1d9cc34..13fe2b9 100644 --- a/wordpress/docs/local-signing-audit.md +++ b/wordpress/docs/local-signing-audit.md @@ -1,31 +1,62 @@ # Local Signing Audit -**Status:** proposal for review +**Status:** browser-local v1 implementation and remaining migration plan **Spec basis:** htmltrust spec, amended 2026-04-10 (§2.2 Identity and Key Resolution; §3.1 Browser Behavior) -**Author:** triggered by P0 cleanup pass; do not implement before Jason approves the rollout. +**Author:** triggered by P0 cleanup pass. The phase 2 browser-local slice is implemented; rollout and future hardware work remain decisions. ## TL;DR -The current plugin sends every author's content (hash + claims) to a remote -"trust server" and asks that server to perform the signing operation using a -private key the server holds. The amended spec is explicit: signing is a -**local cryptographic operation**, the trust directory is **optional and -non-privileged**, and **no central authority** ever performs the signing on -behalf of the author. We need to invert the key-custody relationship and -make signing happen client-side (or at least author-side) before anything -touches the network. - -This document describes the gap, a proposed end-state, the open questions, -and a phased rollout. The rollout is deliberately incremental so that we -can ship value (and learn) at each phase without committing to the full -end-state up front. - -## Current State - -### How signing happens today - -Today's flow, per `includes/class-content-signing-signing-service.php` -> -`sign_post()`: +The WordPress plugin now signs in the author's browser. PHP filters and +canonicalizes the post, builds the frozen v1 RFC 8785 payload, and returns its +exact UTF-8 bytes. The browser signs those bytes with a non-extractable +Ed25519 key in IndexedDB. PHP rebuilds the payload, verifies the signature, and +stores resolver-compatible public-key metadata. + +The trust directory is optional for this flow. Scheduled, headless, REST, +XML-RPC, and mobile publication paths remain unsigned and create a durable +queue entry for the author to sign later in the editor. + +Create an author profile with **Browser-local only** to use this flow. The +profile uses `server_id=0`, defaults its identity to `local-wp-user-{ID}`, and +stores no API key. Local profiles are excluded from site endorsement settings. + +## Current implementation + +The browser flow is implemented in `class-content-signing-signing-service.php`, +the post meta box JavaScript, and the local key REST endpoint: + +1. PHP applies the content filters and computes content and claims hashes. +2. PHP builds the v1 payload with profile, algorithm, key ID, URL scope, + derived location, hashes, and timestamp. +3. The browser signs the returned bytes and sends only the public key and + signature back to PHP. +4. PHP independently rebuilds the v1 bytes, verifies Ed25519, and persists + the signature. +5. The public endpoint returns the public key as canonical unpadded standard + Base64 SPKI DER with `publicKeyEncoding: "spki-der"`. + +Completion uses a short-lived transient plus a compare-and-set option lock. +The transient is deleted after the signature row is stored. The consume lock +remains through its cleanup window, so a failed transient deletion cannot make +a used token valid again. A persistence failure releases the lock and keeps the +transient for retry. If PHP dies after the row is stored, the transient expires +after five minutes. The completion path checks for a stale lock, and each later +prepare request scans at most 100 consume locks and reclaims locks older than ten minutes. This +bounded crash window cannot authorize a late retry. + +The wrapper is registered at `PHP_INT_MAX`. Before emitting a local wrapper, +the display callback canonicalizes the exact filtered content it received and +compares its hash with the stored content hash. A mismatch withholds the +wrapper and leaves the content unsigned. + +WordPress does not define ordering between callbacks registered later at the +same priority, so such a callback can still run after the wrapper. The signing +pass and a frontend request can also have different block context or request +state. Those limits remain deployment concerns even with the maximum priority. + +### Historical pre-v1 flow + +The original flow, retained here for migration context, was: 1. WordPress fires `publish_post` (or `transition_post_status`). 2. `ContentSigning_Hooks::on_publish_post` -> `Signing_Service::process_post` @@ -67,7 +98,7 @@ key* (`api_key_encrypted` / `author_api_key_encrypted` columns), which is the bearer token used to authenticate signing requests, not the signing key itself. -## Spec Gap +## Resolved protocol gap §2.2 (Identity and Key Resolution) makes the trust directory's role purely optional convenience: `keyid` is pluggable across DIDs, direct URLs, and @@ -75,53 +106,58 @@ trust-directory references; "none" is canonical; verification is local. §3.1 (Browser Behavior) reinforces that cryptographic verification is a local operation in the user agent with no required network calls beyond -key resolution. A symmetric reading -- and the design intent -- is that -**signing is also a local operation**, performed by the author (or the +key resolution. The same design intent applies to signing: it is a local +operation performed by the author (or the author's tooling) using a key the author controls. Anything else recreates exactly the central-authority pattern the spec is built to avoid. -The plugin as shipped today fails this test in three ways: +The pre-v1 plugin failed this test in three ways: 1. **Key custody is wrong.** Authors do not hold their own keys; the trust server does. -2. **Signing has a remote-call dependency.** Publishing fails closed if - the trust server is unreachable, even though the spec contemplates no +2. **Signing had a remote-call dependency.** Pre-v1 publication failed if + the trust server was unreachable, even though the spec contemplates no such dependency. 3. **The trust server learns about every publication.** That coupling is incompatible with the "MAY submit to one or more trust directories" language in §2.4 and the federated-by-design framing throughout. -## Proposed Design +## Design notes and future options -### End-state: the editor signs +### Current design: the editor signs -Signing happens at publish time, in PHP on the WordPress server, using a -private key that the author (or the site) controls and that the plugin -loads from a configured key store. The trust directory is contacted only -if the site has opted in to publication notification, and only with the -already-signed blob. +PHP prepares the final filtered HTML and returns the exact HTMLTrust signing +payload to the editor. Browser JavaScript signs those bytes with a Web Crypto +Ed25519 key whose private half is non-extractable and stored in IndexedDB. +PHP verifies the returned signature against the same payload before it stores +anything. The trust directory is optional and receives no signing request. ### Where the private key lives (three options, listed in deployment preference order) -**1. WebAuthn / hardware-backed key (best UX-to-security ratio for -single-author sites).** The author registers a hardware key (TouchID, -Yubikey, platform authenticator) via a JS flow in the post editor. The -key never leaves the device. Signing happens in the browser via the -WebAuthn `sign` operation, and the signed blob is POSTed back to the -WordPress REST API as part of the publish payload. PHP server-side never -sees the private key. - -Limitations: requires a recent browser; doesn't compose with non-browser -publishing paths (XML-RPC, mobile app, REST clients, scheduled posts, -cron-driven imports). For those, fall back to (2) or (3). - -**2. Encrypted key file in `wp-content/uploads/htmltrust/` (server-side -fallback).** The site admin generates an Ed25519 keypair via the plugin -UI or the bundled CLI tool, stores the encrypted private key on disk +**1. Browser Web Crypto Ed25519 key (current implementation).** The editor +generates an Ed25519 `CryptoKeyPair` with `extractable=false` directly. The +private key is never exported; the public member remains exportable for key +publication. The pair is stored in IndexedDB, and only the public key and +signature go to PHP. This is the HTMLTrust signature artifact. It is distinct +from a WebAuthn assertion. + +Limitations: requires a browser with Ed25519 Web Crypto support and local +storage. It does not sign scheduled, headless, XML-RPC, or mobile publishes. +Those paths remain explicitly unsigned until an author opens the editor. + +WebAuthn may protect access to the editor or unlock a future external signer, +but its assertion bytes are not the HTMLTrust payload signature. WebAuthn +assertions bind `clientDataJSON` and authenticator data to a challenge; they +cannot be substituted for `crypto.subtle.sign()` over the HTMLTrust payload. + +**2. Encrypted key file in `wp-content/uploads/htmltrust/` (future +headless option).** The site admin generates an Ed25519 keypair via a +separate CLI or deployment process, stores the encrypted private key on disk (passphrase-derived KEK held in a `wp-config.php` constant, *not* the DB), -and configures author->key mappings in plugin settings. PHP loads the key -on demand for signing. +and configures author-to-key mappings. This option is not enabled by the +current plugin and must not be silently selected when a browser key is +unavailable. Limitations: trust boundary is "anyone with filesystem access to wp-content can attempt offline brute force on the passphrase". Acceptable @@ -146,59 +182,53 @@ because: - WordPress mutates content in non-trivial ways at publish time (shortcode expansion, oEmbed substitution, `the_content` filters). The hash must match what's actually rendered. -- A JS-side canonicalizer in the editor can compute a *preview* hash for - WebAuthn-style signing, but the authoritative hash is what PHP computes +- A JS-side canonicalizer in the editor can compute a *preview* hash, but + the authoritative hash is what PHP computes after all filters have run, and that's what must be signed. -For the WebAuthn path specifically, this means signing has to happen in +For the browser path specifically, this means signing has to happen in two passes: -1. PHP server runs filters, computes canonical hash, returns hash to the - browser. -2. Browser presents the hash to the WebAuthn authenticator, gets a - signature, posts the signature back. -3. PHP server stores the signature alongside the hash and emits the +1. PHP runs filters, computes canonical hashes, and returns the exact frozen + v1 RFC 8785 payload, including profile, key ID, algorithm, scope, + location, hashes, and timestamp, to the browser. +2. Browser signs those UTF-8 bytes with `crypto.subtle.sign({name: + "Ed25519"}, privateKey, payload)` and posts the signature and public key + back. +3. PHP recomputes the hashes, verifies the Ed25519 signature, stores the + signature alongside the hash, and emits the `` wrapper. This is fiddly but workable; the alternative (signing arbitrary client-rendered preview) lets a malicious filter or plugin inject unsigned content. -### Publish-time flow (proposed) +### Publish-time flow (current) -The proposed `wp_insert_post` / `transition_post_status` flow becomes: +The `wp_insert_post` / `transition_post_status` flow is: 1. WordPress applies all `the_content` filters, shortcode expansion, etc. (We piggyback on `the_content` to capture the rendered HTML at the right moment, not the raw post body.) 2. PHP canonicalizes the rendered content; computes `content-hash`. 3. PHP gathers the claims (default + post-specific, same as today). -4. PHP builds the **claims-binding** structure (the canonicalized - key-sorted serialization of `{contentHash, domain, claims, signedAt, - keyid}` per the canonicalization spec). -5. Local signer signs the claims-binding: - - WebAuthn path: PHP returns the binding bytes to the editor JS, JS - calls `navigator.credentials.get(...)`, posts signature back. - - Server-key path: PHP loads encrypted key, decrypts with KEK from - `wp-config`, signs with `sodium_crypto_sign_detached`. - - CLI path: blob already signed at draft import time; PHP just stores - it. -6. PHP stores `{signature, keyid, content-hash, signed-at, claims}` in - post meta (one row per signature in the existing - `wp_content_signing_signatures` table; schema is already mostly - right). -7. The public-facing display layer reads post meta and emits the - `` wrapper at render time. (This already exists; - `ContentSigning_Display` just needs to read from a different source.) -8. *Optional*: notify a configured trust directory by POSTing - `{contentHash, signature, keyid, domain}` to its - `/api/content/notify` endpoint. The notification is fire-and-forget; - publish does not block on it. +4. PHP builds the frozen v1 RFC 8785 signing object, including the profile, + key ID, algorithm, scope, and derived location. +5. The browser signs the binding with Web Crypto Ed25519 and returns the + signature plus public key. PHP verifies the signature before persistence. +6. PHP stores `{signature, keyid, content-hash, signed-at, claims}` in the + existing `wp_content_signing_signatures` table, including v1 payload + metadata and the resolver-compatible public key. +7. The public-facing display layer compares the current filtered bytes with + the stored content hash, then emits the `` wrapper only + when they match. +8. *Future optional action*: notify a configured trust directory with the + already-signed blob. Signing and publication do not depend on that service. ### What the trust directory still receives -In the proposed design the trust directory is reduced from "co-signer" to -"index" -- and only when the site opts in. It receives: +In the current design the trust directory is reduced from "co-signer" to +"index", and only when the site opts in. It receives: - The signed blob (already-signed; the directory cannot forge or alter it). @@ -214,10 +244,9 @@ entirely. ## Risks and Open Questions -- **Key storage UX is the hardest problem.** Authors don't want to think - about keys. The CLI escape hatch is for power users; the WebAuthn path - is for typical authors; the server-key path is for everyone who can't - use either. We will ship all three but we need a clear default. +- **Key storage UX is the hardest problem.** The current editor generates a + local key automatically. Device loss requires rotation, and headless + publishing still needs a separately designed external signer. - **Multi-author sites: per-author keys vs. site key?** §2.2 implies per-author; in practice, many WP sites have one editor publishing on behalf of many "authors". Recommendation: support both, with explicit @@ -228,20 +257,18 @@ entirely. because the key did). We need (a) a way to publish key-rotation metadata at the keyid resolution endpoint, and (b) UI to make this obvious to authors. -- **Backup and recovery.** WebAuthn keys can't be exported; if the - hardware is lost, signed history remains valid but new content can't - be signed under that key. Server-key path needs documented backup - procedure (encrypted key file + KEK passphrase recovery sheet). +- **Backup and recovery.** Browser private keys cannot be exported after + import. If the browser profile is lost, signed history remains valid but + new content requires a rotated key. WordPress cannot recover the old key. - **Mobile app authoring (Jetpack, WP mobile app).** These use the REST - API and don't have access to a local signer. Options: (a) have the - mobile app implement WebAuthn-equivalent signing; (b) accept that - mobile-published posts get signed server-side with a per-site key; (c) - delay signing until the next desktop edit. Probably (b) with explicit - UI labeling. -- **REST API authoring (headless, Gutenberg over REST).** Same shape as - mobile. Recommendation: REST publishers must include a `signature` - field in the publish payload, OR opt in to server-key signing. Reject - unsigned publishes only if "strict signing" is enabled. + API and do not have access to the browser's IndexedDB key. The current + policy is to leave the post unsigned and show it in the local-signing + queue. A future mobile signer must implement the same Ed25519 payload + contract. +- **REST API authoring (headless, Gutenberg over REST).** The publisher must + eventually submit a signature over the PHP-produced payload, or the post + remains unsigned. This slice does not accept an API key as a substitute + for an author's private key. - **What happens on republish / edit?** Re-signs with current timestamp. The previous signature row is preserved (we already have a one-row-per- attempt schema). UI should expose the signature history. @@ -253,7 +280,7 @@ entirely. ## Suggested Rollout -### Phase 1 — Ship a CLI signer (size: S) +### Phase 1: Ship a CLI signer (size: S) Build a standalone PHP/Node CLI tool (`htmltrust-sign`) that takes a file or URL, canonicalizes the content, prompts for a passphrase, signs with @@ -267,71 +294,64 @@ authors a path that doesn't trust the server with anything. **Done when:** CLI exists, plugin accepts pasted blobs, e2e test passes with CLI-signed content. -### Phase 2 — Server-key signing (size: M) - -Implement option (2) from "where the private key lives": encrypted key -file in `wp-content/uploads/htmltrust/`, KEK from `wp-config.php` -constant, plugin settings UI for key generation / per-author mapping. - -The trust-server signing call becomes opt-in (off by default), and the -plugin signs locally with `sodium_crypto_sign_detached`. Schema changes: -add `key_id` and `key_fingerprint` columns to -`wp_content_signing_authors`; deprecate `author_api_key_encrypted`. +### Phase 2: Browser-local signing (current slice) -Migration: existing installs get a one-click "switch to local signing" -flow that generates a new key and re-signs the latest revision of each -post. +The editor uses a two-pass AJAX flow. PHP returns the filtered payload, the +browser signs it with a non-exportable IndexedDB key, and PHP verifies it +with the submitted public key before storing the signature. Existing +remote-signature rows remain readable for migration. -**Done when:** A site with no trust-server configured can publish signed -content; the trust-server signing endpoint is deprecated in admin UI. +Scheduled and headless publishes create an `awaiting-local-signature` queue +entry. They never call the remote signing endpoint. Opening the post editor +and selecting Sign Now produces the local signature. -### Phase 3 — WebAuthn-attested keys (size: M) +**Implemented:** the Docker WordPress suite covers payload mutation rejection, +authorization, resolver-shaped keys, rendered-byte drift, and a static browser +asset assertion that private key material is never sent in an AJAX payload. A +real browser run remains a deployment validation step because Web Crypto and +IndexedDB are not available in PHPUnit. -Editor-side JS integration: WebAuthn registration flow in user profile, -WebAuthn signing flow at publish time. PHP-side: receive -`{publicKeyCredential, signature}` from the editor, verify the signature -against the stored credential, store the result. +### Phase 3: Optional hardware-backed signer (future) -This is the best end-state UX for typical authors. It requires the -two-pass canonicalization flow described above. +WebAuthn can protect editor access or authorize an external signer. It must +not be treated as the HTMLTrust signature itself. A future profile would +define how a WebAuthn assertion authorizes use of the Web Crypto key, or +define a separate WebAuthn signature profile with its own verifier rules. -**Done when:** A user can register a hardware key via the WP admin UI -and publish a post that is signed entirely client-side, with no signing -key ever present on the server. +**Done when:** a separately designed hardware-backed profile has a verifier +and interoperability tests. It must not reinterpret an assertion as an +Ed25519 HTMLTrust signature. -### Phase 4 — Deprecate the trust-server signing endpoint (size: S) +### Phase 4: Legacy remote signing status -Remove `Signing_Service::sign_post`'s call to -`api_client->sign_content()`. Keep `api_client->sign_content` as a -client method (it's still useful for the trust server's own admin -tooling) but no plugin code path calls it. Mark -`X-AUTHOR-API-KEY`-bearing flows as deprecated in plugin docs. +The publication service now hard-disables `Signing_Service::sign_post` and has +removed endorsement execution. The API client's read and compatibility methods +remain for migration screens and existing remote records. -Trust-directory notification (`/api/content/notify`-style) replaces it, -and is opt-in per author. +Trust-directory notification (`/api/content/notify`-style) can be added as +an opt-in post-signing action. -**Done when:** Removing the trust server entirely from a site's config -breaks nothing in the publish path. +**Done:** Removing the trust server from a site's config does not invoke it in +the publish path. Real browser and deployment validation remain separate gates. ## Effort Summary | Phase | Description | Size | Notes | | ----- | ------------------------------------ | ---- | ------------------------------------------------------ | | 1 | CLI signer + paste-blob workflow | S | No infra changes; validates the wire format | -| 2 | Server-key signing (PHP-local) | M | Schema migration + UI + crypto; meaningful test surface | -| 3 | WebAuthn editor integration | M | New JS surface; two-pass canonicalization | -| 4 | Deprecate trust-server signing | S | Mostly removal + docs | +| 2 | Browser-local signing | M | Schema migration + UI + crypto; meaningful test surface | +| 3 | Optional hardware-backed signer | M | Separate profile and verifier design | +| 4 | Legacy remote signing status | S | Disabled in the publication service; compatibility reads remain | Total: roughly two-to-three sprints of focused work, gated on Jason's approval at each phase boundary. ## Recommendation -Approve phases 1 and 2 as a unit (they're a coherent migration and worth -shipping together). Phase 3 is the right end-state but introduces -non-trivial JS-side complexity; defer the go/no-go decision until phase -2 is in production for a few weeks. Phase 4 is mechanical once 1-3 are -in. +The browser-local flow is the correct default for interactive publishing. +Keep server-key signing out of the default path. Decide separately whether +headless publishers should implement the same payload contract or use a +deploy-controlled external signer. -Do **not** start work on any phase from this PR. This document exists -specifically so the redesign can be argued about before code moves. +This document describes the current implementation boundary and the gates +that remain before removing the legacy remote method. diff --git a/wordpress/includes/class-content-signing-activator.php b/wordpress/includes/class-content-signing-activator.php index a1e47ed..c6dba4d 100644 --- a/wordpress/includes/class-content-signing-activator.php +++ b/wordpress/includes/class-content-signing-activator.php @@ -89,6 +89,9 @@ private static function create_database_tables() { content_hash varchar(255) NOT NULL, domain varchar(255) NOT NULL, signature text NOT NULL, + keyid varchar(255) DEFAULT NULL, + public_key text, + signing_mode varchar(32) NOT NULL DEFAULT 'remote', claims_json text, status varchar(50) NOT NULL, api_response_json text, @@ -97,7 +100,8 @@ private static function create_database_tables() { PRIMARY KEY (signature_id), KEY post_id (post_id), KEY server_id (server_id), - KEY wp_user_id (wp_user_id) + KEY wp_user_id (wp_user_id), + KEY local_keyid (keyid(191), signing_mode) ) $charset_collate;"; // Include WordPress database upgrade functions @@ -132,4 +136,4 @@ private static function set_default_options() { add_option('content_signing_' . $option_name, $option_value); } } -} \ No newline at end of file +} diff --git a/wordpress/includes/class-content-signing-api-client.php b/wordpress/includes/class-content-signing-api-client.php index 426d9fa..8ad9b85 100644 --- a/wordpress/includes/class-content-signing-api-client.php +++ b/wordpress/includes/class-content-signing-api-client.php @@ -241,7 +241,10 @@ public function get_author_public_key($author_id) { * @return array|WP_Error The API response or WP_Error on failure. */ public function sign_content($content_data, $author_api_key) { - return $this->request('content/sign', 'POST', $content_data, $author_api_key, 'author'); + return new WP_Error( + 'remote_signing_disabled', + __('Server-side content signing is disabled. Use the browser-local signing flow.', 'content-signing') + ); } /** @@ -338,4 +341,4 @@ public function find_content_occurrences($content_hash, $params = array()) { return $this->request("directory/content/{$content_hash}/occurrences", 'GET', $params); } -} \ No newline at end of file +} diff --git a/wordpress/includes/class-content-signing-hooks.php b/wordpress/includes/class-content-signing-hooks.php index 4927978..9e52541 100644 --- a/wordpress/includes/class-content-signing-hooks.php +++ b/wordpress/includes/class-content-signing-hooks.php @@ -120,6 +120,8 @@ private function register_admin_hooks() { // AJAX handlers add_action('wp_ajax_content_signing_sign_post', array($this, 'ajax_sign_post')); + add_action('wp_ajax_content_signing_prepare_local_signing', array($this, 'ajax_prepare_local_signing')); + add_action('wp_ajax_content_signing_complete_local_signing', array($this, 'ajax_complete_local_signing')); add_action('wp_ajax_content_signing_verify_signature', array($this, 'ajax_verify_signature')); add_action('wp_ajax_content_signing_get_claim_types', array($this, 'ajax_get_claim_types')); } @@ -234,9 +236,65 @@ public function ajax_sign_post() { return; } - // Sign the post - $result = $this->signing_service->sign_post($post_id); - + wp_send_json_error(array( + 'message' => 'Browser-local signing is required. Reload the editor and use the local signing flow.', + 'code' => 'local_signing_required', + )); + } + + /** + * Return the server-rendered payload for browser-local signing. + * + * @return void + */ + public function ajax_prepare_local_signing() { + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + if (!$post_id) { + wp_send_json_error(array('message' => 'Invalid post ID.')); + return; + } + + check_ajax_referer('content_signing_post_' . $post_id, 'nonce'); + if (!current_user_can('edit_post', $post_id)) { + wp_send_json_error(array('message' => 'Permission denied.')); + return; + } + + $keyid = isset($_POST['keyid']) ? wp_unslash((string) $_POST['keyid']) : ''; + $result = $this->signing_service->prepare_local_signing($post_id, $keyid); + if ($result['success']) { + wp_send_json_success($result); + } else { + wp_send_json_error($result); + } + } + + /** + * Verify and persist a browser-local signature. + * + * @return void + */ + public function ajax_complete_local_signing() { + $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; + if (!$post_id) { + wp_send_json_error(array('message' => 'Invalid post ID.')); + return; + } + + check_ajax_referer('content_signing_post_' . $post_id, 'nonce'); + if (!current_user_can('edit_post', $post_id)) { + wp_send_json_error(array('message' => 'Permission denied.')); + return; + } + + $submitted = array(); + foreach (array('prepareToken', 'keyid', 'publicKey', 'signature', 'signedAt', 'contentHash', 'claimsHash', 'domain', 'payload', 'profile', 'algorithm', 'scope', 'location', 'sourceURL') as $field) { + if (isset($_POST[$field])) { + $submitted[$field] = is_string($_POST[$field]) ? wp_unslash($_POST[$field]) : $_POST[$field]; + } + } + + $result = $this->signing_service->complete_local_signing($post_id, $submitted); if ($result['success']) { wp_send_json_success($result); } else { @@ -338,4 +396,4 @@ public function ajax_get_claim_types() { } // The embed_signature_in_content method has been moved to the ContentSigning_Display class -} \ No newline at end of file +} diff --git a/wordpress/includes/class-content-signing-scheduler.php b/wordpress/includes/class-content-signing-scheduler.php index d6c08be..8c03955 100644 --- a/wordpress/includes/class-content-signing-scheduler.php +++ b/wordpress/includes/class-content-signing-scheduler.php @@ -100,8 +100,10 @@ public function process_scheduled_signing($post_id) { $plugin = ContentSigning_Plugin::get_instance(); $signing_service = $plugin->get_signing_service(); - // Sign the post - $signing_service->sign_post($post_id); + // Cron has no access to the author's browser key. Leave a durable + // queue entry for the next editor session instead of invoking the + // legacy remote signer. + $signing_service->queue_local_signature($post_id); } /** diff --git a/wordpress/includes/class-content-signing-signing-service.php b/wordpress/includes/class-content-signing-signing-service.php index 5ac6cd5..2b6d4a5 100644 --- a/wordpress/includes/class-content-signing-signing-service.php +++ b/wordpress/includes/class-content-signing-signing-service.php @@ -10,6 +10,7 @@ */ use HTMLTrust\Canonicalization\Canonicalize; +use HTMLTrust\Canonicalization\Signature; class ContentSigning_Signing_Service { @@ -140,7 +141,10 @@ public function process_post($post_id, $post, $update) { $action_key = 'sign:' . $post_id; if (empty($this->processed_actions[$action_key])) { $this->processed_actions[$action_key] = true; - $this->sign_post($post_id); + // A save/publish hook has no browser context. Queue the + // exact bytes for an author-side signer instead of sending + // them to a trust server that owns the signing key. + $this->queue_local_signature($post_id); } } @@ -155,204 +159,571 @@ public function process_post($post_id, $post, $update) { } /** - * Sign a post. + * Prepare the authoritative payload for browser-local signing. * - * @since 1.0.0 - * @param int $post_id The post ID. - * @return array The result of the signing operation. + * PHP runs the complete WordPress filter chain before returning this + * value. The browser signs the returned payload verbatim; it never signs + * a preview or a client-reconstructed claims object. + * + * @param int $post_id Post ID. + * @param string $keyid Key identifier that is bound into v1 payload. + * @return array Result containing the payload, or an error result. */ - public function sign_post($post_id) { + public function prepare_local_signing($post_id, $keyid) { $post = get_post($post_id); if (!$post) { - return array( - 'success' => false, - 'message' => 'Post not found.', - ); + return array('success' => false, 'message' => 'Post not found.'); + } + + if (!$this->current_user_is_post_author($post)) { + return array('success' => false, 'message' => 'Only the post author may sign locally.'); } - // Get the post author's signing profile $author_profile = $this->db->get_author_by_wp_user_id($post->post_author); if (!$author_profile) { - return array( - 'success' => false, - 'message' => 'Author does not have a signing profile.', - ); + return array('success' => false, 'message' => 'Author does not have a signing profile.'); } - // Get the server for this author - $server = $this->db->get_server($author_profile->server_id); - if (!$server) { + if ((int) $author_profile->server_id !== 0) { + return array('success' => false, 'message' => 'Remote author profiles must use the legacy remote workflow; browser-local signing requires server ID 0.'); + } + + // Completion leaves a short-lived consume option while it persists a + // row. A bounded scan here reclaims options left by a crashed PHP + // request after their five-minute prepare state has expired. + $this->cleanup_orphaned_consume_locks(); + + if (!$this->is_valid_keyid($keyid)) { + return array('success' => false, 'message' => 'Invalid key ID.'); + } + + try { + $content_data = $this->prepare_content_data($post); + $signing_data = $this->local_signing_data($content_data, $post_id, $keyid); + } catch (Exception $e) { return array( 'success' => false, - 'message' => 'Server not found for this author.', + 'message' => 'Local signing payload preparation failed: ' . $e->getMessage(), ); } - // Prepare the content for signing. Canonicalization is allowed to - // fail hard (draft §4.3.2 requires MUST-fail on unresolvable signed - // attribute values); surface that as a signing error rather than a - // PHP fatal inside a save_post hook. + // Keep the exact server-rendered signing state short-lived and bound + // to this post, author, key, and payload. The token carries no key + // material and is consumed by a successful completion. + $prepare_token = wp_generate_password(64, false, false); + set_transient( + $this->prepare_transient_key($prepare_token), + array( + 'post_id' => (int) $post_id, + 'user_id' => (int) get_current_user_id(), + 'keyid' => $keyid, + 'payload' => $signing_data['payload'], + 'contentHash' => $signing_data['contentHash'], + 'claimsHash' => $signing_data['claimsHash'], + 'domain' => $signing_data['domain'], + 'signedAt' => $signing_data['signedAt'], + 'profile' => $signing_data['profile'], + 'algorithm' => $signing_data['algorithm'], + 'scope' => $signing_data['scope'], + 'location' => $signing_data['location'], + 'sourceURL' => $signing_data['sourceURL'], + ), + 5 * MINUTE_IN_SECONDS + ); + $signing_data['prepareToken'] = $prepare_token; + + return array( + 'success' => true, + 'data' => $signing_data, + ); + } + + /** + * Verify and persist a browser-generated Ed25519 signature. + * + * The content hash, claims hash, domain, and signed timestamp are + * recomputed on the server. Client-provided values are accepted only when + * they match that authoritative result, preventing a browser from signing + * bytes for content different from the published post. + * + * @param int $post_id Post ID. + * @param array $submitted Browser submission. + * @return array Result of persistence. + */ + public function complete_local_signing($post_id, $submitted) { + $post = get_post($post_id); + if (!$post) { + return array('success' => false, 'message' => 'Post not found.'); + } + + if (!$this->current_user_is_post_author($post)) { + return array('success' => false, 'message' => 'Only the post author may sign locally.'); + } + + $author_profile = $this->db->get_author_by_wp_user_id($post->post_author); + if (!$author_profile) { + return array('success' => false, 'message' => 'Author does not have a signing profile.'); + } + + if ((int) $author_profile->server_id !== 0) { + return array('success' => false, 'message' => 'Remote author profiles cannot enter the browser-local signing path.'); + } + + $keyid = isset($submitted['keyid']) ? trim((string) $submitted['keyid']) : ''; + $prepare_token = isset($submitted['prepareToken']) ? trim((string) $submitted['prepareToken']) : ''; + $public_key = isset($submitted['publicKey']) ? (string) $submitted['publicKey'] : ''; + $signature = isset($submitted['signature']) ? (string) $submitted['signature'] : ''; + $signed_at = isset($submitted['signedAt']) ? trim((string) $submitted['signedAt']) : ''; + + if ($keyid === '' || strlen($keyid) > 255 || $public_key === '' || $signature === '' || $signed_at === '' || $prepare_token === '') { + return array('success' => false, 'message' => 'A prepare token, key ID, public key, signature, and signed timestamp are required.'); + } + + if (!$this->is_valid_keyid($keyid)) { + return array('success' => false, 'message' => 'Invalid key ID.'); + } + + $prepared = get_transient($this->prepare_transient_key($prepare_token)); + if (!is_array($prepared) + || (int) $prepared['post_id'] !== (int) $post_id + || (int) $prepared['user_id'] !== (int) get_current_user_id() + || !hash_equals((string) $prepared['keyid'], $keyid)) { + return array('success' => false, 'message' => 'The signing preparation is absent, expired, or bound to a different post or key. Prepare a new signature.'); + } + + if (!$this->is_rfc3339_utc($signed_at) || !hash_equals((string) $prepared['signedAt'], $signed_at)) { + return array('success' => false, 'message' => 'signedAt must be an RFC3339 UTC timestamp.'); + } + try { - $content_data = $this->prepare_content_data($post); + $content_data = $this->prepare_content_data($post, $prepared['signedAt']); + $expected = $this->local_signing_data($content_data, $post_id, $keyid); } catch (Exception $e) { return array( 'success' => false, - 'message' => 'Content canonicalization failed: ' . $e->getMessage(), + 'message' => 'Local signing payload preparation failed: ' . $e->getMessage(), ); } - // Get the author's API key - $author_api_key = $this->db->decrypt($author_profile->author_api_key_encrypted); + foreach (array('payload', 'contentHash', 'claimsHash', 'domain', 'signedAt', 'profile', 'algorithm', 'scope', 'location', 'sourceURL') as $prepared_field) { + if (!isset($prepared[$prepared_field]) || !hash_equals((string) $prepared[$prepared_field], (string) $expected[$prepared_field])) { + return array('success' => false, 'message' => 'The signing preparation no longer matches the post. Prepare a new signature.'); + } + } - // Create a new API client for this server - $api_client = new ContentSigning_API_Client( - $server->api_url, - $this->db->decrypt($server->api_key_encrypted), - $this->db - ); + foreach (array('contentHash', 'claimsHash', 'domain', 'signedAt', 'payload', 'profile', 'algorithm', 'scope', 'location', 'sourceURL') as $field) { + if (!array_key_exists($field, $submitted)) { + return array('success' => false, 'message' => 'The complete v1 signing payload is required. Prepare a new signature.'); + } + if (isset($submitted[$field]) && (string) $submitted[$field] !== (string) $expected[$field]) { + return array('success' => false, 'message' => 'The post changed while it was being signed. Prepare a new signature.'); + } + } + + $public_key_bytes = $this->decode_base64url($public_key); + $signature_bytes = $this->decode_base64url($signature); + if (false === $public_key_bytes || false === $signature_bytes || strlen($public_key_bytes) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES || strlen($signature_bytes) !== SODIUM_CRYPTO_SIGN_BYTES) { + return array('success' => false, 'message' => 'The public key or signature is malformed.'); + } - // Record the signature attempt - $signature_id = $this->db->insert_signature(array( + $public_key_spki = $this->ed25519_raw_to_spki_base64($public_key_bytes); + if (false === $public_key_spki) { + return array('success' => false, 'message' => 'The public key is malformed.'); + } + + if (!function_exists('sodium_crypto_sign_verify_detached') || !sodium_crypto_sign_verify_detached($signature_bytes, $expected['payload'], $public_key_bytes)) { + return array('success' => false, 'message' => 'The submitted signature did not verify.'); + } + + // Only a verified proof of possession may establish the immutable + // key ID binding. A malformed first submission must not reserve the + // identifier for a public key that did not sign this payload. + if (!$this->local_keyid_matches_public_key($keyid, $public_key_spki)) { + return array('success' => false, 'message' => 'This key ID is already bound to a different public key. Rotate with a new key ID.'); + } + + // add_option is a single-row insert and therefore gives us a + // persistent compare-and-set guard across concurrent PHP requests. + // Re-read the transient after acquiring it so a second request that + // observed the same state cannot proceed after the first consumes it. + $consume_lock = $this->prepare_consume_lock_key($prepare_token); + $existing_lock = get_option($consume_lock, false); + if ($existing_lock && strtotime((string) $existing_lock) < (time() - (10 * MINUTE_IN_SECONDS))) { + // A process can die after persistence and before cleanup. The + // prepare transient expires after five minutes, so a ten-minute + // lock is safe to reclaim and cannot authorize a late retry. + delete_option($consume_lock); + } + if (!add_option($consume_lock, gmdate('c'), '', 'no')) { + return array('success' => false, 'message' => 'This signing preparation has already been consumed. Prepare a new signature.'); + } + if (false === get_transient($this->prepare_transient_key($prepare_token))) { + delete_option($consume_lock); + return array('success' => false, 'message' => 'The signing preparation is absent or expired. Prepare a new signature.'); + } + + $signature_data = array( 'post_id' => $post_id, - 'server_id' => $server->server_id, + 'server_id' => 0, 'signing_author_id' => $author_profile->signing_author_id, - 'wp_user_id' => get_current_user_id(), + 'wp_user_id' => get_current_user_id() ? get_current_user_id() : $post->post_author, 'content_hash' => $content_data['contentHash'], 'domain' => $content_data['domain'], - 'status' => 'pending', - )); + 'signature' => $signature, + 'keyid' => $keyid, + // Store resolver-compatible SPKI DER, encoded with canonical + // unpadded standard Base64. The browser may submit raw bytes. + 'public_key' => $public_key_spki, + 'signing_mode' => 'local-browser', + 'claims_json' => wp_json_encode($content_data['claims']), + 'status' => 'signed', + 'signed_at' => $content_data['signedAtMysql'], + 'api_response' => array( + 'algorithm' => 'ed25519', + 'keyid' => $keyid, + 'payload' => $expected['payload'], + 'profile' => $expected['profile'], + 'scope' => $expected['scope'], + 'location' => $expected['location'], + 'sourceURL' => $expected['sourceURL'], + 'mode' => 'local-browser', + ), + ); + + if (!apply_filters('content_signing_local_signature_persistence', true, $post_id, $signature_data)) { + delete_option($consume_lock); + return array('success' => false, 'message' => 'Local signature persistence was unavailable. Retry with the same preparation.'); + } + + $pending = $this->db->get_pending_local_signature($post_id); + if ($pending) { + $signature_id = (int) $pending->signature_id; + $stored = $this->db->update_signature($signature_id, $signature_data); + if (!$stored) { + $signature_id = false; + } + } else { + $signature_id = $this->db->insert_signature($signature_data); + } if (!$signature_id) { - return array( - 'success' => false, - 'message' => 'Failed to record signature attempt.', - ); + // Keep the prepare state available for a retry when persistence + // fails, while releasing the compare-and-set guard. + delete_option($consume_lock); + return array('success' => false, 'message' => 'Failed to store the local signature.'); } - // Sign the content - $result = $api_client->sign_content($this->prepare_api_content_data($content_data), $author_api_key); + delete_transient($this->prepare_transient_key($prepare_token)); + // Keep the persistent replay guard until bounded cleanup removes it. + // If a transient backend fails to delete the prepare state, releasing + // this option here would make the successfully used token valid again. - // Update the signature record - if (is_wp_error($result)) { - $this->db->update_signature($signature_id, array( - 'status' => 'error', - 'api_response' => array( - 'error' => $result->get_error_message(), - 'data' => $result->get_error_data(), - ), - )); + return array( + 'success' => true, + 'message' => 'Content signed locally and verified before persistence.', + 'data' => array_merge($expected, array('signatureId' => $signature_id, 'keyid' => $keyid, 'algorithm' => 'ed25519')), + ); + } - return array( - 'success' => false, - 'message' => $result->get_error_message(), - 'data' => $result->get_error_data(), - ); - } else { - $this->db->update_signature($signature_id, array( - 'signature' => $result['signature'], + /** + * Queue a publication for author-side signing. + * + * Headless and scheduled publication cannot access a browser key. They + * remain unsigned and receive a durable queue record for a later editor + * session rather than falling back to remote signing. + * + * @param int $post_id Post ID. + * @return int|false Signature ID or false. + */ + public function queue_local_signature($post_id) { + $post = get_post($post_id); + if (!$post) { + return false; + } + + $author_profile = $this->db->get_author_by_wp_user_id($post->post_author); + if (!$author_profile) { + return false; + } + + if ((int) $author_profile->server_id !== 0) { + return false; + } + + try { + $content_data = $this->prepare_content_data($post); + } catch (Exception $e) { + return false; + } + + $pending = $this->db->get_pending_local_signature($post_id); + if ($pending) { + $this->db->update_signature($pending->signature_id, array( + 'signing_author_id' => $author_profile->signing_author_id, + 'wp_user_id' => $post->post_author, + 'content_hash' => $content_data['contentHash'], + 'domain' => $content_data['domain'], 'claims_json' => wp_json_encode($content_data['claims']), - 'status' => 'signed', - 'signed_at' => $content_data['signedAtMysql'], - 'api_response' => $result, + 'api_response' => array('mode' => 'local-browser', 'reason' => 'browser-required', 'refreshed_at' => current_time('mysql', true)), )); + return (int) $pending->signature_id; + } - // Process endorsements if enabled - if (get_option('content_signing_enable_endorsements', false)) { - $this->process_endorsements($post_id, $content_data); - } + return $this->db->insert_signature(array( + 'post_id' => $post_id, + 'server_id' => 0, + 'signing_author_id' => $author_profile->signing_author_id, + 'wp_user_id' => $post->post_author, + 'content_hash' => $content_data['contentHash'], + 'domain' => $content_data['domain'], + 'claims_json' => wp_json_encode($content_data['claims']), + 'status' => 'awaiting-local-signature', + 'signing_mode' => 'local-browser', + 'api_response' => array('mode' => 'local-browser', 'reason' => 'browser-required'), + )); + } - return array( - 'success' => true, - 'message' => 'Content signed successfully.', - 'data' => $result, - ); + /** + * Build the exact frozen v1 RFC 8785 payload signed by this plugin. + * + * @param array $content_data Prepared content data. + * @param int $post_id Post ID. + * @return array Payload fields. + */ + private function local_signing_data($content_data, $post_id, $keyid) { + $scope = 'url'; + $algorithm = 'ed25519'; + $payload = Signature::buildSigningPayloadV1(array( + 'contentHash' => $content_data['contentHash'], + 'claimsHash' => $content_data['claimsHash'], + 'documentURL' => $content_data['sourceURL'], + 'scope' => $scope, + 'keyid' => $keyid, + 'algorithm' => $algorithm, + 'signedAt' => $content_data['signedAt'], + )); + + return array( + 'postId' => (int) $post_id, + 'contentHash' => $content_data['contentHash'], + 'claimsHash' => $content_data['claimsHash'], + 'domain' => $content_data['domain'], + 'claims' => $content_data['claims'], + 'signedAt' => $content_data['signedAt'], + 'sourceURL' => $content_data['sourceURL'], + 'profile' => Signature::SIGNING_PROFILE_V1, + 'algorithm' => $algorithm, + 'scope' => $scope, + 'location' => Signature::deriveSigningLocationV1($content_data['sourceURL'], $scope), + 'keyid' => $keyid, + 'payload' => $payload, + ); + } + + /** + * Validate a key identifier before it is included in a v1 signing object. + * + * @param string $keyid Key identifier. + * @return bool Whether the identifier is non-empty and path-safe. + */ + private function is_valid_keyid($keyid) { + return strlen($keyid) <= 255 && preg_match('~^[A-Za-z0-9._:/#?=-]{1,255}$~', $keyid) === 1; + } + + /** + * Build the transient key for a one-time signing preparation. + * + * @param string $token Preparation token. + * @return string Transient key. + */ + private function prepare_transient_key($token) { + return 'content_signing_prepare_' . hash('sha256', (string) $token); + } + + /** + * Build the persistent compare-and-set key for token consumption. + * + * @param string $token Preparation token. + * @return string Option key. + */ + private function prepare_consume_lock_key($token) { + return 'content_signing_consumed_' . hash('sha256', (string) $token); + } + + /** + * Build the persistent key binding option for a key identifier. + * + * @param string $keyid Key identifier. + * @return string Option name. + */ + private function local_key_binding_option_key($keyid) { + return 'content_signing_key_binding_' . hash('sha256', (string) $keyid); + } + + /** + * Atomically bind a local key identifier to its first SPKI public key. + * + * add_option performs a single-row insert, so two first-use requests with + * different keys have one winner. The winner remains authoritative across + * all later signatures and is seeded from the earliest historical signed + * row when upgrading an installation that predates this binding option. + * + * @param string $keyid Key identifier. + * @param string $public_key_spki Canonical SPKI public key. + * @return bool Whether the submitted key matches the immutable binding. + */ + private function local_keyid_matches_public_key($keyid, $public_key_spki) { + $option_name = $this->local_key_binding_option_key($keyid); + $bound_key = get_option($option_name, null); + + if ($bound_key === null) { + $legacy_key = $this->db->get_local_public_key_by_keyid($keyid); + $initial_key = $legacy_key ? (string) $legacy_key : (string) $public_key_spki; + add_option($option_name, $initial_key, '', 'no'); + // If another request won the insert, this read observes its key. + $bound_key = get_option($option_name, null); } + + return is_string($bound_key) + && hash_equals((string) $bound_key, (string) $public_key_spki); } /** - * Process endorsements for a post. + * Reclaim a bounded number of consume locks left by crashed requests. * - * @since 1.0.0 - * @param int $post_id The post ID. - * @param array $content_data The content data. - * @return void + * The prepare transient expires after five minutes. Locks older than ten + * minutes cannot authorize a retry and are safe to remove. The bounded + * query keeps normal prepare requests from scanning an unbounded options + * table. + * + * @return void */ - private function process_endorsements($post_id, $content_data) { - // Get selected endorser profiles - $endorser_profile_ids = get_option('content_signing_endorser_profiles', array()); - if (empty($endorser_profile_ids)) { + private function cleanup_orphaned_consume_locks() { + global $wpdb; + + if (!isset($wpdb->options) || !method_exists($wpdb, 'esc_like')) { return; } - // Get all site endorsers - $endorsers = $this->db->get_site_endorsers(); - if (empty($endorsers)) { + $prefix = 'content_signing_consumed_'; + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 100", + $wpdb->esc_like($prefix) . '%' + )); + + if (!is_array($rows)) { return; } - // Filter to only selected endorsers - $selected_endorsers = array(); - foreach ($endorsers as $endorser) { - if (in_array($endorser->author_profile_id, $endorser_profile_ids)) { - $selected_endorsers[] = $endorser; + $cutoff = time() - (10 * MINUTE_IN_SECONDS); + foreach ($rows as $row) { + $created_at = strtotime((string) $row->option_value); + if ($created_at && $created_at < $cutoff) { + delete_option($row->option_name); } } + } - // Process each endorser - foreach ($selected_endorsers as $endorser) { - // Get the server for this endorser - $server = $this->db->get_server($endorser->server_id); - if (!$server) { - continue; - } + /** + * Require the WordPress post author for browser-held signing keys. + * + * @param WP_Post $post Post being signed. + * @return bool Whether the current session is the post author. + */ + private function current_user_is_post_author($post) { + $current_user_id = get_current_user_id(); + return $current_user_id > 0 && (int) $current_user_id === (int) $post->post_author; + } - // Get the endorser's API key - $endorser_api_key = $this->db->decrypt($endorser->author_api_key_encrypted); + /** + * Validate the timestamp grammar used by the protocol. + * + * @param string $value Timestamp. + * @return bool Whether valid UTC RFC3339. + */ + private function is_rfc3339_utc($value) { + $date = DateTime::createFromFormat('!Y-m-d\\TH:i:s\\Z', $value, new DateTimeZone('UTC')); + return $date && $date->format('Y-m-d\\TH:i:s\\Z') === $value; + } - // Create a new API client for this server - $api_client = new ContentSigning_API_Client( - $server->api_url, - $this->db->decrypt($server->api_key_encrypted), - $this->db - ); + /** + * Decode unpadded base64url input strictly. + * + * @param string $value Base64url value. + * @return string|false Decoded bytes. + */ + private function decode_base64url($value) { + if (!preg_match('/^[A-Za-z0-9_-]+$/', $value)) { + return false; + } - // Record the endorsement attempt - $signature_id = $this->db->insert_signature(array( - 'post_id' => $post_id, - 'server_id' => $server->server_id, - 'signing_author_id' => $endorser->signing_author_id, - 'wp_user_id' => get_current_user_id(), - 'content_hash' => $content_data['contentHash'], - 'domain' => $content_data['domain'], - 'status' => 'pending', - )); + $padding = strlen($value) % 4; + if ($padding) { + $value .= str_repeat('=', 4 - $padding); + } - if (!$signature_id) { - continue; - } + return base64_decode(strtr($value, '-_', '+/'), true); + } - // Sign the content with the endorser's key - $result = $api_client->sign_content($this->prepare_api_content_data($content_data), $endorser_api_key); - - // Update the signature record - if (is_wp_error($result)) { - $this->db->update_signature($signature_id, array( - 'status' => 'error', - 'api_response' => array( - 'error' => $result->get_error_message(), - 'data' => $result->get_error_data(), - ), - )); - } else { - $this->db->update_signature($signature_id, array( - 'signature' => $result['signature'], - 'claims_json' => wp_json_encode($content_data['claims']), - 'status' => 'signed', - 'signed_at' => $content_data['signedAtMysql'], - 'api_response' => $result, - )); - } + /** + * Wrap a raw Ed25519 public key as canonical SPKI DER Base64. + * + * @param string $raw_key Raw 32-byte Ed25519 public key. + * @return string|false Unpadded standard Base64 or false when malformed. + */ + private function ed25519_raw_to_spki_base64($raw_key) { + if (strlen($raw_key) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + return false; + } + + // SubjectPublicKeyInfo for id-Ed25519 with a 32-byte BIT STRING. + $prefix = "\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00"; + return rtrim(base64_encode($prefix . $raw_key), '='); + } + + /** + * Decode the resolver's canonical SPKI DER Ed25519 public key. + * + * @param string $value Unpadded standard Base64 SPKI DER. + * @return string|false Raw 32-byte Ed25519 public key or false. + */ + private function decode_ed25519_spki_base64($value) { + if ($value === '' || preg_match('/^[A-Za-z0-9+\/]+$/', $value) !== 1 || strlen($value) % 4 === 1) { + return false; } + + $encoded = $value; + + $padding = strlen($value) % 4; + if ($padding) { + $value .= str_repeat('=', 4 - $padding); + } + + $der = base64_decode($value, true); + $prefix = "\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00"; + if ($der === false + || rtrim(base64_encode($der), '=') !== $encoded + || strlen($der) !== strlen($prefix) + SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES + || substr($der, 0, strlen($prefix)) !== $prefix) { + return false; + } + + return substr($der, strlen($prefix)); + } + + /** + * Sign a post. + * + * @since 1.0.0 + * @param int $post_id The post ID. + * @return array The result of the signing operation. + */ + public function sign_post($post_id) { + return array( + 'success' => false, + 'message' => 'Server-side content signing is disabled. Use browser-local signing.', + 'code' => 'local_signing_required', + ); } /** @@ -362,9 +733,9 @@ private function process_endorsements($post_id, $content_data) { * @param WP_Post $post The post object. * @return array The content data. */ - private function prepare_content_data($post) { + private function prepare_content_data($post, $signed_at = null) { $base_url = get_permalink($post); - $signed_at = gmdate('Y-m-d\TH:i:s\Z'); + $signed_at = $signed_at ? (string) $signed_at : gmdate('Y-m-d\TH:i:s\Z'); $author_name = $this->get_post_author_name($post); // Hash the rendered content, not the raw post_content. The @@ -430,10 +801,10 @@ private function prepare_content_data($post) { * registered on `the_content` itself, so leaving it attached would recurse * and fold a element into the hashed bytes. * - * Caveat: filters registered on `the_content` at a priority later than the - * display callback (20) run after the wrapper and are therefore outside the - * signed bytes on the published page. Themes that mutate content that late - * will break reproducibility. + * Caveat: a filter registered later at the same maximum priority runs + * after the wrapper and is outside the signed bytes. WordPress has no + * ordering signal for that same-priority case, and block context can also + * differ between this pass and the frontend request. * * @since 1.0.0 * @param WP_Post $post The post object. @@ -485,27 +856,6 @@ private function get_display() { return $public->get_display(); } - /** - * Prepare the API-facing signing payload. - * - * @since 1.0.0 - * @param array $content_data Internal content data. - * @return array API content data. - */ - private function prepare_api_content_data($content_data) { - // claimsHash is not optional: the signing payload binding is - // "content-hash:claims-hash:domain:signed-at" (draft §5), and the - // reference server rejects the request outright when it is absent. - return array( - 'contentHash' => $content_data['contentHash'], - 'claimsHash' => $content_data['claimsHash'], - 'domain' => $content_data['domain'], - 'claims' => $content_data['claims'], - 'signedAt' => $content_data['signedAt'], - 'sourceURL' => $content_data['sourceURL'], - ); - } - /** * Normalize content for consistent hashing. * @@ -724,6 +1074,10 @@ public function verify_post_signature($post_id, $signature_id) { ); } + if (isset($signature->signing_mode) && $signature->signing_mode === 'local-browser') { + return $this->verify_local_signature($post, $signature); + } + // Get the server for this signature $server = $this->db->get_server($signature->server_id); if (!$server) { @@ -794,4 +1148,64 @@ public function verify_post_signature($post_id, $signature_id) { ); } } + + /** + * Verify a locally-created signature without a trust-server request. + * + * @param WP_Post $post Post object. + * @param object $signature Signature row. + * @return array Verification result. + */ + private function verify_local_signature($post, $signature) { + if (empty($signature->public_key) || empty($signature->signature)) { + return array('success' => false, 'message' => 'Local public key or signature is missing.'); + } + + $claims = json_decode((string) $signature->claims_json, true); + if (!is_array($claims) || empty($claims['signed-at'])) { + return array('success' => false, 'message' => 'Stored local claims are missing.'); + } + + try { + $current = $this->prepare_content_data($post, (string) $claims['signed-at']); + } catch (Exception $e) { + return array('success' => false, 'message' => 'Content canonicalization failed: ' . $e->getMessage()); + } + + if ($current['contentHash'] !== $signature->content_hash || $current['domain'] !== $signature->domain || wp_json_encode($current['claims']) !== wp_json_encode($claims)) { + return array('success' => true, 'message' => 'Signature is cryptographically valid, but the published content no longer matches.', 'data' => array('valid' => false, 'reason' => 'content-changed')); + } + + $metadata = json_decode((string) $signature->api_response_json, true); + $keyid = isset($signature->keyid) ? (string) $signature->keyid : ''; + try { + $expected = $this->local_signing_data($current, (int) $post->ID, $keyid); + } catch (Exception $e) { + return array('success' => true, 'message' => 'Local signature payload cannot be rebuilt: ' . $e->getMessage(), 'data' => array('valid' => false)); + } + if (!is_array($metadata) + || !isset($metadata['profile'], $metadata['algorithm'], $metadata['scope'], $metadata['location'], $metadata['sourceURL'], $metadata['payload']) + || $metadata['profile'] !== $expected['profile'] + || $metadata['algorithm'] !== $expected['algorithm'] + || $metadata['scope'] !== $expected['scope'] + || $metadata['location'] !== $expected['location'] + || $metadata['sourceURL'] !== $expected['sourceURL'] + || $metadata['payload'] !== $expected['payload']) { + return array('success' => true, 'message' => 'Local signature metadata does not match the v1 signing profile.', 'data' => array('valid' => false)); + } + + // Rebuild the RFC 8785 payload from current content and stored v1 + // attributes. The persisted payload is diagnostic metadata only. + $payload = $expected['payload']; + $public_key = $this->decode_ed25519_spki_base64((string) $signature->public_key); + $signature_bytes = $this->decode_base64url((string) $signature->signature); + + $valid = $public_key !== false && $signature_bytes !== false && strlen($public_key) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES && strlen($signature_bytes) === SODIUM_CRYPTO_SIGN_BYTES && function_exists('sodium_crypto_sign_verify_detached') && sodium_crypto_sign_verify_detached($signature_bytes, $payload, $public_key); + + return array( + 'success' => true, + 'message' => $valid ? 'Local signature verified successfully.' : 'Local signature verification failed.', + 'data' => array('valid' => $valid, 'keyid' => isset($signature->keyid) ? $signature->keyid : ''), + ); + } } diff --git a/wordpress/includes/db/class-content-signing-db.php b/wordpress/includes/db/class-content-signing-db.php index f7f5249..544ad8e 100644 --- a/wordpress/includes/db/class-content-signing-db.php +++ b/wordpress/includes/db/class-content-signing-db.php @@ -348,12 +348,14 @@ public function insert_author($data) { // Encrypt the API key. Refuse the write outright if encryption is // unavailable: storing an unencrypted key would be worse than failing. - if (!empty($data['author_api_key'])) { - $encrypted = $this->encrypt($data['author_api_key']); - if (null === $encrypted) { - return false; + if (array_key_exists('author_api_key', $data)) { + if (!empty($data['author_api_key'])) { + $encrypted = $this->encrypt($data['author_api_key']); + if (null === $encrypted) { + return false; + } + $data['author_api_key_encrypted'] = $encrypted; } - $data['author_api_key_encrypted'] = $encrypted; unset($data['author_api_key']); } @@ -381,12 +383,14 @@ public function update_author($author_profile_id, $data) { $data['updated_at'] = current_time('mysql'); // Encrypt the API key if provided - if (!empty($data['author_api_key'])) { - $encrypted = $this->encrypt($data['author_api_key']); - if (null === $encrypted) { - return false; + if (array_key_exists('author_api_key', $data)) { + if (!empty($data['author_api_key'])) { + $encrypted = $this->encrypt($data['author_api_key']); + if (null === $encrypted) { + return false; + } + $data['author_api_key_encrypted'] = $encrypted; } - $data['author_api_key_encrypted'] = $encrypted; unset($data['author_api_key']); } @@ -505,7 +509,10 @@ public function get_authors($args = array()) { * @return array The site endorser profiles. */ public function get_site_endorsers() { - return $this->get_authors(array('is_site_endorser' => 1)); + $endorsers = $this->get_authors(array('is_site_endorser' => 1)); + return array_values(array_filter($endorsers, function ($author) { + return (int) $author->server_id > 0; + })); } /** @@ -526,6 +533,9 @@ public function insert_signature($data) { 'content_hash' => '', 'domain' => '', 'signature' => '', + 'keyid' => null, + 'public_key' => null, + 'signing_mode' => 'remote', 'claims_json' => '{}', 'status' => 'pending', 'api_response_json' => null, @@ -616,6 +626,36 @@ public function get_signature($signature_id) { return $signature; } + /** + * Find a local signature by its public key identifier. + * + * @param string $keyid Public key identifier. + * @return object|null Signature row or null. + */ + public function get_local_signature_by_keyid($keyid) { + return $this->wpdb->get_row( + $this->wpdb->prepare( + "SELECT * FROM {$this->tables['signatures']} WHERE keyid = %s AND signing_mode = 'local-browser' AND status = 'signed' ORDER BY created_at ASC, signature_id ASC LIMIT 1", + $keyid + ) + ); + } + + /** + * Get the immutable public key bound to a local key identifier. + * + * @param string $keyid Public key identifier. + * @return string|null Canonical SPKI DER Base64, or null when unseen. + */ + public function get_local_public_key_by_keyid($keyid) { + return $this->wpdb->get_var( + $this->wpdb->prepare( + "SELECT public_key FROM {$this->tables['signatures']} WHERE keyid = %s AND signing_mode = 'local-browser' AND status = 'signed' AND public_key IS NOT NULL AND public_key <> '' ORDER BY created_at ASC, signature_id ASC LIMIT 1", + $keyid + ) + ); + } + /** * Get signatures for a post. * @@ -655,4 +695,19 @@ public function get_pending_signatures($limit = 50) { ) ); } -} \ No newline at end of file + + /** + * Get the current browser-signing queue entry for a post. + * + * @param int $post_id Post ID. + * @return object|null Pending local signature or null. + */ + public function get_pending_local_signature($post_id) { + return $this->wpdb->get_row( + $this->wpdb->prepare( + "SELECT * FROM {$this->tables['signatures']} WHERE post_id = %d AND signing_mode = 'local-browser' AND status = 'awaiting-local-signature' ORDER BY created_at DESC LIMIT 1", + $post_id + ) + ); + } +} diff --git a/wordpress/public/class-content-signing-display.php b/wordpress/public/class-content-signing-display.php index 3fe4141..49746fb 100644 --- a/wordpress/public/class-content-signing-display.php +++ b/wordpress/public/class-content-signing-display.php @@ -7,6 +7,9 @@ * @subpackage Content_Signing/public */ +use HTMLTrust\Canonicalization\Canonicalize; +use HTMLTrust\Canonicalization\Signature; + class ContentSigning_Display { /** @@ -68,6 +71,13 @@ public function display_signature($content) { return $content; } + // The wrapper is the last content filter we control. Recompute the + // canonical bytes from exactly what reached this callback so a late + // filter cannot leave a stale signature on the page. + if (!$this->matches_stored_content_hash($primary_signature, $content, $post_id)) { + return $content; + } + if (stripos($content, 'get_signature_html($post_id); } @@ -187,6 +197,30 @@ private function get_primary_signature($signatures) { return null; } + /** + * Compare the rendered bytes with the hash captured during signing. + * + * @param object $signature Signature row. + * @param string $content Filtered content passed to this callback. + * @param int $post_id Current post ID. + * @return bool Whether the content still matches. + */ + private function matches_stored_content_hash($signature, $content, $post_id) { + $post = get_post($post_id); + if (!$post || !class_exists(Canonicalize::class)) { + return false; + } + + try { + $canonical = Canonicalize::extractCanonicalText($content, false, get_permalink($post)); + } catch (Exception $e) { + return false; + } + + $hash = 'sha256:' . rtrim(base64_encode(hash('sha256', $canonical, true)), '='); + return hash_equals((string) $signature->content_hash, $hash); + } + /** * Get the HTML for displaying the signature status. * @@ -380,11 +414,30 @@ private function get_signed_section_html($signature, $content) { return $content; } + $metadata = null; + if (isset($signature->signing_mode) && $signature->signing_mode === 'local-browser') { + $metadata = json_decode((string) $signature->api_response_json, true); + if (!$this->has_valid_local_v1_metadata($metadata, $key['keyid'])) { + return $content; + } + } + $html = 'signature) . '" '; $html .= 'keyid="' . esc_attr($key['keyid']) . '" '; $html .= 'algorithm="' . esc_attr($key['algorithm']) . '" '; $html .= 'content-hash="' . esc_attr($signature->content_hash) . '">'; + + if (isset($signature->signing_mode) && $signature->signing_mode === 'local-browser') { + $html = 'signature) . '" '; + $html .= 'keyid="' . esc_attr($key['keyid']) . '" '; + $html .= 'algorithm="' . esc_attr($key['algorithm']) . '" '; + $html .= 'content-hash="' . esc_attr($signature->content_hash) . '">'; + } $html .= $this->get_claim_meta_html($signature); $html .= $content; $html .= ''; @@ -392,6 +445,33 @@ private function get_signed_section_html($signature, $content) { return $html; } + /** + * Validate the required metadata for a local v1 wrapper. + * + * @param array $metadata Metadata stored with the signature. + * @param string $keyid Resolved key identifier. + * @return bool Whether the metadata is complete and internally consistent. + */ + private function has_valid_local_v1_metadata($metadata, $keyid) { + if (!is_array($metadata) + || isset($metadata['mode']) && $metadata['mode'] !== 'local-browser' + || ($metadata['profile'] ?? '') !== Signature::SIGNING_PROFILE_V1 + || ($metadata['scope'] ?? '') !== 'url' + || empty($metadata['location']) + || empty($metadata['sourceURL']) + || ($metadata['keyid'] ?? '') !== $keyid + || ($metadata['algorithm'] ?? '') !== 'ed25519' + || empty($metadata['payload'])) { + return false; + } + + try { + return Signature::deriveSigningLocationV1($metadata['sourceURL'], $metadata['scope']) === $metadata['location']; + } catch (Exception $e) { + return false; + } + } + /** * Get the key identifier and signature algorithm for a signature. * @@ -411,6 +491,16 @@ private function get_key_metadata($signature) { $keyid = ''; $algorithm = ''; + // Local-browser signatures carry their key identifier in the + // signature row. Rendering must remain fully offline and must never + // turn a public page view into a trust-server request. + if (isset($signature->signing_mode) && $signature->signing_mode === 'local-browser') { + return array( + 'keyid' => isset($signature->keyid) ? (string) $signature->keyid : '', + 'algorithm' => 'ed25519', + ); + } + $api_response = json_decode($signature->api_response_json, true); if (is_array($api_response)) { foreach (array('keyid', 'keyId', 'publicKeyUrl') as $field) { diff --git a/wordpress/public/class-content-signing-public.php b/wordpress/public/class-content-signing-public.php index 2383f2a..1cb07c4 100644 --- a/wordpress/public/class-content-signing-public.php +++ b/wordpress/public/class-content-signing-public.php @@ -130,13 +130,59 @@ public function register_hooks() { // Register AJAX handlers add_action('wp_ajax_nopriv_content_signing_verify', array($this, 'ajax_verify_signature')); add_action('wp_ajax_content_signing_verify', array($this, 'ajax_verify_signature')); + + // Local-browser key resolution is a public, read-only endpoint. It + // exposes only the public key needed by verifiers, never claims or + // private material. + add_action('rest_api_init', array($this, 'register_key_route')); // Register content filters if (get_option('content_signing_embed_signature', false)) { - add_filter('the_content', array($this->display, 'display_signature'), 20); + // Run after the normal content filters so the signed bytes are + // the same bytes visitors receive. A filter registered later at + // this exact priority remains outside this boundary. + add_filter('the_content', array($this->display, 'display_signature'), PHP_INT_MAX); } } + /** + * Register the local key resolution endpoint. + * + * @return void + */ + public function register_key_route() { + register_rest_route('htmltrust/v1', '/keys/(?P[A-Za-z0-9._~-]+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array($this, 'resolve_local_key'), + 'permission_callback' => '__return_true', + )); + } + + /** + * Resolve a locally stored public key. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function resolve_local_key($request) { + $keyid = (string) $request['keyid']; + $base = trailingslashit(get_rest_url(null, 'htmltrust/v1/keys')); + $full_keyid = $base . rawurlencode($keyid); + $signature = $this->db->get_local_signature_by_keyid($full_keyid); + if (!$signature || empty($signature->public_key)) { + return new WP_Error('key_not_found', __('Public key not found.', 'content-signing'), array('status' => 404)); + } + + return rest_ensure_response(array( + 'id' => $keyid, + 'keyid' => $full_keyid, + 'algorithm' => 'ed25519', + 'publicKey' => $signature->public_key, + 'publicKeyEncoding' => 'spki-der', + 'type' => 'HUMAN', + )); + } + /** * Shortcode for displaying signature information. * @@ -201,4 +247,4 @@ public function ajax_verify_signature() { wp_send_json_error($result); } } -} \ No newline at end of file +} diff --git a/wordpress/tests/class-content-signing-api-client-test-case.php b/wordpress/tests/class-content-signing-api-client-test-case.php index b412922..05385d7 100644 --- a/wordpress/tests/class-content-signing-api-client-test-case.php +++ b/wordpress/tests/class-content-signing-api-client-test-case.php @@ -36,6 +36,11 @@ class ContentSigning_API_Client_TestCase extends ContentSigning_DB_TestCase { */ public function setUp(): void { parent::setUp(); + + // v1 signing locations require an HTTPS document URL. Keep the test + // site aligned with the production protocol profile. + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); // Create a mock API client $this->api_client = new ContentSigning_API_Client( diff --git a/wordpress/tests/class-content-signing-db-test-case.php b/wordpress/tests/class-content-signing-db-test-case.php index bc15feb..ad72d1b 100644 --- a/wordpress/tests/class-content-signing-db-test-case.php +++ b/wordpress/tests/class-content-signing-db-test-case.php @@ -110,7 +110,7 @@ protected function create_test_server($data = array()) { */ protected function create_test_author($data = array()) { // Create a server if none provided - if (empty($data['server_id'])) { + if (!array_key_exists('server_id', $data)) { $data['server_id'] = $this->create_test_server(); } @@ -145,7 +145,7 @@ protected function create_test_signature($data = array()) { } // Create a server if none provided - if (empty($data['server_id'])) { + if (!array_key_exists('server_id', $data)) { $data['server_id'] = $this->create_test_server(); } diff --git a/wordpress/tests/test-content-signing-admin-author-profiles.php b/wordpress/tests/test-content-signing-admin-author-profiles.php new file mode 100644 index 0000000..777c6b1 --- /dev/null +++ b/wordpress/tests/test-content-signing-admin-author-profiles.php @@ -0,0 +1,118 @@ +db, $this->api_client); + $method = new ReflectionMethod($admin, 'render_author_form'); + + ob_start(); + $method->invoke($admin, null); + $html = ob_get_clean(); + + $this->assertStringContainsString('Browser-local only', $html); + $this->assertStringContainsString('local-wp-user-{ID}', $html); + $this->assertDoesNotMatchRegularExpression('/name="signing_author_id"[^>]*required/', $html); + $this->assertDoesNotMatchRegularExpression('/name="author_api_key"[^>]*required/', $html); + } + + public function test_local_profile_has_stable_identity_and_is_not_an_endorser() { + $user_id = $this->create_test_user(); + $admin = new ContentSigning_Admin_AuthorProfiles($this->db, $this->api_client); + $method = new ReflectionMethod($admin, 'local_signing_author_id'); + + $this->assertSame('local-wp-user-' . $user_id, $method->invoke($admin, $user_id)); + + $profile_id = $this->db->insert_author(array( + 'wp_user_id' => $user_id, + 'signing_author_id' => 'local-wp-user-' . $user_id, + 'server_id' => 0, + 'author_api_key' => '', + 'is_site_endorser' => 0, + )); + $this->assertNotFalse($profile_id); + $this->assertCount(0, $this->db->get_site_endorsers()); + + $profile = $this->db->get_author($profile_id); + $list_method = new ReflectionMethod($admin, 'render_authors_list'); + ob_start(); + $list_method->invoke($admin, array($profile)); + $html = ob_get_clean(); + $this->assertStringContainsString('Browser-local only', $html); + } + + public function test_edit_form_disables_local_endorser_control() { + $user_id = $this->create_test_user(); + $profile_id = $this->db->insert_author(array( + 'wp_user_id' => $user_id, + 'signing_author_id' => 'local-wp-user-' . $user_id, + 'server_id' => 0, + 'is_site_endorser' => 0, + )); + $admin = new ContentSigning_Admin_AuthorProfiles($this->db, $this->api_client); + $method = new ReflectionMethod($admin, 'render_author_form'); + ob_start(); + $method->invoke($admin, $this->db->get_author($profile_id)); + $html = ob_get_clean(); + + $this->assertStringContainsString('Browser-local only', $html); + $this->assertMatchesRegularExpression('/name="is_site_endorser"[^>]*disabled/', $html); + } + + public function test_local_endorser_submission_is_rejected() { + $user_id = $this->create_test_user(); + wp_set_current_user(1); + + $admin = new ContentSigning_Admin_AuthorProfiles($this->db, $this->api_client); + $_POST = array( + 'wp_user_id' => $user_id, + 'server_id' => 0, + 'signing_author_id' => '', + 'author_api_key' => '', + 'is_site_endorser' => 1, + ); + + $method = new ReflectionMethod($admin, 'handle_add_author'); + $method->invoke($admin); + + $this->assertNull($this->db->get_author_by_wp_user_id($user_id)); + $_POST = array(); + } + + public function test_post_meta_box_limits_sign_now_to_local_profiles() { + $local_user = $this->create_test_user(); + $this->db->insert_author(array( + 'wp_user_id' => $local_user, + 'signing_author_id' => 'local-wp-user-' . $local_user, + 'server_id' => 0, + )); + $local_post = $this->create_test_post(array('post_author' => $local_user, 'post_status' => 'publish')); + wp_set_current_user($local_user); + $meta_box = new ContentSigning_Admin_PostMetaBox($this->db, $this->api_client); + ob_start(); + $meta_box->render_meta_box(get_post($local_post)); + $local_html = ob_get_clean(); + $this->assertStringContainsString('Sign Now', $local_html); + + $remote_server = $this->create_test_server(); + $remote_user = $this->create_test_user(); + $this->db->insert_author(array( + 'wp_user_id' => $remote_user, + 'signing_author_id' => 'remote-author-' . $remote_user, + 'server_id' => $remote_server, + 'author_api_key' => 'mock-author-api-key', + )); + $remote_post = $this->create_test_post(array('post_author' => $remote_user, 'post_status' => 'publish')); + wp_set_current_user($remote_user); + ob_start(); + $meta_box->render_meta_box(get_post($remote_post)); + $remote_html = ob_get_clean(); + $this->assertStringNotContainsString('class="button sign-post"', $remote_html); + $this->assertStringContainsString('remote author profile', strtolower($remote_html)); + } +} diff --git a/wordpress/tests/test-content-signing-api-client.php b/wordpress/tests/test-content-signing-api-client.php index 7c92fdf..3bba18e 100644 --- a/wordpress/tests/test-content-signing-api-client.php +++ b/wordpress/tests/test-content-signing-api-client.php @@ -75,9 +75,9 @@ public function test_get_author_public_key() { } /** - * Test sign_content method. + * Server-side signing is permanently disabled. */ - public function test_sign_content() { + public function test_sign_content_is_disabled() { $content_data = array( 'contentHash' => 'sha256:test-content-hash', 'domain' => 'test.example.com', @@ -90,13 +90,8 @@ public function test_sign_content() { $result = $this->api_client->sign_content($content_data, $author_api_key); - $this->assertNotWPError($result); - $this->assertEquals($content_data['contentHash'], $result['contentHash']); - $this->assertEquals($content_data['domain'], $result['domain']); - $this->assertEquals('mock-author-id', $result['authorId']); - $this->assertEquals('mock-key-id', $result['keyId']); - $this->assertEquals('mock-signature', $result['signature']); - $this->assertEquals($content_data['claims'], $result['claims']); + $this->assertWPError($result); + $this->assertSame('remote_signing_disabled', $result->get_error_code()); } /** diff --git a/wordpress/tests/test-content-signing-browser-assets.php b/wordpress/tests/test-content-signing-browser-assets.php new file mode 100644 index 0000000..ba8ac6b --- /dev/null +++ b/wordpress/tests/test-content-signing-browser-assets.php @@ -0,0 +1,28 @@ +assertNotFalse($script); + $this->assertStringContainsString("generateKey({name: 'Ed25519'}, false, ['sign', 'verify'])", $script); + $this->assertStringContainsString("exportKey('raw', key)", $script); + $this->assertStringNotContainsString("exportKey('pkcs8'", $script); + $this->assertStringNotContainsString("importKey('pkcs8'", $script); + $this->assertDoesNotMatchRegularExpression('/privateKey\s*:\s*stored/', $script); + $this->assertStringContainsString('publicKey: publicKey', $script); + $this->assertStringContainsString('signature: toBase64Url', $script); + } +} diff --git a/wordpress/tests/test-content-signing-db.php b/wordpress/tests/test-content-signing-db.php index b1ca474..d61f948 100644 --- a/wordpress/tests/test-content-signing-db.php +++ b/wordpress/tests/test-content-signing-db.php @@ -341,4 +341,18 @@ public function test_signature_crud() { $this->assertCount(1, $remaining); } -} \ No newline at end of file + + public function test_local_public_key_lookup_ignores_pending_rows() { + $keyid = 'https://example.org/key/pending'; + $signature_id = $this->create_test_signature(array( + 'server_id' => 0, + 'keyid' => $keyid, + 'public_key' => 'pending-public-key', + 'signing_mode' => 'local-browser', + 'status' => 'awaiting-local-signature', + )); + + $this->assertNotFalse($signature_id); + $this->assertNull($this->db->get_local_public_key_by_keyid($keyid)); + } +} diff --git a/wordpress/tests/test-content-signing-integration.php b/wordpress/tests/test-content-signing-integration.php index 630a876..79f063e 100644 --- a/wordpress/tests/test-content-signing-integration.php +++ b/wordpress/tests/test-content-signing-integration.php @@ -51,7 +51,7 @@ public function test_complete_signing_workflow() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Create a post @@ -71,14 +71,9 @@ public function test_complete_signing_workflow() { // Verify a signature was created $signatures = $this->db->get_signatures_by_post_id($post_id); $this->assertCount(1, $signatures); - $this->assertEquals('signed', $signatures[0]->status); - - // Verify the signature - $signature_id = $signatures[0]->signature_id; - $result = $this->signing_service->verify_post_signature($post_id, $signature_id); + $this->assertEquals('awaiting-local-signature', $signatures[0]->status); - $this->assertTrue($result['success']); - $this->assertEquals('Signature verified successfully.', $result['message']); + $this->assertEquals('local-browser', $signatures[0]->signing_mode); } /** @@ -92,7 +87,7 @@ public function test_wordpress_hooks_integration() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Create a post @@ -109,6 +104,10 @@ public function test_wordpress_hooks_integration() { // Verify the content_signing_scheduled_signing hook is registered $this->assertTrue(has_action('content_signing_scheduled_signing')); + + // Key resolution must be registered independently of frontend asset + // enqueueing because verifiers call it from REST requests. + $this->assertTrue(has_action('rest_api_init')); } /** @@ -147,7 +146,7 @@ public function test_scheduled_signing_workflow() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Configure for scheduled signing @@ -175,7 +174,7 @@ public function test_scheduled_signing_workflow() { // Verify a signature was created $signatures = $this->db->get_signatures_by_post_id($post_id); $this->assertCount(1, $signatures); - $this->assertEquals('signed', $signatures[0]->status); + $this->assertEquals('awaiting-local-signature', $signatures[0]->status); // Reset options update_option('content_signing_sign_on_publish', true); @@ -193,7 +192,7 @@ public function test_endorsement_workflow() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Create endorser profiles @@ -217,15 +216,11 @@ public function test_endorsement_workflow() { )); wp_publish_post($post_id); - // Verify signatures were created (1 primary + 2 endorsements) + // Browser signing creates one queue entry. Endorsements cannot use a + // private key held by the remote trust server anymore. $signatures = $this->db->get_signatures_by_post_id($post_id); - $this->assertCount(3, $signatures); - - // Verify all signatures are valid - foreach ($signatures as $signature) { - $result = $this->signing_service->verify_post_signature($post_id, $signature->signature_id); - $this->assertTrue($result['success']); - } + $this->assertCount(1, $signatures); + $this->assertEquals('awaiting-local-signature', $signatures[0]->status); // Reset options update_option('content_signing_enable_endorsements', false); @@ -284,7 +279,6 @@ public function test_public_rendering_wraps_actual_signed_content() { $display = new ContentSigning_Display($this->db, $this->api_client); $method = new ReflectionMethod($display, 'get_signed_section_html'); - $method->setAccessible(true); $html = $method->invoke($display, $this->db->get_signature($signature_id), '

    Signed body

    '); $this->assertStringStartsWith('db, $this->api_client); $method = new ReflectionMethod($display, 'get_signed_section_html'); - $method->setAccessible(true); - $this->assertSame( '

    Signed body

    ', $method->invoke($display, $this->db->get_signature($signature_id), '

    Signed body

    ') ); } + /** + * Local signatures emit the frozen v1 profile, scope, and location. + */ + public function test_public_rendering_emits_v1_local_attributes() { + $post_id = $this->create_test_post(array('post_content' => '

    Signed body

    ')); + $signature_id = $this->create_test_signature(array( + 'post_id' => $post_id, + 'signing_mode' => 'local-browser', + 'server_id' => 0, + 'keyid' => 'https://example.org/wp-json/htmltrust/v1/keys/test-key', + 'public_key' => str_repeat('A', 43), + 'signature' => str_repeat('B', 86), + 'api_response' => array( + 'keyid' => 'https://example.org/wp-json/htmltrust/v1/keys/test-key', + 'algorithm' => 'ed25519', + 'profile' => 'htmltrust-signature-v1', + 'scope' => 'url', + 'location' => 'https://example.org/test-post', + 'sourceURL' => 'https://example.org/test-post', + 'payload' => '{}', + ), + )); + $display = new ContentSigning_Display($this->db, $this->api_client); + $method = new ReflectionMethod($display, 'get_signed_section_html'); + $html = $method->invoke($display, $this->db->get_signature($signature_id), '

    Signed body

    '); + + $this->assertStringContainsString('profile="htmltrust-signature-v1"', $html); + $this->assertStringContainsString('signature-scope="url"', $html); + $this->assertStringNotContainsString(' scope="url"', $html); + $this->assertStringContainsString('location="https://example.org/test-post"', $html); + } + + public function test_public_rendering_withholds_corrupt_local_v1_metadata() { + $post_id = $this->create_test_post(array('post_content' => '

    Signed body

    ')); + foreach (array( + array('profile' => 'wrong-profile', 'scope' => 'url', 'location' => 'https://example.org/test-post', 'sourceURL' => 'https://example.org/test-post'), + array('profile' => 'htmltrust-signature-v1', 'scope' => 'origin', 'location' => 'https://example.org/test-post', 'sourceURL' => 'https://example.org/test-post'), + array('profile' => 'htmltrust-signature-v1', 'scope' => 'url', 'location' => '', 'sourceURL' => 'https://example.org/test-post'), + array('profile' => 'htmltrust-signature-v1', 'scope' => 'url', 'location' => 'https://example.org/test-post', 'sourceURL' => ''), + ) as $metadata) { + $signature_id = $this->create_test_signature(array( + 'post_id' => $post_id, + 'signing_mode' => 'local-browser', + 'server_id' => 0, + 'keyid' => 'https://example.org/wp-json/htmltrust/v1/keys/corrupt', + 'public_key' => str_repeat('A', 43), + 'signature' => str_repeat('B', 86), + 'api_response' => array_merge($metadata, array( + 'keyid' => 'https://example.org/wp-json/htmltrust/v1/keys/corrupt', + 'algorithm' => 'ed25519', + 'payload' => '{}', + )), + )); + $display = new ContentSigning_Display($this->db, $this->api_client); + $method = new ReflectionMethod($display, 'get_signed_section_html'); + $html = $method->invoke($display, $this->db->get_signature($signature_id), '

    Signed body

    '); + $this->assertStringNotContainsString('db->delete_signature($signature_id); + } + } + + /** + * A late content filter cannot leave a stale local wrapper on the page. + */ + public function test_public_rendering_withholds_signature_after_late_mutation() { + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0)); + $post_id = $this->create_test_post(array( + 'post_author' => $user_id, + 'post_content' => '

    Signed body

    ', + )); + wp_set_current_user($user_id); + $keyid = 'https://example.org/wp-json/htmltrust/v1/keys/late-filter'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + $keypair = sodium_crypto_sign_keypair(); + $public_key = sodium_crypto_sign_publickey($keypair); + $signature = sodium_crypto_sign_detached($prepared['data']['payload'], sodium_crypto_sign_secretkey($keypair)); + $stored = $this->signing_service->complete_local_signing($post_id, array( + 'prepareToken' => $prepared['data']['prepareToken'], + 'keyid' => $keyid, + 'publicKey' => rtrim(strtr(base64_encode($public_key), '+/', '-_'), '='), + 'signature' => rtrim(strtr(base64_encode($signature), '+/', '-_'), '='), + 'contentHash' => $prepared['data']['contentHash'], + 'claimsHash' => $prepared['data']['claimsHash'], + 'domain' => $prepared['data']['domain'], + 'signedAt' => $prepared['data']['signedAt'], + 'payload' => $prepared['data']['payload'], + 'profile' => $prepared['data']['profile'], + 'algorithm' => $prepared['data']['algorithm'], + 'scope' => $prepared['data']['scope'], + 'location' => $prepared['data']['location'], + 'sourceURL' => $prepared['data']['sourceURL'], + )); + $this->assertTrue($stored['success']); + + $display = $this->plugin->get_public()->get_display(); + $late_filter = function ($content) { + return $content . '

    Late filter mutation

    '; + }; + add_filter('the_content', $late_filter, PHP_INT_MAX - 1); + add_filter('the_content', array($display, 'display_signature'), PHP_INT_MAX); + $this->go_to(get_permalink($post_id)); + $rendered = apply_filters('the_content', '

    Signed body

    '); + remove_filter('the_content', array($display, 'display_signature'), PHP_INT_MAX); + remove_filter('the_content', $late_filter, PHP_INT_MAX - 1); + + $this->assertStringContainsString('Late filter mutation', $rendered); + $this->assertStringNotContainsString(' $user_id, )); - // Temporarily modify the mock API key to trigger an error - $original_key = $this->mock_api_key; - $this->mock_api_key = 'valid-key-but-not-matching'; - - // Sign the post + // The legacy remote endpoint is intentionally disabled. The AJAX + // route remains for clients that need a deterministic error response. + $this->assertNotFalse(has_action('wp_ajax_content_signing_sign_post')); $result = $this->signing_service->sign_post($post_id); - - // Verify error handling + $this->assertFalse($result['success']); - - // Verify signature record with error status $signatures = $this->db->get_signatures_by_post_id($post_id); - $this->assertCount(1, $signatures); - $this->assertEquals('error', $signatures[0]->status); - - // Restore original key - $this->mock_api_key = $original_key; + $this->assertCount(0, $signatures); } } diff --git a/wordpress/tests/test-content-signing-scheduler.php b/wordpress/tests/test-content-signing-scheduler.php index f1c696b..699c62e 100644 --- a/wordpress/tests/test-content-signing-scheduler.php +++ b/wordpress/tests/test-content-signing-scheduler.php @@ -126,7 +126,7 @@ public function test_process_scheduled_signing() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Create a test post with the author @@ -143,7 +143,7 @@ public function test_process_scheduled_signing() { // Verify a signature was created $signatures = $this->db->get_signatures_by_post_id($post_id); $this->assertCount(1, $signatures); - $this->assertEquals('signed', $signatures[0]->status); + $this->assertEquals('awaiting-local-signature', $signatures[0]->status); } /** diff --git a/wordpress/tests/test-content-signing-signing-service.php b/wordpress/tests/test-content-signing-signing-service.php index f098a5c..7cff21d 100644 --- a/wordpress/tests/test-content-signing-signing-service.php +++ b/wordpress/tests/test-content-signing-signing-service.php @@ -91,7 +91,7 @@ public function test_process_post_sign_on_publish() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Enable sign on publish @@ -111,7 +111,7 @@ public function test_process_post_sign_on_publish() { // Verify a signature was created $signatures = $this->db->get_signatures_by_post_id($post_id); $this->assertCount(1, $signatures); - $this->assertEquals('signed', $signatures[0]->status); + $this->assertEquals('awaiting-local-signature', $signatures[0]->status); } /** @@ -123,7 +123,7 @@ public function test_process_post_sign_on_update() { $user_id = $this->create_test_user(); $author_id = $this->create_test_author(array( 'wp_user_id' => $user_id, - 'server_id' => $server_id, + 'server_id' => 0, )); // Enable sign on update, disable sign on publish @@ -143,7 +143,7 @@ public function test_process_post_sign_on_update() { // Verify a signature was created $signatures = $this->db->get_signatures_by_post_id($post_id); $this->assertCount(1, $signatures); - $this->assertEquals('signed', $signatures[0]->status); + $this->assertEquals('awaiting-local-signature', $signatures[0]->status); } /** @@ -183,32 +183,12 @@ public function test_process_post_scheduled_signing() { /** * Test sign_post method. */ - public function test_sign_post() { - // Create a server and author profile - $server_id = $this->create_test_server(); - $user_id = $this->create_test_user(); - $author_id = $this->create_test_author(array( - 'wp_user_id' => $user_id, - 'server_id' => $server_id, - )); - - // Create a test post with the author - $post_id = $this->create_test_post(array( - 'post_author' => $user_id, - )); - - // Sign the post - $result = $this->signing_service->sign_post($post_id); - - // Verify result - $this->assertTrue($result['success']); - $this->assertEquals('Content signed successfully.', $result['message']); - - // Verify a signature was created - $signatures = $this->db->get_signatures_by_post_id($post_id); - $this->assertCount(1, $signatures); - $this->assertEquals('signed', $signatures[0]->status); - $this->assertEquals('mock-signature', $signatures[0]->signature); + public function test_sign_post_is_disabled() { + $result = $this->signing_service->sign_post(123); + + $this->assertFalse($result['success']); + $this->assertSame('local_signing_required', $result['code']); + $this->assertStringContainsString('disabled', strtolower($result['message'])); } /** @@ -281,7 +261,7 @@ public function test_sign_post_missing_post() { // Verify result $this->assertFalse($result['success']); - $this->assertEquals('Post not found.', $result['message']); + $this->assertSame('local_signing_required', $result['code']); } /** @@ -296,7 +276,459 @@ public function test_sign_post_missing_author_profile() { // Verify result $this->assertFalse($result['success']); - $this->assertEquals('Author does not have a signing profile.', $result['message']); + $this->assertSame('local_signing_required', $result['code']); + } + + /** + * Browser signatures are verified by PHP and stored without an API call. + */ + public function test_complete_local_signing_verifies_and_persists() { + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0)); + $post_id = $this->create_test_post(array('post_author' => $user_id, 'post_status' => 'draft')); + wp_set_current_user($user_id); + + $keyid = 'https://example.org/wp-json/htmltrust/v1/keys/test-key'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + $this->assertTrue($prepared['success']); + $this->assertSame('htmltrust-signature-v1', $prepared['data']['profile']); + $this->assertSame('url', $prepared['data']['scope']); + $this->assertSame($prepared['data']['sourceURL'], $prepared['data']['location']); + $this->assertStringStartsWith('{', $prepared['data']['payload']); + $payload_object = json_decode($prepared['data']['payload'], true); + $this->assertSame('htmltrust-signature-v1', $payload_object['profile']); + $this->assertSame($keyid, $payload_object['keyid']); + $this->assertSame('url', $payload_object['scope']); + $this->assertSame($prepared['data']['location'], $payload_object['location']); + $this->assertSame($prepared['data']['signedAt'], $payload_object['signedAt']); + $this->assertSame( + HTMLTrust\Canonicalization\Signature::buildSigningPayloadV1(array( + 'contentHash' => $prepared['data']['contentHash'], + 'claimsHash' => $prepared['data']['claimsHash'], + 'documentURL' => $prepared['data']['sourceURL'], + 'scope' => 'url', + 'keyid' => $keyid, + 'algorithm' => 'ed25519', + 'signedAt' => $prepared['data']['signedAt'], + )), + $prepared['data']['payload'] + ); + + $keypair = sodium_crypto_sign_keypair(); + $public_key = sodium_crypto_sign_publickey($keypair); + $private_key = sodium_crypto_sign_secretkey($keypair); + $signature = sodium_crypto_sign_detached($prepared['data']['payload'], $private_key); + + $result = $this->signing_service->complete_local_signing($post_id, array( + 'prepareToken' => $prepared['data']['prepareToken'], + 'keyid' => $keyid, + 'publicKey' => $this->base64url($public_key), + 'signature' => $this->base64url($signature), + 'contentHash' => $prepared['data']['contentHash'], + 'claimsHash' => $prepared['data']['claimsHash'], + 'domain' => $prepared['data']['domain'], + 'signedAt' => $prepared['data']['signedAt'], + 'payload' => $prepared['data']['payload'], + 'profile' => $prepared['data']['profile'], + 'algorithm' => $prepared['data']['algorithm'], + 'scope' => $prepared['data']['scope'], + 'location' => $prepared['data']['location'], + 'sourceURL' => $prepared['data']['sourceURL'], + )); + + $this->assertTrue($result['success']); + $signatures = $this->db->get_signatures_by_post_id($post_id); + $this->assertCount(1, $signatures); + $this->assertEquals('local-browser', $signatures[0]->signing_mode); + $this->assertEquals($keyid, $signatures[0]->keyid); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9+\/]+$/', $signatures[0]->public_key); + $this->assertStringNotContainsString('=', $signatures[0]->public_key); + $this->assertTrue($this->signing_service->verify_post_signature($post_id, $signatures[0]->signature_id)['data']['valid']); + } + + /** + * The public key endpoint returns the resolver's canonical SPKI document. + */ + public function test_local_key_resolver_document_and_mutation() { + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0)); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = trailingslashit(get_rest_url(null, 'htmltrust/v1/keys')) . 'resolver'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + $keypair = sodium_crypto_sign_keypair(); + $public_key = sodium_crypto_sign_publickey($keypair); + $signature = sodium_crypto_sign_detached($prepared['data']['payload'], sodium_crypto_sign_secretkey($keypair)); + $result = $this->signing_service->complete_local_signing($post_id, array( + 'prepareToken' => $prepared['data']['prepareToken'], + 'keyid' => $keyid, + 'publicKey' => $this->base64url($public_key), + 'signature' => $this->base64url($signature), + 'contentHash' => $prepared['data']['contentHash'], + 'claimsHash' => $prepared['data']['claimsHash'], + 'domain' => $prepared['data']['domain'], + 'signedAt' => $prepared['data']['signedAt'], + 'payload' => $prepared['data']['payload'], + 'profile' => $prepared['data']['profile'], + 'algorithm' => $prepared['data']['algorithm'], + 'scope' => $prepared['data']['scope'], + 'location' => $prepared['data']['location'], + 'sourceURL' => $prepared['data']['sourceURL'], + )); + $this->assertTrue($result['success']); + + $request = new WP_REST_Request('GET', '/htmltrust/v1/keys/resolver'); + $request->set_param('keyid', 'resolver'); + $response = $this->plugin->get_public()->resolve_local_key($request); + $this->assertNotWPError($response, $response instanceof WP_Error ? $response->get_error_message() : 'unexpected response type'); + $document = $response->get_data(); + $this->assertSame('resolver', $document['id']); + $this->assertSame($keyid, $document['keyid']); + $this->assertSame('ed25519', $document['algorithm']); + $this->assertSame('spki-der', $document['publicKeyEncoding']); + $this->assertSame('HUMAN', $document['type']); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9+\/]+$/', $document['publicKey']); + $this->assertStringNotContainsString('=', $document['publicKey']); + $encoded_key = $document['publicKey'] . str_repeat('=', (4 - strlen($document['publicKey']) % 4) % 4); + $der = base64_decode($encoded_key, true); + $this->assertSame("\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00", substr($der, 0, 12)); + $this->assertSame(44, strlen($der)); + + $stored = $this->db->get_signature($result['data']['signatureId']); + $mutated_key = substr($stored->public_key, 0, -1) . ($stored->public_key[-1] === 'A' ? 'B' : 'A'); + $this->assertTrue($this->db->update_signature($stored->signature_id, array('public_key' => $mutated_key))); + $this->assertFalse($this->signing_service->verify_post_signature($post_id, $stored->signature_id)['data']['valid']); + } + + /** + * A signature prepared for old content cannot be attached to new content. + */ + public function test_complete_local_signing_rejects_content_mutation() { + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0)); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = 'https://example.org/wp-json/htmltrust/v1/keys/mutation'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + wp_update_post(array('ID' => $post_id, 'post_content' => 'Changed after prepare.')); + + $keypair = sodium_crypto_sign_keypair(); + $public_key = sodium_crypto_sign_publickey($keypair); + $private_key = sodium_crypto_sign_secretkey($keypair); + $signature = sodium_crypto_sign_detached($prepared['data']['payload'], $private_key); + $result = $this->signing_service->complete_local_signing($post_id, array( + 'prepareToken' => $prepared['data']['prepareToken'], + 'keyid' => $keyid, + 'publicKey' => $this->base64url($public_key), + 'signature' => $this->base64url($signature), + 'signedAt' => $prepared['data']['signedAt'], + 'payload' => $prepared['data']['payload'], + 'contentHash' => $prepared['data']['contentHash'], + 'claimsHash' => $prepared['data']['claimsHash'], + 'domain' => $prepared['data']['domain'], + 'profile' => $prepared['data']['profile'], + 'algorithm' => $prepared['data']['algorithm'], + 'scope' => $prepared['data']['scope'], + 'location' => $prepared['data']['location'], + 'sourceURL' => $prepared['data']['sourceURL'], + )); + + $this->assertFalse($result['success']); + $this->assertMatchesRegularExpression('/changed|preparation/', strtolower($result['message'])); + } + + /** + * v1 metadata mutations cannot be smuggled past independent rebuilding. + */ + public function test_complete_local_signing_rejects_v1_binding_mutations() { + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0)); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = 'https://example.org/wp-json/htmltrust/v1/keys/negative'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + $keypair = sodium_crypto_sign_keypair(); + $public_key = sodium_crypto_sign_publickey($keypair); + $private_key = sodium_crypto_sign_secretkey($keypair); + $signature = sodium_crypto_sign_detached($prepared['data']['payload'], $private_key); + $submission = array( + 'prepareToken' => $prepared['data']['prepareToken'], + 'keyid' => $keyid, + 'publicKey' => $this->base64url($public_key), + 'signature' => $this->base64url($signature), + 'contentHash' => $prepared['data']['contentHash'], + 'claimsHash' => $prepared['data']['claimsHash'], + 'domain' => $prepared['data']['domain'], + 'signedAt' => $prepared['data']['signedAt'], + 'payload' => $prepared['data']['payload'], + 'profile' => $prepared['data']['profile'], + 'algorithm' => $prepared['data']['algorithm'], + 'scope' => $prepared['data']['scope'], + 'location' => $prepared['data']['location'], + 'sourceURL' => $prepared['data']['sourceURL'], + ); + + foreach (array( + 'keyid' => 'https://example.org/wp-json/htmltrust/v1/keys/other', + 'scope' => 'origin', + 'location' => 'https://example.org', + 'algorithm' => 'rsa-pss-sha256', + 'signedAt' => '2026-01-01T00:00:00Z', + ) as $field => $value) { + $mutated = $submission; + $mutated[$field] = $value; + $result = $this->signing_service->complete_local_signing($post_id, $mutated); + $this->assertFalse($result['success'], 'Mutation should be rejected: ' . $field); + } + } + + /** + * Save/publish hooks queue work for a browser instead of remote signing. + */ + public function test_queue_local_signature_does_not_call_remote_api() { + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0)); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + + $signature_id = $this->signing_service->queue_local_signature($post_id); + $this->assertNotFalse($signature_id); + $signature = $this->db->get_signature($signature_id); + $this->assertEquals('awaiting-local-signature', $signature->status); + $this->assertEquals('local-browser', $signature->signing_mode); + $this->assertSame(0, (int) $signature->server_id); + } + + /** + * Browser-held signing keys cannot be used by another editor. + */ + public function test_local_signing_requires_post_author() { + update_option('siteurl', 'https://example.org'); + update_option('home', 'https://example.org'); + $server_id = $this->create_test_server(); + $author_id = $this->create_test_user(); + $other_user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $author_id, 'server_id' => $server_id)); + $post_id = $this->create_test_post(array('post_author' => $author_id)); + wp_set_current_user($other_user_id); + + $prepared = $this->signing_service->prepare_local_signing($post_id, 'https://example.org/wp-json/htmltrust/v1/keys/unauthorized'); + $this->assertFalse($prepared['success']); + $this->assertStringContainsString('post author', strtolower($prepared['message'])); + + $completed = $this->signing_service->complete_local_signing($post_id, array('keyid' => 'https://example.org/wp-json/htmltrust/v1/keys/unauthorized')); + $this->assertFalse($completed['success']); + $this->assertStringContainsString('post author', strtolower($completed['message'])); + } + + public function test_remote_profile_cannot_enter_local_signing_path() { + $server_id = $this->create_test_server(); + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => $server_id)); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + + $prepared = $this->signing_service->prepare_local_signing($post_id, 'https://example.org/key/remote'); + $this->assertFalse($prepared['success']); + $this->assertStringContainsString('server ID 0', $prepared['message']); + $this->assertFalse($this->signing_service->queue_local_signature($post_id)); + $completed = $this->signing_service->complete_local_signing($post_id, array()); + $this->assertFalse($completed['success']); + $this->assertStringContainsString('remote author', strtolower($completed['message'])); + } + + /** + * A valid prepare token is consumed and cannot be replayed. + */ + public function test_local_prepare_token_is_one_time() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $prepared = $this->signing_service->prepare_local_signing($post_id, 'https://example.org/key/one-time'); + $keypair = sodium_crypto_sign_keypair(); + $submission = $this->local_submission($prepared['data'], 'https://example.org/key/one-time', $keypair); + $transient_key = 'content_signing_prepare_' . hash('sha256', $prepared['data']['prepareToken']); + $consume_lock = 'content_signing_consumed_' . hash('sha256', $prepared['data']['prepareToken']); + $prepared_state = get_transient($transient_key); + + $this->assertTrue($this->signing_service->complete_local_signing($post_id, $submission)['success']); + $this->assertNotFalse(get_option($consume_lock, false)); + + // Model a transient backend that acknowledged deletion without + // removing the value. The durable consume guard still rejects replay. + set_transient($transient_key, $prepared_state, 5 * MINUTE_IN_SECONDS); + $replay = $this->signing_service->complete_local_signing($post_id, $submission); + $this->assertFalse($replay['success']); + $this->assertStringContainsString('consumed', strtolower($replay['message'])); + delete_transient($transient_key); + delete_option($consume_lock); + } + + public function test_local_prepare_is_reusable_after_persistence_failure() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = 'https://example.org/key/retry'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + $keypair = sodium_crypto_sign_keypair(); + $submission = $this->local_submission($prepared['data'], $keyid, $keypair); + + add_filter('content_signing_local_signature_persistence', '__return_false'); + $failed = $this->signing_service->complete_local_signing($post_id, $submission); + remove_filter('content_signing_local_signature_persistence', '__return_false'); + $this->assertFalse($failed['success']); + $this->assertStringContainsString('retry', strtolower($failed['message'])); + + $retried = $this->signing_service->complete_local_signing($post_id, $submission); + $this->assertTrue($retried['success']); + } + + /** + * Expired or deleted prepare state cannot be completed. + */ + public function test_local_prepare_token_expiry_is_rejected() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $prepared = $this->signing_service->prepare_local_signing($post_id, 'https://example.org/key/expired'); + delete_transient('content_signing_prepare_' . hash('sha256', $prepared['data']['prepareToken'])); + $rejected = $this->signing_service->complete_local_signing($post_id, array( + 'prepareToken' => $prepared['data']['prepareToken'], + 'keyid' => 'https://example.org/key/expired', + 'publicKey' => 'invalid', + 'signature' => 'invalid', + 'signedAt' => $prepared['data']['signedAt'], + )); + $this->assertFalse($rejected['success']); + $this->assertStringContainsString('expired', strtolower($rejected['message'])); + } + + /** + * A key identifier remains bound to its first public key. + */ + public function test_local_keyid_rejects_public_key_rotation() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = 'https://example.org/key/immutable'; + $first = $this->signing_service->prepare_local_signing($post_id, $keyid); + $first_pair = sodium_crypto_sign_keypair(); + $this->assertTrue($this->signing_service->complete_local_signing($post_id, $this->local_submission($first['data'], $keyid, $first_pair))['success']); + + $second = $this->signing_service->prepare_local_signing($post_id, $keyid); + $second_pair = sodium_crypto_sign_keypair(); + $rejected = $this->signing_service->complete_local_signing($post_id, $this->local_submission($second['data'], $keyid, $second_pair)); + $this->assertFalse($rejected['success']); + $this->assertStringContainsString('different public key', strtolower($rejected['message'])); + } + + /** + * Competing first-use requests serialize on the persistent key binding. + */ + public function test_local_keyid_binding_rejects_competing_first_use_key() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = 'https://example.org/key/competing-first-use'; + + // Prepare both requests before either signature is stored. Both see + // an unused key ID, then add_option elects the first completion. + $first = $this->signing_service->prepare_local_signing($post_id, $keyid); + $second = $this->signing_service->prepare_local_signing($post_id, $keyid); + $first_pair = sodium_crypto_sign_keypair(); + $second_pair = sodium_crypto_sign_keypair(); + + $this->assertTrue($this->signing_service->complete_local_signing( + $post_id, + $this->local_submission($first['data'], $keyid, $first_pair) + )['success']); + + $rejected = $this->signing_service->complete_local_signing( + $post_id, + $this->local_submission($second['data'], $keyid, $second_pair) + ); + $this->assertFalse($rejected['success']); + $this->assertStringContainsString('different public key', strtolower($rejected['message'])); + } + + /** + * A signature that fails verification cannot reserve a new key ID. + */ + public function test_invalid_signature_does_not_bind_keyid() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $keyid = 'https://example.org/key/verify-before-binding'; + $prepared = $this->signing_service->prepare_local_signing($post_id, $keyid); + $unproven_pair = sodium_crypto_sign_keypair(); + $valid_pair = sodium_crypto_sign_keypair(); + + $invalid = $this->local_submission($prepared['data'], $keyid, $unproven_pair); + $invalid['signature'] = $this->base64url(sodium_crypto_sign_detached( + $prepared['data']['payload'], + sodium_crypto_sign_secretkey($valid_pair) + )); + $rejected = $this->signing_service->complete_local_signing($post_id, $invalid); + $this->assertFalse($rejected['success']); + $this->assertStringContainsString('did not verify', strtolower($rejected['message'])); + + $accepted = $this->signing_service->complete_local_signing( + $post_id, + $this->local_submission($prepared['data'], $keyid, $valid_pair) + ); + $this->assertTrue($accepted['success']); + } + + /** + * Prepare requests reclaim old consume locks left by crashed PHP. + */ + public function test_prepare_reclaims_bounded_orphaned_consume_lock() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id)); + wp_set_current_user($user_id); + $token = 'orphaned-consume-lock-for-prepare'; + $lock_name = 'content_signing_consumed_' . hash('sha256', $token); + update_option($lock_name, gmdate('c', time() - (11 * MINUTE_IN_SECONDS)), false); + + $prepared = $this->signing_service->prepare_local_signing($post_id, 'https://example.org/key/reclaim'); + + $this->assertTrue($prepared['success']); + $this->assertFalse(get_option($lock_name, false)); + } + + /** + * Re-queuing after an edit refreshes the durable pending hashes. + */ + public function test_queue_local_signature_refreshes_existing_row() { + $user_id = $this->create_test_user(); + $this->create_test_author(array('wp_user_id' => $user_id, 'server_id' => 0, 'author_api_key' => '')); + $post_id = $this->create_test_post(array('post_author' => $user_id, 'post_content' => 'First version.')); + $first_id = $this->signing_service->queue_local_signature($post_id); + $first = $this->db->get_signature($first_id); + wp_update_post(array('ID' => $post_id, 'post_content' => 'Second version.')); + $second_id = $this->signing_service->queue_local_signature($post_id); + $second = $this->db->get_signature($second_id); + $this->assertSame($first_id, $second_id); + $this->assertNotSame($first->content_hash, $second->content_hash); } /** @@ -307,11 +739,40 @@ public function test_sign_post_missing_author_profile() { */ private function invoke_prepare_content_data($post) { $method = new ReflectionMethod($this->signing_service, 'prepare_content_data'); - $method->setAccessible(true); - return $method->invoke($this->signing_service, $post); } + /** + * Encode binary test data as unpadded base64url. + * + * @param string $bytes Binary data. + * @return string Encoded value. + */ + private function base64url($bytes) { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } + + private function local_submission($prepared, $keyid, $keypair) { + $public_key = sodium_crypto_sign_publickey($keypair); + $signature = sodium_crypto_sign_detached($prepared['payload'], sodium_crypto_sign_secretkey($keypair)); + return array( + 'prepareToken' => $prepared['prepareToken'], + 'keyid' => $keyid, + 'publicKey' => $this->base64url($public_key), + 'signature' => $this->base64url($signature), + 'contentHash' => $prepared['contentHash'], + 'claimsHash' => $prepared['claimsHash'], + 'domain' => $prepared['domain'], + 'signedAt' => $prepared['signedAt'], + 'payload' => $prepared['payload'], + 'profile' => $prepared['profile'], + 'algorithm' => $prepared['algorithm'], + 'scope' => $prepared['scope'], + 'location' => $prepared['location'], + 'sourceURL' => $prepared['sourceURL'], + ); + } + /** * Get the expected serialized origin for the test site. * @@ -331,51 +792,12 @@ private function expected_site_origin() { } /** - * Test process_endorsements method. + * Legacy endorsement authority is absent from the publication service. */ - public function test_process_endorsements() { - // Create a server - $server_id = $this->create_test_server(); - - // Create a regular author - $user_id = $this->create_test_user(); - $author_id = $this->create_test_author(array( - 'wp_user_id' => $user_id, - 'server_id' => $server_id, - )); - - // Create endorser profiles - $endorser1_id = $this->create_test_author(array( - 'server_id' => $server_id, - 'is_site_endorser' => 1, - )); - - $endorser2_id = $this->create_test_author(array( - 'server_id' => $server_id, - 'is_site_endorser' => 1, - )); - - // Enable endorsements and select endorsers - update_option('content_signing_enable_endorsements', true); - update_option('content_signing_endorser_profiles', array($endorser1_id, $endorser2_id)); - - // Create a test post - $post_id = $this->create_test_post(array( - 'post_author' => $user_id, - )); - - // Sign the post - $result = $this->signing_service->sign_post($post_id); - - // Verify result - $this->assertTrue($result['success']); - - // Verify signatures were created (1 primary + 2 endorsements) - $signatures = $this->db->get_signatures_by_post_id($post_id); - $this->assertCount(3, $signatures); - - // Reset options for other tests - update_option('content_signing_enable_endorsements', false); + public function test_remote_endorsement_authority_is_disabled() { + $this->assertFalse(method_exists($this->signing_service, 'process_endorsements')); + $result = $this->signing_service->sign_post(456); + $this->assertFalse($result['success']); } /** @@ -452,8 +874,6 @@ public function test_normalize_content() { // Use reflection to access private method $reflection = new ReflectionClass($this->signing_service); $method = $reflection->getMethod('normalize_content'); - $method->setAccessible(true); - // Call the method $normalized = $method->invoke($this->signing_service, $post->post_content); @@ -470,8 +890,6 @@ public function test_calculate_content_hash() { // Use reflection to access private method $reflection = new ReflectionClass($this->signing_service); $method = $reflection->getMethod('calculate_content_hash'); - $method->setAccessible(true); - // Call the method $hash = $method->invoke($this->signing_service, $content); From cf050a9679610cf58103e641aab14a739c7d4503 Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 14:41:12 -0500 Subject: [PATCH 2/3] fix(wordpress): load signer and emit v1 base64 --- README.md | 2 +- .../admin/js/content-signing-post-meta-box.js | 8 ++-- .../includes/class-content-signing-hooks.php | 1 + .../class-content-signing-signing-service.php | 22 +++++++---- .../test-content-signing-browser-assets.php | 2 +- .../test-content-signing-integration.php | 8 +++- .../test-content-signing-signing-service.php | 39 ++++++++++++------- 7 files changed, 52 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 042be8a..a6789ef 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ this reference repository. The current checkout contains existing WordPress Coding Standards violations, so `--lint` reports a nonzero result after PHPUnit completes. Keeping that check explicit makes the default test command a reliable pass/fail signal for the -current PHPUnit suite, which contains 80 tests in this checkout. +current PHPUnit suite, which contains 81 tests in this checkout. ### Manual test setup diff --git a/wordpress/admin/js/content-signing-post-meta-box.js b/wordpress/admin/js/content-signing-post-meta-box.js index 4a77136..bfab30c 100644 --- a/wordpress/admin/js/content-signing-post-meta-box.js +++ b/wordpress/admin/js/content-signing-post-meta-box.js @@ -12,10 +12,10 @@ const databaseName = 'htmltrust-local-signing'; const storeName = 'keys'; - function toBase64Url(bytes) { + function toCanonicalBase64(bytes) { let binary = ''; bytes.forEach(function(byte) { binary += String.fromCharCode(byte); }); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + return btoa(binary).replace(/=+$/g, ''); } function openKeyStore() { @@ -67,7 +67,7 @@ function exportPublicKey(key) { return crypto.subtle.exportKey('raw', key).then(function(buffer) { - return toBase64Url(new Uint8Array(buffer)); + return toCanonicalBase64(new Uint8Array(buffer)); }); } @@ -102,7 +102,7 @@ return exportPublicKey(stored.keyPair.publicKey).then(function(publicKey) { return ajax({ action: 'content_signing_complete_local_signing', nonce: config.nonce, post_id: postId, - prepareToken: prepared.prepareToken, keyid: stored.keyId, publicKey: publicKey, signature: toBase64Url(new Uint8Array(signature)), + prepareToken: prepared.prepareToken, keyid: stored.keyId, publicKey: publicKey, signature: toCanonicalBase64(new Uint8Array(signature)), contentHash: prepared.contentHash, claimsHash: prepared.claimsHash, domain: prepared.domain, signedAt: prepared.signedAt, payload: prepared.payload, profile: prepared.profile, algorithm: prepared.algorithm, scope: prepared.scope, location: prepared.location, diff --git a/wordpress/includes/class-content-signing-hooks.php b/wordpress/includes/class-content-signing-hooks.php index 9e52541..f5e187f 100644 --- a/wordpress/includes/class-content-signing-hooks.php +++ b/wordpress/includes/class-content-signing-hooks.php @@ -107,6 +107,7 @@ private function register_admin_hooks() { // Admin menu and settings add_action('admin_menu', array($this->admin, 'add_admin_menu')); add_action('admin_init', array($this->admin, 'register_settings')); + add_action('admin_enqueue_scripts', array($this->admin, 'enqueue_scripts')); // Meta boxes add_action('add_meta_boxes', array($this->admin, 'add_meta_boxes')); diff --git a/wordpress/includes/class-content-signing-signing-service.php b/wordpress/includes/class-content-signing-signing-service.php index 2b6d4a5..0c9fde0 100644 --- a/wordpress/includes/class-content-signing-signing-service.php +++ b/wordpress/includes/class-content-signing-signing-service.php @@ -320,8 +320,8 @@ public function complete_local_signing($post_id, $submitted) { } } - $public_key_bytes = $this->decode_base64url($public_key); - $signature_bytes = $this->decode_base64url($signature); + $public_key_bytes = $this->decode_canonical_base64($public_key); + $signature_bytes = $this->decode_canonical_base64($signature); if (false === $public_key_bytes || false === $signature_bytes || strlen($public_key_bytes) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES || strlen($signature_bytes) !== SODIUM_CRYPTO_SIGN_BYTES) { return array('success' => false, 'message' => 'The public key or signature is malformed.'); } @@ -647,22 +647,28 @@ private function is_rfc3339_utc($value) { } /** - * Decode unpadded base64url input strictly. + * Decode canonical unpadded standard Base64 strictly. * - * @param string $value Base64url value. + * @param string $value Base64 value. * @return string|false Decoded bytes. */ - private function decode_base64url($value) { - if (!preg_match('/^[A-Za-z0-9_-]+$/', $value)) { + private function decode_canonical_base64($value) { + if ($value === '' || !preg_match('/^[A-Za-z0-9+\/]+$/', $value) || strlen($value) % 4 === 1) { return false; } + $encoded = $value; $padding = strlen($value) % 4; if ($padding) { $value .= str_repeat('=', 4 - $padding); } - return base64_decode(strtr($value, '-_', '+/'), true); + $decoded = base64_decode($value, true); + if ($decoded === false || rtrim(base64_encode($decoded), '=') !== $encoded) { + return false; + } + + return $decoded; } /** @@ -1198,7 +1204,7 @@ private function verify_local_signature($post, $signature) { // attributes. The persisted payload is diagnostic metadata only. $payload = $expected['payload']; $public_key = $this->decode_ed25519_spki_base64((string) $signature->public_key); - $signature_bytes = $this->decode_base64url((string) $signature->signature); + $signature_bytes = $this->decode_canonical_base64((string) $signature->signature); $valid = $public_key !== false && $signature_bytes !== false && strlen($public_key) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES && strlen($signature_bytes) === SODIUM_CRYPTO_SIGN_BYTES && function_exists('sodium_crypto_sign_verify_detached') && sodium_crypto_sign_verify_detached($signature_bytes, $payload, $public_key); diff --git a/wordpress/tests/test-content-signing-browser-assets.php b/wordpress/tests/test-content-signing-browser-assets.php index ba8ac6b..1d565a6 100644 --- a/wordpress/tests/test-content-signing-browser-assets.php +++ b/wordpress/tests/test-content-signing-browser-assets.php @@ -23,6 +23,6 @@ public function test_private_key_is_local_only() { $this->assertStringNotContainsString("importKey('pkcs8'", $script); $this->assertDoesNotMatchRegularExpression('/privateKey\s*:\s*stored/', $script); $this->assertStringContainsString('publicKey: publicKey', $script); - $this->assertStringContainsString('signature: toBase64Url', $script); + $this->assertStringContainsString('signature: toCanonicalBase64', $script); } } diff --git a/wordpress/tests/test-content-signing-integration.php b/wordpress/tests/test-content-signing-integration.php index 79f063e..c406ee1 100644 --- a/wordpress/tests/test-content-signing-integration.php +++ b/wordpress/tests/test-content-signing-integration.php @@ -232,6 +232,10 @@ public function test_endorsement_workflow() { public function test_post_meta_box_integration() { // Verify the add_meta_boxes hook is registered $this->assertTrue(has_action('add_meta_boxes')); + $this->assertNotFalse(has_action( + 'admin_enqueue_scripts', + array($this->plugin->get_admin(), 'enqueue_scripts') + )); // Create a post $post_id = $this->create_test_post(); @@ -388,8 +392,8 @@ public function test_public_rendering_withholds_signature_after_late_mutation() $stored = $this->signing_service->complete_local_signing($post_id, array( 'prepareToken' => $prepared['data']['prepareToken'], 'keyid' => $keyid, - 'publicKey' => rtrim(strtr(base64_encode($public_key), '+/', '-_'), '='), - 'signature' => rtrim(strtr(base64_encode($signature), '+/', '-_'), '='), + 'publicKey' => rtrim(base64_encode($public_key), '='), + 'signature' => rtrim(base64_encode($signature), '='), 'contentHash' => $prepared['data']['contentHash'], 'claimsHash' => $prepared['data']['claimsHash'], 'domain' => $prepared['data']['domain'], diff --git a/wordpress/tests/test-content-signing-signing-service.php b/wordpress/tests/test-content-signing-signing-service.php index 7cff21d..f44741e 100644 --- a/wordpress/tests/test-content-signing-signing-service.php +++ b/wordpress/tests/test-content-signing-signing-service.php @@ -325,8 +325,8 @@ public function test_complete_local_signing_verifies_and_persists() { $result = $this->signing_service->complete_local_signing($post_id, array( 'prepareToken' => $prepared['data']['prepareToken'], 'keyid' => $keyid, - 'publicKey' => $this->base64url($public_key), - 'signature' => $this->base64url($signature), + 'publicKey' => $this->canonical_base64($public_key), + 'signature' => $this->canonical_base64($signature), 'contentHash' => $prepared['data']['contentHash'], 'claimsHash' => $prepared['data']['claimsHash'], 'domain' => $prepared['data']['domain'], @@ -368,8 +368,8 @@ public function test_local_key_resolver_document_and_mutation() { $result = $this->signing_service->complete_local_signing($post_id, array( 'prepareToken' => $prepared['data']['prepareToken'], 'keyid' => $keyid, - 'publicKey' => $this->base64url($public_key), - 'signature' => $this->base64url($signature), + 'publicKey' => $this->canonical_base64($public_key), + 'signature' => $this->canonical_base64($signature), 'contentHash' => $prepared['data']['contentHash'], 'claimsHash' => $prepared['data']['claimsHash'], 'domain' => $prepared['data']['domain'], @@ -428,8 +428,8 @@ public function test_complete_local_signing_rejects_content_mutation() { $result = $this->signing_service->complete_local_signing($post_id, array( 'prepareToken' => $prepared['data']['prepareToken'], 'keyid' => $keyid, - 'publicKey' => $this->base64url($public_key), - 'signature' => $this->base64url($signature), + 'publicKey' => $this->canonical_base64($public_key), + 'signature' => $this->canonical_base64($signature), 'signedAt' => $prepared['data']['signedAt'], 'payload' => $prepared['data']['payload'], 'contentHash' => $prepared['data']['contentHash'], @@ -466,8 +466,8 @@ public function test_complete_local_signing_rejects_v1_binding_mutations() { $submission = array( 'prepareToken' => $prepared['data']['prepareToken'], 'keyid' => $keyid, - 'publicKey' => $this->base64url($public_key), - 'signature' => $this->base64url($signature), + 'publicKey' => $this->canonical_base64($public_key), + 'signature' => $this->canonical_base64($signature), 'contentHash' => $prepared['data']['contentHash'], 'claimsHash' => $prepared['data']['claimsHash'], 'domain' => $prepared['data']['domain'], @@ -682,7 +682,7 @@ public function test_invalid_signature_does_not_bind_keyid() { $valid_pair = sodium_crypto_sign_keypair(); $invalid = $this->local_submission($prepared['data'], $keyid, $unproven_pair); - $invalid['signature'] = $this->base64url(sodium_crypto_sign_detached( + $invalid['signature'] = $this->canonical_base64(sodium_crypto_sign_detached( $prepared['data']['payload'], sodium_crypto_sign_secretkey($valid_pair) )); @@ -697,6 +697,17 @@ public function test_invalid_signature_does_not_bind_keyid() { $this->assertTrue($accepted['success']); } + /** + * Local signing accepts only the frozen v1 Base64 alphabet and padding. + */ + public function test_local_signature_encoding_rejects_base64url_and_padding() { + $method = new ReflectionMethod($this->signing_service, 'decode_canonical_base64'); + + $this->assertSame("\xfb\xff", $method->invoke($this->signing_service, '+/8')); + $this->assertFalse($method->invoke($this->signing_service, '-_8')); + $this->assertFalse($method->invoke($this->signing_service, '+/8=')); + } + /** * Prepare requests reclaim old consume locks left by crashed PHP. */ @@ -743,13 +754,13 @@ private function invoke_prepare_content_data($post) { } /** - * Encode binary test data as unpadded base64url. + * Encode binary test data as unpadded standard Base64. * * @param string $bytes Binary data. * @return string Encoded value. */ - private function base64url($bytes) { - return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + private function canonical_base64($bytes) { + return rtrim(base64_encode($bytes), '='); } private function local_submission($prepared, $keyid, $keypair) { @@ -758,8 +769,8 @@ private function local_submission($prepared, $keyid, $keypair) { return array( 'prepareToken' => $prepared['prepareToken'], 'keyid' => $keyid, - 'publicKey' => $this->base64url($public_key), - 'signature' => $this->base64url($signature), + 'publicKey' => $this->canonical_base64($public_key), + 'signature' => $this->canonical_base64($signature), 'contentHash' => $prepared['contentHash'], 'claimsHash' => $prepared['claimsHash'], 'domain' => $prepared['domain'], From 1b94416250b98123c125e60da92d6a6f2e16a9ce Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Fri, 28 Aug 2026 15:06:01 -0500 Subject: [PATCH 3/3] ci(wordpress): use supported PHP version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a29dd7..f1aa953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2 with: - php-version: "8.3" + php-version: "8.5" extensions: intl, mbstring, mysqli tools: composer