From 5e4213445814b568ba844f444165c53aa4a6ef4a Mon Sep 17 00:00:00 2001 From: Jason Grey Date: Thu, 27 Aug 2026 15:36:44 -0500 Subject: [PATCH 1/3] feat: harden WordPress signing lifecycle --- ...ss-content-signing-admin-post-meta-box.php | 7 +- .../admin/js/content-signing-post-meta-box.js | 24 +- wordpress/content-signing.php | 12 + wordpress/docs/local-signing-audit.md | 12 +- .../class-content-signing-api-client.php | 72 ++++ .../includes/class-content-signing-hooks.php | 58 +-- .../class-content-signing-scheduler.php | 4 +- .../class-content-signing-signing-service.php | 375 ++++++++++++++++-- .../includes/db/class-content-signing-db.php | 158 ++++++-- .../public/class-content-signing-display.php | 267 ++++++++++--- .../public/class-content-signing-public.php | 13 + wordpress/public/js/content-signing-public.js | 90 +++-- wordpress/tests/bootstrap.php | 7 + ...s-content-signing-api-client-test-case.php | 48 ++- .../class-content-signing-db-test-case.php | 6 +- .../tests/class-content-signing-test-case.php | 11 +- .../tests/test-content-signing-api-client.php | 25 +- wordpress/tests/test-content-signing-db.php | 41 +- .../test-content-signing-integration.php | 82 +++- .../tests/test-content-signing-scheduler.php | 6 +- .../test-content-signing-signing-service.php | 103 ++++- 21 files changed, 1167 insertions(+), 254 deletions(-) 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 b874fbf..bc8e1d9 100644 --- a/wordpress/admin/class-content-signing-admin-post-meta-box.php +++ b/wordpress/admin/class-content-signing-admin-post-meta-box.php @@ -262,13 +262,18 @@ public function enqueue_scripts() { false ); + // The sign and verify AJAX handlers verify a post-scoped nonce, so the + // one handed to the script has to be minted for the post being edited. + $post = get_post(); + $post_id = $post ? $post->ID : 0; + // Localize script wp_localize_script( 'content-signing-post-meta-box', 'content_signing_post_meta_box', array( 'ajax_url' => admin_url('admin-ajax.php'), - 'nonce' => wp_create_nonce('content_signing_nonce'), + 'nonce' => 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'), 'verifying_text' => __('Verifying...', 'content-signing'), diff --git a/wordpress/admin/js/content-signing-post-meta-box.js b/wordpress/admin/js/content-signing-post-meta-box.js index 353e209..5112b71 100644 --- a/wordpress/admin/js/content-signing-post-meta-box.js +++ b/wordpress/admin/js/content-signing-post-meta-box.js @@ -114,16 +114,24 @@ 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 : + const resultText = response.data.valid ? + content_signing_post_meta_box.valid_text : content_signing_post_meta_box.invalid_text; - - const $result = $('
' + resultText + '
'); + + // 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 + // 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 = $('
' + content_signing_post_meta_box.error_text + ' ' + errorMessage + '
'); + const $result = $('
') + .addClass('verify-result invalid') + .text(content_signing_post_meta_box.error_text + ' ' + errorMessage); $listItem.append($result); } }, @@ -133,7 +141,9 @@ $button.text(content_signing_post_meta_box.verify_text); // Show error message - const $result = $('
' + content_signing_post_meta_box.error_text + ' ' + content_signing_post_meta_box.ajax_error + '
'); + const $result = $('
') + .addClass('verify-result invalid') + .text(content_signing_post_meta_box.error_text + ' ' + content_signing_post_meta_box.ajax_error); $listItem.append($result); } }); diff --git a/wordpress/content-signing.php b/wordpress/content-signing.php index 5d8c309..9f17dbc 100644 --- a/wordpress/content-signing.php +++ b/wordpress/content-signing.php @@ -23,6 +23,18 @@ define('CONTENT_SIGNING_PLUGIN_URL', plugin_dir_url(__FILE__)); define('CONTENT_SIGNING_PLUGIN_BASENAME', plugin_basename(__FILE__)); +/** + * Composer autoloader. + * + * Supplies HTMLTrust\Canonicalization\Canonicalize, the shared canonicalization + * library the signing service uses to derive the bytes it hashes. Without it + * the plugin cannot produce a content hash any verifier will reproduce, so the + * signing service reports a clear deployment error rather than fatalling. + */ +if (file_exists(CONTENT_SIGNING_PLUGIN_DIR . 'vendor/autoload.php')) { + require_once CONTENT_SIGNING_PLUGIN_DIR . 'vendor/autoload.php'; +} + /** * The code that runs during plugin activation. */ diff --git a/wordpress/docs/local-signing-audit.md b/wordpress/docs/local-signing-audit.md index 6d6cfa8..1d9cc34 100644 --- a/wordpress/docs/local-signing-audit.md +++ b/wordpress/docs/local-signing-audit.md @@ -30,12 +30,14 @@ Today's flow, per `includes/class-content-signing-signing-service.php` -> 1. WordPress fires `publish_post` (or `transition_post_status`). 2. `ContentSigning_Hooks::on_publish_post` -> `Signing_Service::process_post` -> `Signing_Service::sign_post`. -3. `prepare_content_data()` strips HTML, runs the canonicalization PHP - binding (`HTMLTrust\Canonicalization\Canonicalize::normalize`), computes - `sha256:`, attaches the post author's claims. +3. `prepare_content_data()` canonicalizes the signed content, including + `href`, `src`, `alt`, and `aria-label` records, computes + `sha256:`, serializes `domain` as the Web + origin, and attaches direct meta claims such as `author`, `signed-at`, + and `claim:*`. 4. `ContentSigning_API_Client::sign_content()` POSTs `{contentHash, domain, - claims}` to `/api/content/sign` with the author's API key - in `X-AUTHOR-API-KEY`. + claims, signedAt, sourceURL}` to `/api/content/sign` with + the author's API key in `X-AUTHOR-API-KEY`. 5. The trust server holds the author's signing key, signs server-side, and returns `{signature, ...}`. 6. We persist the signature row in `wp_content_signing_signatures` and diff --git a/wordpress/includes/class-content-signing-api-client.php b/wordpress/includes/class-content-signing-api-client.php index 8bd5e65..426d9fa 100644 --- a/wordpress/includes/class-content-signing-api-client.php +++ b/wordpress/includes/class-content-signing-api-client.php @@ -131,6 +131,46 @@ private function request($endpoint, $method = 'GET', $data = array(), $api_key = return $result; } + /** + * Identifier shape: opaque tokens (object ids, slugs, key ids). + */ + const IDENTIFIER_PATTERN = '/^[A-Za-z0-9._~-]{1,128}$/'; + + /** + * Hash shape: an algorithm identifier, a colon, then unpadded Base64. + */ + const HASH_PATTERN = '/^[A-Za-z0-9]{1,16}:[A-Za-z0-9+\/_-]{1,128}={0,2}$/'; + + /** + * Validate and encode a value for use as a single URL path segment. + * + * Identifiers reaching these methods come from the database and from API + * responses, so they are not guaranteed to be path-safe. Interpolated raw, + * a value containing "/", "..", "?" or "#" retargets the request at a + * different endpoint on the signing server; rawurlencode() confines it to + * one segment, and the pattern check rejects shapes that were never valid + * identifiers in the first place. + * + * @since 1.0.0 + * @access private + * @param string $value The path segment value. + * @param string $pattern The pattern the value must match. + * @param string $label Human-readable name, used in the error. + * @return string|WP_Error The encoded segment, or WP_Error if unusable. + */ + private function encode_path_segment($value, $pattern = self::IDENTIFIER_PATTERN, $label = 'identifier') { + if (!is_scalar($value)) { + return new WP_Error('invalid_identifier', sprintf('Invalid %s.', $label)); + } + + $value = trim((string) $value); + if ($value === '' || !preg_match($pattern, $value)) { + return new WP_Error('invalid_identifier', sprintf('Invalid %s.', $label)); + } + + return rawurlencode($value); + } + /** * Create a new author. * @@ -150,6 +190,11 @@ public function create_author($author_data) { * @return array|WP_Error The API response or WP_Error on failure. */ public function get_author($author_id) { + $author_id = $this->encode_path_segment($author_id, self::IDENTIFIER_PATTERN, 'author ID'); + if (is_wp_error($author_id)) { + return $author_id; + } + return $this->request("authors/{$author_id}", 'GET'); } @@ -163,6 +208,11 @@ public function get_author($author_id) { * @return array|WP_Error The API response or WP_Error on failure. */ public function update_author($author_id, $author_data, $author_api_key) { + $author_id = $this->encode_path_segment($author_id, self::IDENTIFIER_PATTERN, 'author ID'); + if (is_wp_error($author_id)) { + return $author_id; + } + return $this->request("authors/{$author_id}", 'PUT', $author_data, $author_api_key, 'author'); } @@ -174,6 +224,11 @@ public function update_author($author_id, $author_data, $author_api_key) { * @return array|WP_Error The API response or WP_Error on failure. */ public function get_author_public_key($author_id) { + $author_id = $this->encode_path_segment($author_id, self::IDENTIFIER_PATTERN, 'author ID'); + if (is_wp_error($author_id)) { + return $author_id; + } + return $this->request("authors/{$author_id}/public-key", 'GET'); } @@ -219,6 +274,11 @@ public function get_claim_types($params = array()) { * @return array|WP_Error The API response or WP_Error on failure. */ public function get_claim_type($claim_id) { + $claim_id = $this->encode_path_segment($claim_id, self::IDENTIFIER_PATTERN, 'claim ID'); + if (is_wp_error($claim_id)) { + return $claim_id; + } + return $this->request("claims/{$claim_id}", 'GET'); } @@ -241,6 +301,11 @@ public function search_public_keys($params = array()) { * @return array|WP_Error The API response or WP_Error on failure. */ public function get_key_reputation($key_id) { + $key_id = $this->encode_path_segment($key_id, self::IDENTIFIER_PATTERN, 'key ID'); + if (is_wp_error($key_id)) { + return $key_id; + } + return $this->request("directory/keys/{$key_id}/reputation", 'GET'); } @@ -264,6 +329,13 @@ public function search_signed_content($params = array()) { * @return array|WP_Error The API response or WP_Error on failure. */ public function find_content_occurrences($content_hash, $params = array()) { + // A content hash is "algorithm:base64", and standard-alphabet Base64 + // contains "/" -- rawurlencode() is what keeps it inside one segment. + $content_hash = $this->encode_path_segment($content_hash, self::HASH_PATTERN, 'content hash'); + if (is_wp_error($content_hash)) { + return $content_hash; + } + 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 19b40c0..4927978 100644 --- a/wordpress/includes/class-content-signing-hooks.php +++ b/wordpress/includes/class-content-signing-hooks.php @@ -214,22 +214,26 @@ public function on_transition_post_status($new_status, $old_status, $post) { * @return void */ public function ajax_sign_post() { - // Check nonce - check_ajax_referer('content_signing_nonce', 'nonce'); - - // Check permissions - if (!current_user_can('edit_posts')) { - wp_send_json_error(array('message' => 'Permission denied.')); - return; - } - - // Get post ID + // Get post ID first: the nonce and the capability are both scoped to + // this specific post. $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 nonce. Post-scoped, so a nonce minted on one post's edit + // screen cannot be replayed against another post. + check_ajax_referer('content_signing_post_' . $post_id, 'nonce'); + + // Check permissions against this post, not the generic edit_posts + // capability: edit_posts is true for any Contributor, which would let + // them sign posts they do not own. + if (!current_user_can('edit_post', $post_id)) { + wp_send_json_error(array('message' => 'Permission denied.')); + return; + } + // Sign the post $result = $this->signing_service->sign_post($post_id); @@ -247,24 +251,24 @@ public function ajax_sign_post() { * @return void */ public function ajax_verify_signature() { - // Check nonce - check_ajax_referer('content_signing_nonce', 'nonce'); - - // Check permissions - if (!current_user_can('edit_posts')) { - wp_send_json_error(array('message' => 'Permission denied.')); - return; - } - // Get post ID and signature ID $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; $signature_id = isset($_POST['signature_id']) ? intval($_POST['signature_id']) : 0; - + if (!$post_id || !$signature_id) { wp_send_json_error(array('message' => 'Invalid post ID or signature ID.')); return; } - + + // Check nonce. Post-scoped, as in ajax_sign_post(). + check_ajax_referer('content_signing_post_' . $post_id, 'nonce'); + + // Check permissions against this post rather than edit_posts. + if (!current_user_can('edit_post', $post_id)) { + wp_send_json_error(array('message' => 'Permission denied.')); + return; + } + // Verify the signature $result = $this->signing_service->verify_post_signature($post_id, $signature_id); @@ -284,13 +288,15 @@ public function ajax_verify_signature() { public function ajax_get_claim_types() { // Check nonce check_ajax_referer('content_signing_nonce', 'nonce'); - - // Check permissions - if (!current_user_can('edit_posts')) { + + // Check permissions. This handler reads a server profile and talks to + // that server with its stored API key, so it is gated on the same + // capability as the plugin's settings screens. + if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => 'Permission denied.')); return; } - + // Get server ID $server_id = isset($_POST['server_id']) ? intval($_POST['server_id']) : 0; if (!$server_id) { diff --git a/wordpress/includes/class-content-signing-scheduler.php b/wordpress/includes/class-content-signing-scheduler.php index 35875f0..d6c08be 100644 --- a/wordpress/includes/class-content-signing-scheduler.php +++ b/wordpress/includes/class-content-signing-scheduler.php @@ -68,7 +68,7 @@ public function schedule_signing($post_id, $timestamp) { * @return bool True if canceled successfully, false otherwise. */ public function cancel_scheduled_signing($post_id) { - return wp_clear_scheduled_hook('content_signing_scheduled_signing', array($post_id)); + return wp_clear_scheduled_hook('content_signing_scheduled_signing', array($post_id)) > 0; } /** @@ -125,4 +125,4 @@ public function get_next_scheduled_signing($post_id) { public function has_scheduled_signing($post_id) { return (bool) $this->get_next_scheduled_signing($post_id); } -} \ No newline at end of file +} diff --git a/wordpress/includes/class-content-signing-signing-service.php b/wordpress/includes/class-content-signing-signing-service.php index 44ac2b6..5ac6cd5 100644 --- a/wordpress/includes/class-content-signing-signing-service.php +++ b/wordpress/includes/class-content-signing-signing-service.php @@ -13,6 +13,9 @@ class ContentSigning_Signing_Service { + /** @var array Signing or scheduling work completed in this request. */ + private $processed_actions = array(); + /** * The database handler. * @@ -134,12 +137,20 @@ public function process_post($post_id, $post, $update) { // Sign now if needed if ($should_sign_now) { - $this->sign_post($post_id); + $action_key = 'sign:' . $post_id; + if (empty($this->processed_actions[$action_key])) { + $this->processed_actions[$action_key] = true; + $this->sign_post($post_id); + } } // Schedule signing if needed if ($should_schedule && $schedule_time > time()) { - $this->scheduler->schedule_signing($post_id, $schedule_time); + $action_key = 'schedule:' . $post_id; + if (empty($this->processed_actions[$action_key])) { + $this->processed_actions[$action_key] = true; + $this->scheduler->schedule_signing($post_id, $schedule_time); + } } } @@ -177,8 +188,18 @@ public function sign_post($post_id) { ); } - // Prepare the content for signing - $content_data = $this->prepare_content_data($post); + // 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. + try { + $content_data = $this->prepare_content_data($post); + } catch (Exception $e) { + return array( + 'success' => false, + 'message' => 'Content canonicalization failed: ' . $e->getMessage(), + ); + } // Get the author's API key $author_api_key = $this->db->decrypt($author_profile->author_api_key_encrypted); @@ -209,7 +230,7 @@ public function sign_post($post_id) { } // Sign the content - $result = $api_client->sign_content($content_data, $author_api_key); + $result = $api_client->sign_content($this->prepare_api_content_data($content_data), $author_api_key); // Update the signature record if (is_wp_error($result)) { @@ -229,9 +250,9 @@ public function sign_post($post_id) { } else { $this->db->update_signature($signature_id, array( 'signature' => $result['signature'], - 'claims_json' => wp_json_encode($result['claims']), + 'claims_json' => wp_json_encode($content_data['claims']), 'status' => 'signed', - 'signed_at' => current_time('mysql'), + 'signed_at' => $content_data['signedAtMysql'], 'api_response' => $result, )); @@ -311,7 +332,7 @@ private function process_endorsements($post_id, $content_data) { } // Sign the content with the endorser's key - $result = $api_client->sign_content($content_data, $endorser_api_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)) { @@ -325,9 +346,9 @@ private function process_endorsements($post_id, $content_data) { } else { $this->db->update_signature($signature_id, array( 'signature' => $result['signature'], - 'claims_json' => wp_json_encode($result['claims']), + 'claims_json' => wp_json_encode($content_data['claims']), 'status' => 'signed', - 'signed_at' => current_time('mysql'), + 'signed_at' => $content_data['signedAtMysql'], 'api_response' => $result, )); } @@ -342,17 +363,25 @@ private function process_endorsements($post_id, $content_data) { * @return array The content data. */ private function prepare_content_data($post) { - // Get the post content - $content = $post->post_content; + $base_url = get_permalink($post); + $signed_at = gmdate('Y-m-d\TH:i:s\Z'); + $author_name = $this->get_post_author_name($post); - // Normalize the content (strip whitespace, etc.) - $normalized_content = $this->normalize_content($content); + // Hash the rendered content, not the raw post_content. The + // element emitted by ContentSigning_Display wraps the + // output of the `the_content` filter chain (wpautop, shortcodes, ...), + // so hashing the raw editor markup would produce a content hash that + // no verifier can reproduce from the published page. + $rendered_content = $this->get_rendered_content($post); + + // Normalize the content using the signed content extraction rules. + $normalized_content = $this->normalize_content($rendered_content, $base_url); // Calculate the content hash $content_hash = $this->calculate_content_hash($normalized_content); - // Determine the domain - $domain = parse_url(get_site_url(), PHP_URL_HOST); + // Determine the serialized Web origin for the legacy-named domain field. + $domain = $this->serialize_origin(get_site_url()); // Get default claims from post author's profile $author_profile = $this->db->get_author_by_wp_user_id($post->post_author); @@ -367,38 +396,136 @@ private function prepare_content_data($post) { $post_claims = array(); } - // Merge claims, with post-specific claims taking precedence - $claims = array_merge($default_claims, $post_claims); + // Merge claims, with post-specific claims taking precedence. + $custom_claims = array_merge($default_claims, $post_claims); // Add standard claims if not already set - if (!isset($claims['ContentType'])) { - $claims['ContentType'] = 'Article'; + if (!isset($custom_claims['ContentType'])) { + $custom_claims['ContentType'] = 'Article'; } - if (!isset($claims['AuthorType'])) { - $claims['AuthorType'] = $author_profile ? $author_profile->default_key_type : 'HUMAN'; + if (!isset($custom_claims['AuthorType'])) { + $custom_claims['AuthorType'] = $author_profile ? $author_profile->default_key_type : 'HUMAN'; } + $claims = $this->build_claims($author_name, $signed_at, $custom_claims); + return array( 'contentHash' => $content_hash, + 'claimsHash' => $this->calculate_claims_hash($claims), 'domain' => $domain, 'claims' => $claims, + 'signedAt' => $signed_at, + 'signedAtMysql' => gmdate('Y-m-d H:i:s', strtotime($signed_at)), + 'sourceURL' => $base_url, + ); + } + + /** + * Render post content through the same filters the front end applies. + * + * ContentSigning_Display wraps the value it receives from the + * `the_content` filter, so the signer has to hash that same value. Our own + * wrapper callback is detached for the duration of the call: it is + * 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. + * + * @since 1.0.0 + * @param WP_Post $post The post object. + * @return string The rendered post content. + */ + private function get_rendered_content($post) { + $display = $this->get_display(); + $callback = $display ? array($display, 'display_signature') : null; + + // Read the registered priority back rather than assuming it: detaching + // at the wrong priority is a silent no-op, and the recursion it would + // leave in place is not obvious from the resulting hash. + $priority = $callback ? has_filter('the_content', $callback) : false; + + if (false !== $priority) { + remove_filter('the_content', $callback, $priority); + } + + $content = apply_filters('the_content', $post->post_content); + + if (false !== $priority) { + add_filter('the_content', $callback, $priority); + } + + return $content; + } + + /** + * Get the plugin's display handler, if the plugin is fully booted. + * + * @since 1.0.0 + * @return ContentSigning_Display|null The display handler. + */ + private function get_display() { + if (!class_exists('ContentSigning_Plugin')) { + return null; + } + + $plugin = ContentSigning_Plugin::get_instance(); + if (!$plugin) { + return null; + } + + $public = $plugin->get_public(); + if (!$public || !method_exists($public, 'get_display')) { + return null; + } + + 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. * + * Delegates to the shared htmltrust/canonicalization package. This plugin + * deliberately keeps no canonicalizer of its own: the bytes hashed here + * have to match, byte for byte, what the JavaScript, Go, Python and Rust + * verifiers derive from the published page. + * * @since 1.0.0 - * @param string $content The content to normalize. - * @return string The normalized content. + * @param string $content The content to normalize. + * @param string|null $base_url The signed document URL, used to + * resolve relative href/src values. + * @return string The normalized content. + * @throws RuntimeException If the canonicalization package is missing. + * @throws InvalidArgumentException If a signed attribute cannot be canonicalized. */ - private function normalize_content($content) { - // Strip HTML tags - $content = wp_strip_all_tags($content); + private function normalize_content($content, $base_url = null) { + $this->require_canonicalization_library(); - // Apply canonical text normalization - return Canonicalize::normalize($content); + return Canonicalize::extractCanonicalText($content, false, $base_url === '' ? null : $base_url); } /** @@ -410,7 +537,156 @@ private function normalize_content($content) { */ private function calculate_content_hash($content) { // Use SHA-256 for hashing - return 'sha256:' . hash('sha256', $content); + return $this->hash_canonical_bytes($content); + } + + /** + * Calculate the claims hash for a direct-child claim map. + * + * The claims hash is the second field of the signing payload binding + * (draft §5) and is computed over the canonical claims byte string + * defined in draft §4.6. + * + * @since 1.0.0 + * @param array $claims Claims keyed by direct meta name. + * @return string The claims hash. + * @throws RuntimeException If the canonicalization package is missing. + * @throws InvalidArgumentException If a claim is malformed or duplicated. + */ + private function calculate_claims_hash($claims) { + $this->require_canonicalization_library(); + + return $this->hash_canonical_bytes(Canonicalize::canonicalizeClaims($claims)); + } + + /** + * Hash a canonical byte string into the spec's prefixed hash format. + * + * Base64 with the standard alphabet and padding removed, prefixed with the + * hash algorithm identifier and a colon (draft §6.2). + * + * @since 1.0.0 + * @param string $bytes The canonical bytes to hash. + * @return string The prefixed hash. + */ + private function hash_canonical_bytes($bytes) { + return 'sha256:' . rtrim(base64_encode(hash('sha256', $bytes, true)), '='); + } + + /** + * Assert that the shared canonicalization package is loadable. + * + * The package is a composer dependency (htmltrust/canonicalization); a + * missing autoloader is a deployment error, not a content error, so it is + * reported distinctly rather than surfacing as a class-not-found fatal. + * + * @since 1.0.0 + * @return void + * @throws RuntimeException If the package is not installed. + */ + private function require_canonicalization_library() { + if (!class_exists(Canonicalize::class)) { + throw new RuntimeException( + 'The htmltrust/canonicalization package is not installed. Run "composer install" in the plugin directory.' + ); + } + } + + /** + * Serialize a URL as a Web origin. + * + * @since 1.0.0 + * @param string $url The URL to serialize. + * @return string The serialized origin. + */ + private function serialize_origin($url) { + $parts = wp_parse_url($url); + $scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : 'https'; + $host = isset($parts['host']) ? strtolower($parts['host']) : ''; + + if (function_exists('idn_to_ascii') && $host !== '') { + $ascii_host = idn_to_ascii($host, 0, defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : 0); + if ($ascii_host) { + $host = strtolower($ascii_host); + } + } + + $origin = $scheme . '://' . $host; + if (isset($parts['port']) && !$this->is_default_port($scheme, intval($parts['port']))) { + $origin .= ':' . intval($parts['port']); + } + + return $origin; + } + + /** + * Check whether a port is the default for a scheme. + * + * @since 1.0.0 + * @param string $scheme The URL scheme. + * @param int $port The port. + * @return bool Whether the port is default. + */ + private function is_default_port($scheme, $port) { + return ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443); + } + + /** + * Build direct child meta claims. + * + * @since 1.0.0 + * @param string $author_name The author display name. + * @param string $signed_at RFC3339 UTC signing timestamp. + * @param array $custom_claims Custom claim values. + * @return array Direct meta claims keyed by meta name. + */ + private function build_claims($author_name, $signed_at, $custom_claims) { + $claims = array( + 'author' => $author_name, + 'signed-at' => $signed_at, + ); + + foreach ($custom_claims as $name => $value) { + $claim_name = strpos($name, 'claim:') === 0 ? $name : 'claim:' . $name; + if (is_array($value)) { + $value = implode(', ', $value); + } + $claims[$claim_name] = (string) $value; + } + + return $claims; + } + + /** + * Get the post author's display name. + * + * @since 1.0.0 + * @param WP_Post $post The post object. + * @return string The author display name. + */ + private function get_post_author_name($post) { + $user = get_user_by('ID', $post->post_author); + if ($user && !empty($user->display_name)) { + return $user->display_name; + } + + return (string) $post->post_author; + } + + /** + * Format a stored datetime as RFC3339 UTC. + * + * @since 1.0.0 + * @param string $datetime The stored datetime. + * @return string The RFC3339 UTC datetime. + */ + private function format_signed_at($datetime) { + $timestamp = strtotime($datetime); + if (!$timestamp) { + $timestamp = time(); + } + + return gmdate('Y-m-d\TH:i:s\Z', $timestamp); } /** @@ -438,6 +714,16 @@ public function verify_post_signature($post_id, $signature_id) { ); } + // The caller was authorized against $post_id, so the signature has to + // belong to that post; otherwise the post ID is just decoration and any + // signature row is reachable. + if (intval($signature->post_id) !== intval($post_id)) { + return array( + 'success' => false, + 'message' => 'Signature does not belong to this post.', + ); + } + // Get the server for this signature $server = $this->db->get_server($signature->server_id); if (!$server) { @@ -454,10 +740,39 @@ public function verify_post_signature($post_id, $signature_id) { $this->db ); + // Rebuild the signing payload binding. The verifier needs all four + // bound fields (draft §5); sending only contentHash and domain makes + // the request fail validation before any crypto runs. + $claims = json_decode((string) $signature->claims_json, true); + if (!is_array($claims) || empty($claims)) { + return array( + 'success' => false, + 'message' => 'Stored claims are missing; cannot rebuild the signing payload.', + ); + } + + try { + $claims_hash = $this->calculate_claims_hash($claims); + } catch (Exception $e) { + return array( + 'success' => false, + 'message' => 'Claims canonicalization failed: ' . $e->getMessage(), + ); + } + + // Prefer the signed-at claim: it is the exact string that was bound + // into the signature. The signed_at column is a MySQL datetime and + // only reconstructs the RFC3339 form. + $signed_at = isset($claims['signed-at']) && $claims['signed-at'] !== '' + ? (string) $claims['signed-at'] + : $this->format_signed_at($signature->signed_at ? $signature->signed_at : $signature->created_at); + // Prepare verification data $verification_data = array( 'contentHash' => $signature->content_hash, + 'claimsHash' => $claims_hash, 'domain' => $signature->domain, + 'signedAt' => $signed_at, 'authorId' => $signature->signing_author_id, 'signature' => $signature->signature, ); diff --git a/wordpress/includes/db/class-content-signing-db.php b/wordpress/includes/db/class-content-signing-db.php index 0e792fd..f7f5249 100644 --- a/wordpress/includes/db/class-content-signing-db.php +++ b/wordpress/includes/db/class-content-signing-db.php @@ -57,32 +57,126 @@ public function get_table_name($table) { return isset($this->tables[$table]) ? $this->tables[$table] : ''; } + /** + * Derive the symmetric encryption key. + * + * The key material comes from the HTMLTRUST_ENCRYPTION_KEY constant, which + * the site owner defines in wp-config.php. Generate one with: + * + * php -r 'echo sodium_bin2base64(sodium_crypto_secretbox_keygen(), SODIUM_BASE64_VARIANT_ORIGINAL), "\n";' + * + * and add it to wp-config.php: + * + * define('HTMLTRUST_ENCRYPTION_KEY', ''); + * + * A base64 value decoding to exactly 32 bytes is used as the key directly. + * Anything else is run through BLAKE2b to produce a 32-byte key, so a + * passphrase also works, with correspondingly less entropy. + * + * @since 1.0.0 + * @access private + * @return string|null The 32-byte key, or null if none is configured. + */ + private function get_encryption_key() { + if (!defined('HTMLTRUST_ENCRYPTION_KEY')) { + return null; + } + + $configured = (string) HTMLTRUST_ENCRYPTION_KEY; + if ($configured === '') { + return null; + } + + $decoded = base64_decode($configured, true); + if (false !== $decoded && strlen($decoded) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + return $decoded; + } + + return sodium_crypto_generichash($configured, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + } + + /** + * Check whether authenticated encryption is available and configured. + * + * @since 1.0.0 + * @return bool True when encrypt()/decrypt() can operate. + */ + public function has_encryption_key() { + return function_exists('sodium_crypto_secretbox') && null !== $this->get_encryption_key(); + } + /** * Encrypt sensitive data. * + * Authenticated encryption via XSalsa20-Poly1305 (sodium_crypto_secretbox). + * A fresh random nonce is generated per call and prepended to the + * ciphertext; the pair is base64-encoded for storage in a text column. + * + * Fails closed: returns null when no key is configured, rather than + * degrading to storing recoverable plaintext. + * + * MIGRATION: values written by the previous release were base64-encoded + * plaintext, not ciphertext. They will not decrypt and are not migrated + * automatically, deliberately, because base64 is reversible by anyone with + * database read access and those keys must be treated as disclosed. After + * defining HTMLTRUST_ENCRYPTION_KEY, revoke every server and author API key + * on the signing server and re-enter the replacements in the plugin's + * server and author profile screens. + * * @since 1.0.0 - * @param string $data The data to encrypt. - * @return string The encrypted data. + * @param string $data The data to encrypt. + * @return string|null The encrypted data, or null on failure. */ public function encrypt($data) { - // For simplicity, we're using base64 encoding here - // In a production environment, use a more secure encryption method - // Consider using WordPress's Sodium compatibility layer if available - return base64_encode($data); + if (!function_exists('sodium_crypto_secretbox')) { + return null; + } + + $key = $this->get_encryption_key(); + if (null === $key) { + return null; + } + + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = sodium_crypto_secretbox((string) $data, $nonce, $key); + sodium_memzero($key); + + return base64_encode($nonce . $ciphertext); } /** * Decrypt sensitive data. * + * Splits the stored blob into nonce and ciphertext and verifies the + * Poly1305 tag. Any tampering, truncation, or wrong key yields null. + * * @since 1.0.0 - * @param string $data The data to decrypt. - * @return string The decrypted data. + * @param string $data The data to decrypt. + * @return string|null The decrypted data, or null on failure. */ public function decrypt($data) { - // For simplicity, we're using base64 decoding here - // In a production environment, use a more secure decryption method - // Consider using WordPress's Sodium compatibility layer if available - return base64_decode($data); + if (!function_exists('sodium_crypto_secretbox_open') || !is_string($data) || $data === '') { + return null; + } + + $key = $this->get_encryption_key(); + if (null === $key) { + return null; + } + + $raw = base64_decode($data, true); + if (false === $raw || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) { + sodium_memzero($key); + return null; + } + + $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); + sodium_memzero($key); + + return false === $plaintext ? null : $plaintext; } /** @@ -106,12 +200,17 @@ public function insert_server($data) { $data = wp_parse_args($data, $defaults); - // Encrypt the API key + // Encrypt the API key. Refuse the write outright if encryption is + // unavailable: storing an unencrypted key would be worse than failing. if (!empty($data['api_key'])) { - $data['api_key_encrypted'] = $this->encrypt($data['api_key']); + $encrypted = $this->encrypt($data['api_key']); + if (null === $encrypted) { + return false; + } + $data['api_key_encrypted'] = $encrypted; unset($data['api_key']); } - + // If this is set as default, unset any existing defaults if ($data['is_default_server']) { $this->wpdb->update( @@ -140,10 +239,14 @@ public function update_server($server_id, $data) { // Encrypt the API key if provided if (!empty($data['api_key'])) { - $data['api_key_encrypted'] = $this->encrypt($data['api_key']); + $encrypted = $this->encrypt($data['api_key']); + if (null === $encrypted) { + return false; + } + $data['api_key_encrypted'] = $encrypted; unset($data['api_key']); } - + // If this is set as default, unset any existing defaults if (isset($data['is_default_server']) && $data['is_default_server']) { $this->wpdb->update( @@ -243,18 +346,23 @@ public function insert_author($data) { $data = wp_parse_args($data, $defaults); - // Encrypt the API key + // 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'])) { - $data['author_api_key_encrypted'] = $this->encrypt($data['author_api_key']); + $encrypted = $this->encrypt($data['author_api_key']); + if (null === $encrypted) { + return false; + } + $data['author_api_key_encrypted'] = $encrypted; unset($data['author_api_key']); } - + // Encode default claims as JSON if it's an array if (isset($data['default_claims']) && is_array($data['default_claims'])) { $data['default_claims_json'] = wp_json_encode($data['default_claims']); unset($data['default_claims']); } - + // Insert the author $result = $this->wpdb->insert($this->tables['authors'], $data); @@ -274,10 +382,14 @@ public function update_author($author_profile_id, $data) { // Encrypt the API key if provided if (!empty($data['author_api_key'])) { - $data['author_api_key_encrypted'] = $this->encrypt($data['author_api_key']); + $encrypted = $this->encrypt($data['author_api_key']); + if (null === $encrypted) { + return false; + } + $data['author_api_key_encrypted'] = $encrypted; unset($data['author_api_key']); } - + // Encode default claims as JSON if it's an array if (isset($data['default_claims']) && is_array($data['default_claims'])) { $data['default_claims_json'] = wp_json_encode($data['default_claims']); diff --git a/wordpress/public/class-content-signing-display.php b/wordpress/public/class-content-signing-display.php index f13520a..3fe4141 100644 --- a/wordpress/public/class-content-signing-display.php +++ b/wordpress/public/class-content-signing-display.php @@ -63,13 +63,16 @@ public function display_signature($content) { return $content; } - // Build the signature HTML - $signature_html = $this->get_signature_html($post_id); + $primary_signature = $this->get_primary_signature($signatures); + if (!$primary_signature) { + return $content; + } - // Append the signature HTML to the content - $content .= $signature_html; + if (stripos($content, 'get_signature_html($post_id); + } - return $content; + return $this->get_signed_section_html($primary_signature, $content) . $this->get_signature_html($post_id); } /** @@ -102,16 +105,12 @@ public function get_signature_html($post_id, $options = array()) { } // Get the primary signature (first one with status 'signed') - $primary_signature = null; + $primary_signature = $this->get_primary_signature($signatures); $endorsement_signatures = array(); foreach ($signatures as $signature) { - if ($signature->status === 'signed') { - if (!$primary_signature) { - $primary_signature = $signature; - } else { - $endorsement_signatures[] = $signature; - } + if ($signature->status === 'signed' && $primary_signature && $signature->signature_id !== $primary_signature->signature_id) { + $endorsement_signatures[] = $signature; } } @@ -166,14 +165,28 @@ public function get_signature_html($post_id, $options = array()) { $html .= '
'; // .content-signing-details - // Add the signature HTML attributes for verification - $html .= $this->get_signature_attributes_html($primary_signature); - $html .= '
'; // .content-signing-container return $html; } + /** + * Get the primary signed signature. + * + * @since 1.0.0 + * @param array $signatures The signatures. + * @return object|null The primary signature. + */ + private function get_primary_signature($signatures) { + foreach ($signatures as $signature) { + if ($signature->status === 'signed') { + return $signature; + } + } + + return null; + } + /** * Get the HTML for displaying the signature status. * @@ -354,69 +367,205 @@ private function get_endorsements_html($endorsements, $options) { } /** - * Get the HTML attributes for the signature container. + * Wrap signed post content in a spec-conformant signed-section element. * * @since 1.0.0 * @param object $signature The signature object. - * @return string The signature attributes HTML. + * @param string $content The rendered post content. + * @return string The signed section HTML. */ - private function get_signature_attributes_html($signature) { - // Get the author's public key - $author_public_key = ''; - - // Create an API client for the server - $server = $this->db->get_server($signature->server_id); - if ($server) { - $api_client = new ContentSigning_API_Client( - $server->api_url, - $this->db->decrypt($server->api_key_encrypted), - $this->db - ); - - // Get the author's public key - $result = $api_client->get_author_public_key($signature->signing_author_id); - if (!is_wp_error($result) && isset($result['key'])) { - $author_public_key = $result['key']; - } + private function get_signed_section_html($signature, $content) { + $key = $this->get_key_metadata($signature); + if ($key['keyid'] === '' || $key['algorithm'] === '') { + return $content; } - // Build the signed-section element with signature attributes $html = 'signature) . '" '; - $html .= 'keyid="' . esc_attr($author_public_key) . '" '; - $html .= 'algorithm="ed25519" '; - $html .= 'content-hash="' . esc_attr($signature->content_hash) . '" '; - $html .= 'style="display: block;">'; - - // Inner metadata: timestamp - $signed_at = $signature->signed_at ? $signature->signed_at : $signature->created_at; - if ($signed_at) { - $html .= ''; + $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 .= ''; + + return $html; + } + + /** + * Get the key identifier and signature algorithm for a signature. + * + * The `algorithm` attribute has to name the algorithm the signing key + * actually uses; a verifier that is handed the wrong identifier fails with + * "algorithm-not-supported" or, worse, tries the wrong primitive. The + * directory reports it from GET /authors/{id}/public-key. + * + * The remote lookup is cached because this runs on every rendered view of + * a signed post. + * + * @since 1.0.0 + * @param object $signature The signature object. + * @return array Key metadata: 'keyid' and 'algorithm'. + */ + private function get_key_metadata($signature) { + $keyid = ''; + $algorithm = ''; + + $api_response = json_decode($signature->api_response_json, true); + if (is_array($api_response)) { + foreach (array('keyid', 'keyId', 'publicKeyUrl') as $field) { + if (!empty($api_response[$field])) { + $keyid = (string) $api_response[$field]; + break; + } + } + + if (!empty($api_response['algorithm'])) { + $algorithm = (string) $api_response['algorithm']; + } } - // Inner metadata: author name - $author_name = $signature->signing_author_id; - $wp_user = get_user_by('ID', $signature->wp_user_id); - if ($wp_user) { - $author_name = $wp_user->display_name; + if ($keyid === '' || $algorithm === '') { + $remote = $this->get_remote_key_metadata($signature); + + if ($keyid === '' && $remote['keyid'] !== '') { + $keyid = $remote['keyid']; + } + + if ($algorithm === '' && $remote['algorithm'] !== '') { + $algorithm = $remote['algorithm']; + } + } + + return array( + 'keyid' => $keyid, + 'algorithm' => $algorithm, + ); + } + + /** + * Look up key metadata from the signing server, with a short cache. + * + * @since 1.0.0 + * @param object $signature The signature object. + * @return array Key metadata: 'keyid' and 'algorithm'. + */ + private function get_remote_key_metadata($signature) { + $empty = array('keyid' => '', 'algorithm' => ''); + + $cache_key = 'content_signing_key_' . md5($signature->server_id . '|' . $signature->signing_author_id); + $cached = get_transient($cache_key); + if (is_array($cached)) { + return $cached; } - if ($author_name) { - $html .= ''; + + $server = $this->db->get_server($signature->server_id); + if (!$server) { + return $empty; } - // Inner metadata: claims from JSON - $claims = json_decode($signature->claims_json, true); - if (!empty($claims)) { - foreach ($claims as $key => $value) { + $api_client = new ContentSigning_API_Client( + $server->api_url, + $this->db->decrypt($server->api_key_encrypted), + $this->db + ); + + $result = $api_client->get_author_public_key($signature->signing_author_id); + if (is_wp_error($result) || !is_array($result)) { + return $empty; + } + + $metadata = $empty; + if (!empty($result['id'])) { + $metadata['keyid'] = rtrim($server->api_url, '/') . '/keys/' . rawurlencode((string) $result['id']); + } + if (!empty($result['algorithm'])) { + $metadata['algorithm'] = (string) $result['algorithm']; + } + + set_transient($cache_key, $metadata, 12 * HOUR_IN_SECONDS); + + return $metadata; + } + + /** + * Get direct child claim meta HTML for a signed-section. + * + * @since 1.0.0 + * @param object $signature The signature object. + * @return string The meta HTML. + */ + private function get_claim_meta_html($signature) { + $claims = $this->get_protocol_claims($signature); + $html = ''; + + foreach ($claims as $name => $value) { + $html .= ''; + } + + return $html; + } + + /** + * Get the protocol claim map for a signature. + * + * @since 1.0.0 + * @param object $signature The signature object. + * @return array Claims keyed by direct meta name. + */ + private function get_protocol_claims($signature) { + $claims = array(); + $stored_claims = json_decode($signature->claims_json, true); + + if (is_array($stored_claims)) { + foreach ($stored_claims as $key => $value) { + if (is_array($value) && isset($value['name'], $value['content'])) { + $claims[$value['name']] = $value['content']; + continue; + } + if (is_array($value)) { $value = implode(', ', $value); } - $html .= ''; + + $name = (string) $key; + if ($name !== 'author' && $name !== 'signed-at' && strpos($name, 'claim:') !== 0) { + $name = 'claim:' . $name; + } + + $claims[$name] = (string) $value; } } - $html .= ''; + $author_name = $signature->signing_author_id; + $wp_user = get_user_by('ID', $signature->wp_user_id); + if ($wp_user) { + $author_name = $wp_user->display_name; + } + if ($author_name && empty($claims['author'])) { + $claims['author'] = $author_name; + } - return $html; + if (empty($claims['signed-at'])) { + $claims['signed-at'] = $this->format_signed_at($signature->signed_at ? $signature->signed_at : $signature->created_at); + } + + return $claims; + } + + /** + * Format a stored datetime as RFC3339 UTC. + * + * @since 1.0.0 + * @param string $datetime The stored datetime. + * @return string The RFC3339 UTC datetime. + */ + private function format_signed_at($datetime) { + $timestamp = strtotime($datetime); + if (!$timestamp) { + $timestamp = time(); + } + + return gmdate('Y-m-d\TH:i:s\Z', $timestamp); } } diff --git a/wordpress/public/class-content-signing-public.php b/wordpress/public/class-content-signing-public.php index 48c61ff..2383f2a 100644 --- a/wordpress/public/class-content-signing-public.php +++ b/wordpress/public/class-content-signing-public.php @@ -52,6 +52,19 @@ public function __construct($db, $api_client) { $this->display = new ContentSigning_Display($db, $api_client); } + /** + * Get the display handler. + * + * The signing service needs it to detach the `the_content` wrapper while + * rendering the bytes it hashes. + * + * @since 1.0.0 + * @return ContentSigning_Display The display handler. + */ + public function get_display() { + return $this->display; + } + /** * Register the stylesheets for the public-facing side of the site. * diff --git a/wordpress/public/js/content-signing-public.js b/wordpress/public/js/content-signing-public.js index 504097c..68ad1fc 100644 --- a/wordpress/public/js/content-signing-public.js +++ b/wordpress/public/js/content-signing-public.js @@ -85,7 +85,8 @@ "content-signing-verification-loading content-signing-verification-error", ) .addClass("content-signing-verification-success") - .html(buildVerificationResultHtml(response.data, true)); + .empty() + .append(buildVerificationResult(response.data, true)); } else { // Show error message $resultContainer @@ -93,58 +94,75 @@ "content-signing-verification-loading content-signing-verification-success", ) .addClass("content-signing-verification-error") - .html(buildVerificationResultHtml(response.data, false)); + .empty() + .append(buildVerificationResult(response.data, false)); } } /** - * Build the verification result HTML. + * Build the verification result nodes. + * + * Every value here originates from the trust server and is untrusted: a + * directory is not part of the trust root, and its response strings reach + * this page verbatim. Nodes are therefore built with .text() rather than + * concatenated into markup. * * @param {Object} data - The verification data. * @param {boolean} success - Whether verification was successful. - * @return {string} The HTML for the verification result. + * @return {jQuery} The nodes for the verification result. */ - function buildVerificationResultHtml(data, success) { - let html = ""; + function buildVerificationResult(data, success) { + const $nodes = $(); + const details = data && data.verification_details; if (success) { - html += - "

" + content_signing_public.i18n.verified + "

"; + const $heading = $("

").append( + $("").text(content_signing_public.i18n.verified), + ); + let $result = $nodes.add($heading); // Add verification details if available - if (data.verification_details) { - html += '