Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 30 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 postit 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

Expand All @@ -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
Expand All @@ -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 81 tests in this checkout.

### Manual test setup

Expand All @@ -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 `<signed-section>` wrapper around the actual signed content:

```html
<signed-section keyid="did:web:author.example"
<signed-section profile="htmltrust-signature-v1"
signature-scope="url" location="https://example.com/articles/engines"
keyid="did:web:author.example"
signature="BASE64_SIG" algorithm="ed25519"
content-hash="sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU">
<meta name="author" content="Alice Example">
Expand Down Expand Up @@ -203,16 +202,16 @@ 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 academicI'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:

- Debates about whether AI should be used to write code or specifications.
- Opinions on who is or isn't trustworthy on the web.
- Politics, religion, professional practice, or personal philosophy.

HTMLTrust is a mechanisma 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).
9 changes: 6 additions & 3 deletions docs/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
37 changes: 25 additions & 12 deletions docs/html-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,23 @@ Signed content uses the `<signed-section>` 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 `<signed-section>` wrapper itself in this revision. All claim and contextual metadata (author name, signed-at timestamp, license, content type, AI assistance, etc.) belongs in inner `<meta>` 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 `<meta>` elements as documented under **Inner Metadata** below.

Presentational attributes such as `style` and `class` SHOULD NOT be set inline on `<signed-section>`. 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.

Expand All @@ -35,7 +40,7 @@ Presentational attributes such as `style` and `class` SHOULD NOT be set inline o

## Inner Metadata

The `<signed-section>` element MAY contain `<meta>` 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 `<signed-section>` element MAY contain `<meta>` 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

Expand Down Expand Up @@ -65,6 +70,9 @@ The `<signed-section>` element wraps the signed content:
<signed-section
signature="BASE64_SIG"
keyid="https://api.example.com/authors/123/public-key"
profile="htmltrust-signature-v1"
signature-scope="url"
location="https://example.com/article"
algorithm="ed25519"
content-hash="sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU">
<meta name="author" content="Alice Example">
Expand Down Expand Up @@ -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 `<meta>` 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 `<meta name="signed-at">` element
- `content-hash`: hash of the canonicalized text content (see above)
- `claims-hash`: SHA-256 hash of the canonical serialization of all inner `<meta>` 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 `<meta name="signed-at">` element

For example:
```
Expand All @@ -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 `<meta>` 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.

Expand Down
20 changes: 19 additions & 1 deletion wordpress/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
- Limited documentation
Loading
Loading