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
5 changes: 5 additions & 0 deletions .changeset/activitypub-fep-8b32-integrity-proofs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dwk/activitypub": minor
---

Add outbound FEP-8b32 object integrity proofs. A new optional `integrityKey` config (an Ed25519 private JWK) publishes a FEP-521a `assertionMethod` `Multikey` on the actor document and adds an `eddsa-jcs-2022` `DataIntegrityProof` to every activity the actor authors when it is delivered. Forwarded activities are never signed. A malformed key fails at startup, and a key that still fails to import in the Durable Object is logged (`activitypub.integrity.key_invalid`) and deliveries continue without a proof.
5 changes: 5 additions & 0 deletions .changeset/vc-data-integrity-subpath.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dwk/vc": minor
---

Publish the Data Integrity proof pipeline as the `@dwk/vc/data-integrity` subpath export (`importSigner`, `addProof`, `verifyProof`, `encodeEd25519Multikey`, `decodeMultikey`), so consumers that only need proofs avoid the credential and `did:web` endpoint code.
8 changes: 8 additions & 0 deletions packages/activitypub/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ Also contributes a read-only `@dwk/mcp` tool (`createActivitypubMcpTools` →
method, target URI, host, date, and content digest.
- **Activity deduplication.** Inbound activities are deduped by their `id` in
the DO's SQLite store to prevent replay.
- **FEP-8b32 integrity proofs (outbound).** An optional `integrityKey`
(Ed25519 private JWK) publishes a FEP-521a `assertionMethod` Multikey on the
actor and makes `#processDeliveries` add an `eddsa-jcs-2022` proof via
`integrity.ts` `signOwnActivity`, only to activities whose `actor` is this
actor. Never sign a forwarded or relayed activity: a proof asserts
authorship. A key that fails to import must degrade to unsigned delivery,
never stall the queue. The proof pipeline is `@dwk/vc/data-integrity`'s; don't
reimplement JCS or Multikey here.
- **Delivery retries.** Outbound delivery failures trigger DO alarm-based retries
with exponential backoff. Failed deliveries are not silently dropped.
- **Inbox resolution off the critical path.** Auto-`Accept`-on-`Follow`/`Join`
Expand Down
25 changes: 25 additions & 0 deletions packages/activitypub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const activitypub = createActivityPub({
actor: { username: "alice", name: "Alice", summary: "Hello, fediverse." },
publicKeyPem: env.AP_PUBLIC_KEY, // published in the actor document
privateKeyPem: env.AP_PRIVATE_KEY, // signs outbound deliveries (secret binding)
integrityKey: JSON.parse(env.AP_INTEGRITY_KEY), // optional: Ed25519 JWK for FEP-8b32 proofs (secret binding)
publishToken: env.AP_PUBLISH_TOKEN, // optional: enables POST <actor>/outbox
software: { name: "anglesite", version: "1.2.3" }, // NodeInfo
});
Expand Down Expand Up @@ -94,6 +95,30 @@ No KV is used for any authoritative state, per
[`spec/non-functional-requirements.md`](../../spec/non-functional-requirements.md):
follower lists, dedup, and the delivery queue all live in the DO's SQLite.

## Object integrity proofs (FEP-8b32)

Set `integrityKey` to an Ed25519 private JWK (for example the output of
`crypto.subtle.exportKey("jwk", privateKey)` for a `{ name: "Ed25519" }` key
pair) and the actor:

- publishes the public half as a [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)
`assertionMethod` `Multikey` at `<actor>#ed25519-key`, and
- delivers every activity it authors with a top-level
[FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md)
`DataIntegrityProof` (`eddsa-jcs-2022`), so peers can authenticate it
without relying on the HTTP signature, for example after it has been
forwarded.

Store the JWK as a **secret binding** (`wrangler secret put AP_INTEGRITY_KEY`),
never a plain `vars` entry: it is a private signing key, handled exactly like
`privateKeyPem`. It is persisted in the actor's Durable Object so alarm-driven
deliveries can sign.

Activities this actor forwards on someone else's behalf are never signed. The
proof pipeline is [`@dwk/vc/data-integrity`](../vc/README.md)'s. Verifying
proofs on inbound activities is not implemented yet; inbound authentication is
still the HTTP signature.

## Unreachable peers

Each actor's Durable Object keeps a per-host delivery circuit breaker. After 5
Expand Down
1 change: 1 addition & 0 deletions packages/activitypub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@dwk/mastodon-api": "workspace:*",
"@dwk/mcp": "workspace:*",
"@dwk/safe-fetch": "workspace:*",
"@dwk/vc": "workspace:*",
"@dwk/webfinger": "workspace:*"
}
}
19 changes: 19 additions & 0 deletions packages/activitypub/src/as2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

/** The ActivityStreams 2.0 namespace IRI. */
export const AS2_NS = "https://www.w3.org/ns/activitystreams";
/** JSON-LD context defining `proof` / `DataIntegrityProof` terms (FEP-8b32). */
export const DATA_INTEGRITY_CONTEXT =
"https://w3id.org/security/data-integrity/v2";
/** JSON-LD context defining `Multikey` / `assertionMethod` terms (FEP-521a). */
export const MULTIKEY_CONTEXT = "https://w3id.org/security/multikey/v1";

/** The W3C security vocabulary namespace (carries `publicKey`). */
export const SECURITY_NS = "https://w3id.org/security/v1";
Expand Down Expand Up @@ -250,6 +255,12 @@ export interface ActorDocumentOptions {
* Omitted when not supplied.
*/
readonly url?: string;
/**
* A FEP-521a `Multikey` verification method for FEP-8b32 integrity proofs,
* published under `assertionMethod` (with the Multikey and Data Integrity
* contexts). Omitted when the actor has no integrity key.
*/
readonly assertionMethod?: Record<string, JsonValue>;
}

/**
Expand Down Expand Up @@ -310,6 +321,14 @@ export function buildActorDocument(
if (options.sharedInbox !== undefined) {
doc.endpoints = { sharedInbox: options.sharedInbox };
}
if (options.assertionMethod !== undefined) {
doc["@context"] = [
...ACTOR_CONTEXT,
MULTIKEY_CONTEXT,
DATA_INTEGRITY_CONTEXT,
] as JsonValue;
doc.assertionMethod = [options.assertionMethod];
}
return doc;
}

Expand Down
18 changes: 18 additions & 0 deletions packages/activitypub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { safeFetch, SsrfError } from "@dwk/safe-fetch";

import type { ActorIris, ActorProfile } from "./as2.js";
import { readJsonCapped } from "./discovery.js";
import { assertIntegrityKey } from "./integrity.js";
import { DEFAULT_USER_AGENT } from "./http.js";
import { ActivityPubLogEvent } from "./log.js";
import {
Expand Down Expand Up @@ -90,6 +91,17 @@ export interface ActivityPubConfig {
*/
readonly privateKeyPem?: string;

/**
* Ed25519 **private** JWK (`kty: "OKP"`, `crv: "Ed25519"`, with `x` and `d`;
* a secret binding's value, parsed) for FEP-8b32 object integrity proofs.
* When set, the actor document publishes the public half as a FEP-521a
* `assertionMethod` `Multikey` (`<actor>#ed25519-key`), and every activity
* this actor authors is delivered with an `eddsa-jcs-2022`
* `DataIntegrityProof`. Optional: omit it and deliveries carry only the HTTP
* signature. A malformed key fails at startup.
*/
readonly integrityKey?: JsonWebKey;

/**
* Bearer token authorizing the owner-only publish endpoint
* (`POST <actor>/outbox`). When unset, that endpoint is disabled. This is the
Expand Down Expand Up @@ -223,6 +235,7 @@ export interface ResolvedConfig {
readonly moderators: readonly string[];
readonly publicKeyPem: string;
readonly privateKeyPem?: string;
readonly integrityKey?: JsonWebKey;
readonly publishToken?: string;
readonly pageSize: number;
readonly deliveryMaxAttempts: number;
Expand Down Expand Up @@ -296,6 +309,8 @@ export interface ForwardedConfig {
readonly keyId: string;
/** Private key (PKCS#8 PEM) so the DO can sign deliveries from its alarm. */
readonly privateKeyPem?: string;
/** Ed25519 JWK so the DO can add FEP-8b32 proofs to deliveries from its alarm. */
readonly integrityKey?: JsonWebKey;
/** `User-Agent` the DO sends on outbound deliveries from its alarm. */
readonly userAgent: string;
}
Expand Down Expand Up @@ -507,6 +522,8 @@ export function resolveConfig(config: ActivityPubConfig): ResolvedConfig {
if (!config.publicKeyPem) {
throw new Error("@dwk/activitypub: `publicKeyPem` is required");
}
if (config.integrityKey !== undefined)
assertIntegrityKey(config.integrityKey);
const baseUrl = normalizeBaseUrl(config.baseUrl);
const fetchImpl = config.fetch ?? fetch;
const sharedInbox =
Expand Down Expand Up @@ -584,6 +601,7 @@ export function resolveConfig(config: ActivityPubConfig): ResolvedConfig {
moderators: config.moderators ?? [],
publicKeyPem: config.publicKeyPem,
privateKeyPem: config.privateKeyPem,
...(config.integrityKey ? { integrityKey: config.integrityKey } : {}),
publishToken: config.publishToken,
pageSize: config.pageSize ?? DEFAULT_PAGE_SIZE,
deliveryMaxAttempts: config.deliveryMaxAttempts ?? DEFAULT_MAX_ATTEMPTS,
Expand Down
10 changes: 10 additions & 0 deletions packages/activitypub/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isHandleShaped, resolveHandleGuarded } from "./discovery.js";

import { as2ContentType, buildActorDocument, type JsonValue } from "./as2.js";
import { readRequestBodyCapped } from "./body.js";
import { buildAssertionMethod } from "./integrity.js";
import {
buildNodeInfo20,
buildNodeInfo21,
Expand Down Expand Up @@ -106,6 +107,7 @@ export function forwardedConfig(config: ResolvedConfig): ForwardedConfig {
sharedInbox: config.sharedInbox,
userAgent: config.userAgent,
...(config.privateKeyPem ? { privateKeyPem: config.privateKeyPem } : {}),
...(config.integrityKey ? { integrityKey: config.integrityKey } : {}),
};
}

Expand Down Expand Up @@ -297,6 +299,14 @@ export function createActivityPub(
sharedInbox: resolved.sharedInbox,
webfinger: resolved.webfinger,
url: resolved.url,
...(resolved.integrityKey
? {
assertionMethod: buildAssertionMethod(
iris.id,
resolved.integrityKey,
),
}
: {}),
}),
);
return new Response(method === "HEAD" ? null : body, {
Expand Down
Loading
Loading