Skip to content
Merged
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
44 changes: 31 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,47 @@ on:
pull_request:
branches: [main]

# Least privilege: this workflow only reads the repo and uploads artifacts.
permissions:
contents: read

jobs:
wordpress:
name: WordPress Plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- uses: shivammathur/setup-php@v2
- uses: shivammathur/setup-php@b604ade2a87db23f8871b7182e69ec5e75effb45 # v2
with:
php-version: "8.3"
extensions: intl, mbstring, mysqli
tools: composer

- name: Configure private dep access
env:
TOKEN: ${{ secrets.HTMLTRUST_PKG_TOKEN }}
run: |
git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
composer config --global github-oauth.github.com "${TOKEN}"

# The package token used to be written to ~/.gitconfig and to composer's
# global auth.json, where every later step -- including any composer
# plugin or lifecycle script -- could read it back. It is now passed
# through COMPOSER_AUTH and GIT_CONFIG_*, which are scoped to this
# process tree and never persisted to disk. --no-scripts and --no-plugins
# stop third-party install hooks (dealerdirect/phpcodesniffer-composer-
# installer among them) from running while the token is in the
# environment; vendor/ is excluded from the packaged zip below, so
# nothing downstream depends on those hooks having run.
#
# HTMLTRUST_PKG_TOKEN must be a fine-grained PAT scoped to the
# HTMLTrust/htmltrust-canonicalization repository with Contents: Read and
# nothing else. A classic `repo`-scoped token grants write access to every
# repo the owner can reach and must not be used here.
- name: Install dependencies
working-directory: wordpress
run: composer install --no-interaction --prefer-dist
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.HTMLTRUST_PKG_TOKEN }}"}}'
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: url.https://x-access-token:${{ secrets.HTMLTRUST_PKG_TOKEN }}@github.com/.insteadOf
GIT_CONFIG_VALUE_0: https://github.com/
run: composer install --no-interaction --prefer-dist --no-scripts --no-plugins

- name: Package plugin
run: |
Expand All @@ -39,7 +55,7 @@ jobs:
zip -r ../dist/htmltrust-wordpress-plugin.zip . \
-x "vendor/*" "tests/*" "bin/*" "composer.lock" "phpunit.xml"

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: wordpress-plugin
path: dist/htmltrust-wordpress-plugin.zip
Expand All @@ -48,7 +64,9 @@ jobs:
name: Hugo Integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- name: Verify Hugo partials syntax
run: |
Expand All @@ -57,7 +75,7 @@ jobs:
test -s hugo/layouts/partials/htmltrust-meta.html && echo " ✓ htmltrust-meta.html"
test -s hugo/scripts/sign-site.mjs && echo " ✓ sign-site.mjs"

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: hugo-integration
path: |
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ htmltrust-cms-reference/
### Prerequisites

- WordPress 5.0+
- PHP 7.0+
- PHP 7.2+
- A running [HTMLTrust trust directory server](https://github.com/HTMLTrust/htmltrust-server-reference)

### Installation
Expand Down Expand Up @@ -82,12 +82,15 @@ composer test

## The HTML Protocol

Signed content is embedded using data attributes that the browser extension recognizes:
Signed content is embedded with a `<signed-section>` wrapper around the actual signed content:

```html
<signed-section keyid="did:web:author.example"
signature="BASE64_SIG" algorithm="ed25519"
content-hash="sha256:abc123...">
content-hash="sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU">
<meta name="author" content="Alice Example">
<meta name="signed-at" content="2026-05-01T10:30:00Z">
<meta name="claim:ContentType" content="Article">
<article>
<h1>Verifiable Web Content</h1>
<p>Content should be provable...</p>
Expand Down
4 changes: 2 additions & 2 deletions docs/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Stores details about each signature generated.
| signing_author_id | VARCHAR | External author ID |
| wp_user_id | BIGINT | WordPress user ID |
| content_hash | VARCHAR | Content hash |
| domain | VARCHAR | Domain |
| domain | VARCHAR | Serialized Web origin stored in the legacy-named API field |
| signature | TEXT | Signature data |
| claims_json | TEXT | Claims as JSON |
| status | VARCHAR | Signature status |
Expand Down Expand Up @@ -364,4 +364,4 @@ wp i18n make-json languages/content-signing-LOCALE.po

- Keep API keys secure
- Validate and sanitize all user input
- Follow WordPress security best practices
- Follow WordPress security best practices
55 changes: 18 additions & 37 deletions docs/html-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document specifies how content publishers embed cryptographic signatures in

## Overview

Signed content uses the `<signed-section>` custom HTML element, as defined in the [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec). This element wraps or accompanies signed content and carries the cryptographic signature as attributes.
Signed content uses the `<signed-section>` custom HTML element, as defined in the [HTMLTrust specification](https://github.com/HTMLTrust/htmltrust-spec). This element wraps the signed content and carries the cryptographic signature as attributes.

## The `<signed-section>` Element

Expand All @@ -16,7 +16,7 @@ Per spec §2.1, the wrapper element carries exactly four required attributes:
|---|---|---|
| `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..."` |
| `content-hash` | Hash of the canonicalized text content, prefixed with the hash algorithm | `content-hash="sha256:abc123def456..."` |
| `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"` |

### Optional Attributes
Expand Down Expand Up @@ -59,14 +59,14 @@ Custom claim types are permitted. The claim vocabulary is extensible.

## HTML Structure

The `<signed-section>` element can either **wrap** the signed content:
The `<signed-section>` element wraps the signed content:

```html
<signed-section
signature="BASE64_SIG"
keyid="https://api.example.com/authors/123/public-key"
algorithm="ed25519"
content-hash="sha256:abc123def456...">
content-hash="sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU">
<meta name="author" content="Alice Example">
<meta name="signed-at" content="2025-05-01T10:30:00Z">
<meta name="claim:ContentType" content="Article">
Expand All @@ -79,26 +79,7 @@ The `<signed-section>` element can either **wrap** the signed content:
</signed-section>
```

Or appear as a **standalone marker** alongside content (e.g., when added by a CMS after the content):

```html
<article>
<h1>Verifiable Web Content</h1>
<p>This content is signed and verifiable.</p>
</article>
<signed-section
signature="BASE64_SIG"
keyid="https://api.example.com/authors/123/public-key"
algorithm="ed25519"
content-hash="sha256:abc123def456...">
<meta name="author" content="Alice Example">
<meta name="signed-at" content="2025-05-01T10:30:00Z">
<meta name="claim:ContentType" content="Article">
<meta name="claim:License" content="CC-BY-4.0">
</signed-section>
```

Both forms are valid. Verifying clients should handle either case.
CMS integrations should not emit a detached `<signed-section>` containing only metadata. Compatibility UI such as badges and verification buttons should live outside the signed section.

## Identity and Key Resolution

Expand All @@ -116,17 +97,18 @@ User agents MAY cache resolved keys (with appropriate freshness and revocation h

## Canonical Content Extraction

The hash that the signature covers is taken from the **text content** of the signed region, after the extraction and normalization process described below (spec §2.1). This is performed in two stages: HTML extraction, then text normalization.
The hash that the signature covers is taken from the canonicalized signed region after extraction and normalization. The canonical content includes normalized text plus the signed semantic attributes `href`, `src`, `alt`, and `aria-label` when present on included elements.

### Stage 1: HTML extraction

Given the inner contents of a `<signed-section>` element:

1. **Strip excluded elements** entirely, including their text content: `<script>`, `<style>`, `<meta>`, `<link>`, `<head>`, `<noscript>`. (`<meta>` is excluded because, inside a signed-section, it carries claim metadata rather than signed content. Claim metadata is hashed separately into the `claims-hash` field.)
2. **Insert a single space at every block-element boundary** (open and close tags of `<p>`, `<div>`, `<article>`, `<section>`, `<h1>`-`<h6>`, `<li>`, `<ul>`, `<ol>`, `<table>`, `<tr>`, `<td>`, `<th>`, `<header>`, `<footer>`, `<nav>`, `<main>`, `<aside>`, etc.) so that `<p>A</p><p>B</p>` extracts to `A B` and not `AB`. Inline elements (`<em>`, `<strong>`, `<a>`, `<span>`, etc.) do **not** introduce separators.
3. **Strip all remaining markup** (inline tags and any attributes), preserving only the text content.
4. **Decode HTML entities** (`&amp;`, `&lt;`, `&gt;`, named entities, numeric `&#nnn;` and `&#xhhhh;` entities).
5. Pass the resulting string to text normalization.
2. **Emit a line feed after every boundary-producing element** (`<p>`, `<div>`, `<article>`, `<section>`, `<h1>`-`<h6>`, `<li>`, `<ul>`, `<ol>`, `<table>`, `<tr>`, `<td>`, `<th>`, `<header>`, `<footer>`, `<nav>`, `<main>`, `<aside>`, etc.) so that `<p>A</p><p>B</p>` extracts to `A\nB` and not `AB`. The `br` element emits a line feed at its position. Inline elements (`<em>`, `<strong>`, `<a>`, `<span>`, etc.) do **not** introduce separators.
3. **Include signed semantic attributes** on included elements in this order: `href`, `src`, `alt`, `aria-label`. `href` and `src` are resolved against the signed document base URL and serialized as URLs; `alt` and `aria-label` use text normalization.
4. **Strip all remaining markup**, preserving text content and the signed attribute records.
5. **Decode HTML entities** (`&amp;`, `&lt;`, `&gt;`, named entities, numeric `&#nnn;` and `&#xhhhh;` entities).
6. Pass text and claim values to text normalization.

### Stage 2: Text normalization

Expand All @@ -141,16 +123,15 @@ The HTMLTrust canonicalization library applies, in order:

The output is a UTF-8 string. Hashing produces `sha256:<base64>` where `<base64>` is the unpadded Base64 encoding of the 32-byte SHA-256 digest.

**What is NOT covered by the hash.** Only the text content is hashed. HTML markup, element types, attributes (including `href`, `src`, `class`, `style`), and surrounding media are not part of the canonical content. This is a deliberate scoping choice; see **Text-only scope** below for the rationale and how HTMLTrust addresses the resulting semantic gaps through its layered design.
**What is NOT covered by the hash.** Full markup, element types, layout attributes, classes, inline styles, and most ARIA attributes are not part of the canonical content. The current signed attribute set is deliberately small and covers `href`, `src`, `alt`, and `aria-label`.

The reference implementation lives in the `@htmltrust/canonicalization` library, with byte-identical bindings for JavaScript, Go, PHP, Python, and Rust.

### Text-only scope
### Signed-content scope

The canonicalization hashes **text content only**, not the HTML markup or attributes that surround it. This means an adversary with possession of signed text MAY:
The canonicalization hashes normalized text plus the small signed semantic attribute set, not the full HTML structure. This means an adversary with possession of signed content MAY:

- Rewrap the text in misleading block elements (e.g., change an `<h1>` to a `<del>` strikethrough)
- Alter link destinations (`href` values) on `<a>` elements surrounding the signed text
- Introduce, remove, or swap images and other media around the signed text

These are **semantic integrity concerns**, not cryptographic ones. HTMLTrust addresses them through a layered design:
Expand All @@ -160,7 +141,7 @@ These are **semantic integrity concerns**, not cryptographic ones. HTMLTrust add

The layered design keeps cryptographic verification simple and portable across language implementations, while delegating semantic-integrity detection to the research ecosystem where it can evolve without breaking existing signatures.

**Open design question**: a future revision MAY extend the hash to cover particularly meaningful attributes, especially `href` on `<a>` elements (since link-swap within the original publication origin is a phishing vector that domain-binding and research cannot address alone). Feedback on which attributes to cover is explicitly welcome.
Future revisions MAY extend the signed attribute list. Verifiers for this revision use exactly `href`, `src`, `alt`, and `aria-label`.

## Signature Data Format

Expand All @@ -172,17 +153,17 @@ The signature binds four values, concatenated with `:` separators:

- `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 origin where the content is authoritatively published (anti-theft binding)
- `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:
```
sha256:RAyBCvKT...:sha256:eFgHiJkL...:example.com:2025-05-01T10:30:00Z
sha256:RAyBCvKT...:sha256:eFgHiJkL...:https://example.com:2025-05-01T10:30:00Z
```

The author's identity is **not** included in the binding because it is implicit in the keyid resolution step: any attempt to claim a signature under a different identity would resolve to a different public key and fail verification. This string is signed with the author's private key using the algorithm declared in the `algorithm` attribute.

**Hash encoding (open feedback)**: hashes are encoded as unpadded Base64, which is shorter than hexadecimal by roughly one-third. Community feedback on alternative encodings (hex, Base32) for ecosystem alignment is welcome.
Hashes and signatures are encoded as canonical unpadded standard Base64. This is not hex and not base64url.

## Verification Flow

Expand Down
29 changes: 14 additions & 15 deletions hugo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ This directory contains tools for integrating HTMLTrust content signing with [Hu

## How It Works

A Hugo partial computes SHA-256 content hashes and outputs `<signed-section>` elements during the normal `hugo build` — no post-processing or external tools required for content hashing.
A Hugo partial wraps page content in `<signed-section>` and emits direct child claim metadata during the normal `hugo build`.

For full cryptographic signing (binding content to an author's private key via a trust directory), an optional post-build script can fill in `signature` and `keyid` attributes by calling the API.
For content hashing and full cryptographic signing (binding content to an author's private key via a trust directory), run the post-build script. The script computes the spec wire hash as `sha256:<unpadded standard Base64>`, fills in `signature` and `keyid`, and preserves the signed wrapper.

## Quick Start

Expand Down Expand Up @@ -51,15 +51,16 @@ htmltrust:
hugo --minify
```

That's it. Every page with `htmltrust.sign: true` will have its content wrapped in a `<signed-section>` element with:
- `content-hash` — SHA-256 hash of the canonicalized content
Every page with `htmltrust.sign: true` will have its content wrapped in a `<signed-section>` element with:
- Inner `<meta>` tags for author, timestamp, and claims
- The actual page content

Run the post-build script to add the required `content-hash`, `signature`, `keyid`, and `algorithm` attributes.

## What Gets Generated

```html
<signed-section content-hash="sha256:abc123..." style="display: block;">
<signed-section>
<meta name="author" content="Jason Grey">
<meta name="signed-at" content="2025-05-12T10:30:00Z">
<meta name="claim:ContentType" content="Article">
Expand All @@ -72,7 +73,7 @@ That's it. Every page with `htmltrust.sign: true` will have its content wrapped

## Optional: API-Based Cryptographic Signing

To add full cryptographic signatures (the `signature`, `keyid`, and `algorithm` attributes), use the post-build script after `hugo build`:
To add spec-conformant content hashes and full cryptographic signatures, use the post-build script after `hugo build`:

```sh
hugo --minify
Expand All @@ -85,10 +86,10 @@ This requires a running [HTMLTrust trust directory server](https://github.com/HT
export HTMLTRUST_API_URL=http://localhost:3000
export HTMLTRUST_AUTHOR_API_KEY=your_author_api_key
export HTMLTRUST_AUTHOR_ID=your_author_id
export HTMLTRUST_DOMAIN=yourdomain.com
export HTMLTRUST_DOMAIN=https://yourdomain.com
```

The script finds existing `<signed-section>` elements (already wrapping the content from the Hugo build) and adds the missing `signature`, `keyid`, and `algorithm` attributes.
The script finds existing `<signed-section>` elements (already wrapping the content from the Hugo build) and adds or replaces the `content-hash`, `signature`, `keyid`, and `algorithm` attributes. If a page was not built with the partial, the script wraps the selected element instead of appending a detached marker.

## Files

Expand All @@ -104,10 +105,8 @@ hugo/

## Canonicalization

The partial canonicalizes content by:
1. Stripping all HTML tags (Hugo's `plainify`)
2. Collapsing all whitespace to single spaces (`replaceRE`)
3. Trimming leading/trailing whitespace (`strings.TrimSpace`)
4. Computing SHA-256 hash (Hugo's `sha256`)

This matches the canonicalization used by the WordPress plugin and browser extension.
The post-build script canonicalizes content by:
1. Excluding claim and executable elements such as `meta`, `script`, `style`, and `iframe`
2. Including signed semantic attributes: `href`, `src`, `alt`, and `aria-label`
3. Collapsing whitespace and computing a SHA-256 digest
4. Encoding the digest as canonical unpadded standard Base64
Loading
Loading