Skip to content

Document signing companion HTTP requests - #3556

Open
Jiwoon-Kim wants to merge 4 commits into
Automattic:trunkfrom
Jiwoon-Kim:docs/http-request-signing-extension
Open

Document signing companion HTTP requests#3556
Jiwoon-Kim wants to merge 4 commits into
Automattic:trunkfrom
Jiwoon-Kim:docs/http-request-signing-extension

Conversation

@Jiwoon-Kim

@Jiwoon-Kim Jiwoon-Kim commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What

Document the existing companion-plugin signing surface in FEDERATION.md.

A caller can pass key_id and private_key to wp_safe_remote_post() and reuse the plugin's existing http_request_args signature implementation without adopting the official Outbox model.

The new section also makes the ownership boundary explicit: the caller owns recipient validation, recipient selection, queueing, and retries, and should resolve private key material only while sending.

Why

This follows the maintainer guidance in #3548. The capability already exists and works with stock 9.0.2, but it was not documented as an extension point. A small documentation contract is preferable to adding a parallel delivery API.

Resolution of #3533

No new domain-persistence or delivery API is required. The inbound half already composes from the existing post-verification controller actions (activitypub_inbox and activitypub_inbox_shared), behavior-level handler registration through activitypub_register_handlers, and conditional activitypub_skip_inbox_storage claiming. A companion can therefore consume verified traffic and suppress duplicate default domain persistence without replacing signature verification.

For outbound traffic, the companion owns recipient selection, its private spool, and retry policy. This PR documents the remaining supported boundary: reusing the official http_request_args signer with transient key_id and private_key request arguments. Together these existing seams satisfy the inbound and outbound requirements described in #3533 while keeping the official plugin authoritative for protocol verification and HTTP signing.

Validation

  • Markdown diff check passes.
  • A companion implementation using this exact request shape was tested against both stock 9.0.2 and the former integration fork; the official signer produced the outbound HTTP signature in both configurations.

Changelog

  • Automatically create a changelog entry from the details below.
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Added - for new features
  • Changed - for changes in existing functionality
  • Deprecated - for soon-to-be removed features
  • Removed - for now removed features
  • Fixed - for any bug fixes
  • Security - in case of vulnerabilities

Message

Document how companion plugins can reuse the existing outbound HTTP signing extension point.

Closes #3533

@matticbot

Copy link
Copy Markdown
Contributor

👋 Thanks for your contribution!

I see you have provided all the required changelog information in your PR description. To complete the process, you'll need to create a changelog file in your branch.

Please create a file named .github/changelog/3556-from-description with this content:

Significance: patch
Type: changed

Document how companion plugins can reuse the existing outbound HTTP signing extension point.

You can do this by either:

  1. Running composer changelog:add in your local repository, or
  2. Creating the file manually with the content above

Once you've committed and pushed the file to your PR branch, the checks will pass automatically.

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Thanks again for pointing us back toward the existing composition and signing surfaces. I have now released Axismundi ActivityPub Bridge 0.0.16 Alpha, which works with stock ActivityPub 9.0.2 and no longer depends on the integration fork.

The transport spool was also changed from the provisional private CPT to a dedicated delivery table rather than simply copying ap_outbox into another CPT. There were two concrete reasons:

  1. A CPT split the transport state between post_status and post meta, recreating ambiguous ownership of the state machine.
  2. add_post_meta( ..., true ) is not a database-level worker claim because wp_postmeta has no unique (post_id, meta_key) constraint. Two workers can pass its check-then-insert window.

The released Bridge instead uses one site-prefixed delivery table with a unique Activity URI hash, one status field, a conditional UPDATE compare-and-swap claim, claim heartbeats, bounded retries, and no persisted private key material. Legacy experimental ap_outbox rows and provisional Bridge CPT rows are migrated non-destructively and linked to the new jobs. The Bridge suite passes 74 assertions, and the stock-plugin composition/signing subset passes 52 assertions.

Current companion releases:

At this point the companion stack includes FEP-b2b8 Article projection, an FEP-1311-based standalone media rendition application, and Like/Undo plus Announce/Undo. FEP-044f quote authorization work is in progress. Once that layer is complete, I plan to run the real Article Create and end-to-end interaction scenarios against public Mastodon/Misskey instances and report the observed interoperability results.

@Jiwoon-Kim

Jiwoon-Kim commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up interoperability update:

The FEP-044f consent and authorization path is now implemented in the Axismundi companion stack. This includes per-Article canQuote policy projection, URI-keyed and idempotent QuoteRequest Accept/Reject decisions, dereferenceable QuoteAuthorization objects with active/revoked 200/410 behavior, privacy-minimal revocation Deletes, and quote-relation indexing that recognizes both FEP-044f and Misskey-compatible quote fields without treating a compatibility alias as consent evidence.

I cannot yet honestly mark the complete Mastodon quote round trip as passed. During one deployment the Actor document was briefly cached without its publicKey. mastodon.social cached that recent keyless Actor and currently rejects the generated Follow/Quote Accept activities with 401 Public key not found for key ...#main-key, even though the canonical Actor document now advertises that exact key again.

Bridge 0.0.20 closes that deployment race: inbox, endpoints, and publicKey are now advertised as one atomic bundle, the WebFinger ActivityStreams self link is withheld while the signing key cannot be projected, and queued delivery is held rather than discarded during that readiness window. The exact peer key-discovery 401 now uses a bounded 5m -> 1h -> 24h -> 48h recovery schedule before dead-lettering; generic authentication failures remain terminal. I will repeat the final QuoteAuthorization test after the remote Actor cache becomes stale.

As an independent control, the same Actor identity, key material, stock ActivityPub signing hook, and Bridge delivery table successfully completed Follow/Accept and Announce/repost interoperability against uri.life. That instance does not expose quote authoring, so it could not exercise FEP-044f, but it confirms that the stock signing/composition path works outside the mastodon.social cache condition.

Current relevant releases:

I will post the final mastodon.social QuoteRequest -> Accept -> QuoteAuthorization result once that cache no longer masks the corrected Actor representation.

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Final interoperability update:

The outbound FEP-044f Quote path is now complete in the Axismundi companion stack, including the held Create lifecycle for remote consent:

  • a local Note quoting a remote object emits a QuoteRequest with the finalized Note inline;
  • the Note remains non-public and no Create is emitted while approval is pending;
  • a verified Accept plus QuoteAuthorization opens the first Create, with quoteAuthorization attached;
  • self-quotes emit immediately without fabricated authorization, while local-other and remote targets use the authorization path;
  • a terminal Reject remains immutable, and an explicit target removal/re-addition creates a new QuoteRequest generation for a retry.

The projection emits the FEP-044f quote member and the Misskey compatibility aliases, while keeping policy declaration separate from approval evidence. Existing unset content remains fail-closed; only newly authored objects receive the public/anyone default.

I have now verified the completed flow against both public implementations:

The generic Bridge delivery path is also covered end to end for outbound QuoteRequest, automatic Accept, and revocation Delete. The full quote path no longer requires Quote-specific transport code.

Current releases:

@pfefferle

Copy link
Copy Markdown
Member

I know I mentioned the FEDERATION.md, but I think (because the FEDERATION.md is about generics) we should have a dedicated doc in the docs folder instead. What do you think?

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Yes, agreed. I moved the companion signing/delivery material out of FEDERATION.md into docs/external-activity-delivery.md, and added it to the docs index. FEDERATION.md is generic again.

Separately, I am currently implementing Question support in the Axismundi companion stack: structured Question/options storage, ActivityStreams Question projection, and a read-only poll view. That work is intentionally outside this documentation-only PR.

@Jiwoon-Kim

Jiwoon-Kim commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author

Companion-stack progress update

The Axismundi theme and the current companion-plugin releases are now at a point where the integrated federated-object experience is genuinely usable, while still clearly pre-release and incomplete.

Since the earlier QuoteAuthorization work, the main advances are on the receiving and presentation side:

  1. Remote ActivityStreams Objects now have a URI-keyed, rebuildable cache and a shared Object view model. The same card composition is used for individual Object pages, Actor timelines, and mixed hashtag discovery.
  2. Public remote Actor profiles can show inbound Create and Announce history. A URI-only public Announce now queues bounded background acquisition of its target Object; older ledger rows self-heal when the profile is viewed. Rendering never performs the remote fetch synchronously, and non-public/tombstoned cached Objects remain hidden.
  3. ax_hashtag is now a shared local-and-remote vocabulary: received remote Hashtags are indexed alongside local Notes, Posts, and media, and the block-theme hashtag archive mixes those public Object sources through the normal template hierarchy.
  4. Object-to-Actor Mention relations are persisted separately from hashtags. The editor-side mention flow uses Actor handles for authoring while canonical Actor URIs remain the stored/federated authority.
  5. The block-theme surfaces are being normalized around server-registered, editable block siblings rather than bespoke page output. This includes reusable Object cards, Actor identity parts, interaction controls, and public cache-only Object rendering.

There is still substantial work ahead: pagination and richer discovery filters, fuller media/object presentation, notification and inbox UX, additional interaction views, reconciliation work, and more real-world interoperability coverage. But the pieces now form a useful end-to-end baseline rather than isolated protocol experiments.

The companion code continues to compose with the stock ActivityPub plugin at the verified inbound-handler and outbound HTTP-signing seams documented by this PR; it does not replace the plugin's signature verification or signer.

@Jiwoon-Kim

Copy link
Copy Markdown
Contributor Author
image

@pfefferle pfefferle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for moving this out of FEDERATION.md. Two structural notes and one correction to the prose, suggested inline where GitHub lets me.

Placement. docs/developer-docs.md already exists for exactly this audience. It opens by saying it is for developers building a complementary plugin and documents the available hooks, so a companion signing seam belongs there as a section rather than as a new top-level file. docs/readme.md currently indexes only the user-facing FAQ and How-To sections and does not link developer-docs.md at all, so a one-item "Extension points" heading there ends up promoting this single seam above the general developer documentation.

Filename. "External Activity delivery" reads as though the plugin delivers something. The doc correctly says the companion owns recipient selection, the queue and retries, so what the plugin actually contributes is the signature. Under a "Signing Outbound Requests" heading that mismatch goes away.

One correction worth making. The bullet about not persisting key material understates what happens. key_id and private_key deliberately stay in the request arguments after signing, because Signature::maybe_double_knock() is hooked on http_response and needs them to re-sign in the draft format and retry when an RFC 9421 request comes back 4xx (see includes/class-signature.php:214). They are therefore readable by any other http_request_args or http_response callback on the site, and by anything that logs request arguments. A companion cannot prevent that, so the doc should say so plainly instead of implying that careful handling on the caller's side is enough.

Here is the section to add to docs/developer-docs.md, with - [Signing Outbound Requests](#signing-outbound-requests) added to its Table of Contents:

## Signing Outbound Requests

A companion plugin that constructs and delivers its own Activities can reuse the plugin's HTTP-signing implementation, without adopting the Outbox model. Pass the sender's public key identifier and private key as the `key_id` and `private_key` request arguments, and the plugin's `http_request_args` filter adds the signature headers before WordPress sends the request:

```php
$response = wp_safe_remote_post(
	$recipient_inbox,
	array(
		'body'        => wp_json_encode( $activity ),
		'headers'     => array( 'Content-Type' => 'application/activity+json' ),
		'data_format' => 'body',
		'key_id'      => $sender_key_id,
		'private_key' => $sender_private_key,
	)
);
```

The plugin chooses the signature format itself, based on the site's RFC 9421 setting and whether the recipient is known to support it. When an RFC 9421 request is answered with a 4xx, the plugin re-signs it in the older draft format and retries once. Callers do not select the format and should not depend on which one a given request uses.

Both arguments stay in the request arguments after signing, because that retry needs them again once the response comes back. They are therefore visible to any other callback on `http_request_args` and `http_response`, and to anything on the site that logs request arguments. Resolve key material only for the duration of the send, and treat the request arguments as readable by other plugins.

The companion remains responsible for:

- validating recipient URLs;
- selecting recipients;
- owning its delivery queue and retry policy; and
- resolving private key material only while sending, without persisting it in transport rows or logs.

Comment thread docs/readme.md
Comment on lines +5 to +8
## Extension points

- [External Activity delivery](./external-activity-delivery.md) documents the supported HTTP-signing seam for companion plugins that own their outbound delivery flow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This index lists only the user-facing FAQ and How-To sections today, and does not link developer-docs.md. Adding a single extension point here puts it above the general developer docs. Suggest dropping it and adding the content to docs/developer-docs.md instead.

Suggested change
## Extension points
- [External Activity delivery](./external-activity-delivery.md) documents the supported HTTP-signing seam for companion plugins that own their outbound delivery flow.

Comment thread docs/readme.md

## How-To

For guides on configuring specific setups (caching, reverse proxies, subdirectory installs), check out the [How-To section](./how-to) or use [the support forums on WordPress.org](https://wordpress.org/support/plugin/activitypub/).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the change: this drops the file's trailing blank line. Worth restoring so the diff stays limited to the section above.

Suggested change
For guides on configuring specific setups (caching, reverse proxies, subdirectory installs), check out the [How-To section](./how-to) or use [the support forums on WordPress.org](https://wordpress.org/support/plugin/activitypub/).
For guides on configuring specific setups (caching, reverse proxies, subdirectory installs), check out the [How-To section](./how-to) or use [the support forums on WordPress.org](https://wordpress.org/support/plugin/activitypub/).

@@ -0,0 +1,25 @@
# External Activity delivery

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file can be removed once the content lands in docs/developer-docs.md, which is already the home for companion-plugin extension points. The full section, with the signature-format and key-visibility notes added, is in the review comment above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Supported S2S transport extension points without default domain persistence

3 participants