diff --git a/.changeset/activitypub-fep-8b32-integrity-proofs.md b/.changeset/activitypub-fep-8b32-integrity-proofs.md new file mode 100644 index 00000000..22720552 --- /dev/null +++ b/.changeset/activitypub-fep-8b32-integrity-proofs.md @@ -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. diff --git a/.changeset/vc-data-integrity-subpath.md b/.changeset/vc-data-integrity-subpath.md new file mode 100644 index 00000000..905a4ae9 --- /dev/null +++ b/.changeset/vc-data-integrity-subpath.md @@ -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. diff --git a/packages/activitypub/CLAUDE.md b/packages/activitypub/CLAUDE.md index 570f7a59..f4cfba95 100644 --- a/packages/activitypub/CLAUDE.md +++ b/packages/activitypub/CLAUDE.md @@ -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` diff --git a/packages/activitypub/README.md b/packages/activitypub/README.md index 6dd558aa..e65d0dde 100644 --- a/packages/activitypub/README.md +++ b/packages/activitypub/README.md @@ -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 /outbox software: { name: "anglesite", version: "1.2.3" }, // NodeInfo }); @@ -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 `#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 diff --git a/packages/activitypub/package.json b/packages/activitypub/package.json index edbf35d7..932e7769 100644 --- a/packages/activitypub/package.json +++ b/packages/activitypub/package.json @@ -54,6 +54,7 @@ "@dwk/mastodon-api": "workspace:*", "@dwk/mcp": "workspace:*", "@dwk/safe-fetch": "workspace:*", + "@dwk/vc": "workspace:*", "@dwk/webfinger": "workspace:*" } } diff --git a/packages/activitypub/src/as2.ts b/packages/activitypub/src/as2.ts index 951ae186..7570be03 100644 --- a/packages/activitypub/src/as2.ts +++ b/packages/activitypub/src/as2.ts @@ -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"; @@ -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; } /** @@ -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; } diff --git a/packages/activitypub/src/config.ts b/packages/activitypub/src/config.ts index cd0d9236..05e78cb2 100644 --- a/packages/activitypub/src/config.ts +++ b/packages/activitypub/src/config.ts @@ -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 { @@ -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` (`#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 /outbox`). When unset, that endpoint is disabled. This is the @@ -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; @@ -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; } @@ -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 = @@ -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, diff --git a/packages/activitypub/src/handler.ts b/packages/activitypub/src/handler.ts index 45a2634f..7ebabe93 100644 --- a/packages/activitypub/src/handler.ts +++ b/packages/activitypub/src/handler.ts @@ -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, @@ -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 } : {}), }; } @@ -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, { diff --git a/packages/activitypub/src/index.test.ts b/packages/activitypub/src/index.test.ts index 471f7609..39fe7f3d 100644 --- a/packages/activitypub/src/index.test.ts +++ b/packages/activitypub/src/index.test.ts @@ -1197,6 +1197,7 @@ function forwardedHeader( keyId: iris.keyId, privateKeyPem, userAgent: "test-ua/1.0", + ...(overrides.integrityKey ? { integrityKey: overrides.integrityKey } : {}), }; return JSON.stringify(config); } @@ -1917,3 +1918,225 @@ describe("delivery circuit breaker", () => { ); }); }); + +// --------------------------------------------------------------------------- +// FEP-8b32 object integrity proofs: the actor publishes an assertionMethod +// Multikey, and its own deliveries carry a DataIntegrityProof. +// --------------------------------------------------------------------------- + +describe("FEP-8b32 integrity proofs", () => { + let integrityKey: JsonWebKey; + + beforeAll(async () => { + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + integrityKey = (await crypto.subtle.exportKey( + "jwk", + pair.privateKey, + )) as JsonWebKey; + }); + + it("publishes the key as an assertionMethod Multikey on the actor", async () => { + const config = makeConfig({ integrityKey }); + const handler = createActivityPub(config); + const res = await handler(new Request(actorUrl(config)), testEnv, ctx); + const doc = (await res.json()) as Record; + + expect(doc["@context"]).toEqual( + expect.arrayContaining([ + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/data-integrity/v2", + ]), + ); + expect(doc.assertionMethod).toEqual([ + { + id: `${actorUrl(config)}#ed25519-key`, + type: "Multikey", + controller: actorUrl(config), + publicKeyMultibase: expect.stringMatching(/^z6Mk/), + }, + ]); + }); + + it("omits assertionMethod when no integrity key is configured", async () => { + const config = makeConfig(); + const handler = createActivityPub(config); + const res = await handler(new Request(actorUrl(config)), testEnv, ctx); + const doc = (await res.json()) as Record; + expect(doc.assertionMethod).toBeUndefined(); + }); + + it("fails at startup on a malformed integrity key", () => { + expect(() => + createActivityPub( + makeConfig({ integrityKey: { kty: "OKP", crv: "Ed25519" } }), + ), + ).toThrow(/integrityKey/); + }); + + it("proofs this actor's own deliveries but not activities it forwards", async () => { + const username = `bob-${crypto.randomUUID().slice(0, 8)}`; + const iris = deriveIris(BASE, username); + const stub = testEnv.ACTOR.get(testEnv.ACTOR.idFromName(iris.id)); + + await runInDurableObject(stub, async (instance, state) => { + const original = globalThis.fetch; + const bodies: Record[] = []; + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + bodies.push( + JSON.parse( + new TextDecoder().decode(init?.body as ArrayBufferView), + ) as Record, + ); + return new Response(null, { status: 202 }); + }) as unknown as typeof fetch; + const deliver = () => + instance.fetch( + new Request(`${iris.id}/__deliver`, { + headers: { + [INTERNAL_HEADERS.config]: forwardedHeader(username, { + integrityKey, + }), + }, + }), + ); + + try { + await deliver(); + const own = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${iris.id}/activities/1`, + type: "Create", + actor: iris.id, + object: { id: `${iris.id}/notes/1`, type: "Note", content: "hi" }, + }; + const forwarded = { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://remote.example/activities/9", + type: "Create", + actor: REMOTE, + object: { id: "https://remote.example/notes/9", type: "Note" }, + }; + for (const activity of [own, forwarded]) { + state.storage.sql.exec( + "INSERT INTO delivery (inbox, json, attempts, next_at) VALUES (?, ?, 0, 0)", + "https://peer.example/inbox", + JSON.stringify(activity), + ); + } + await deliver(); + + expect(bodies).toHaveLength(2); + const [ownSent, forwardedSent] = bodies; + expect(ownSent!.proof).toMatchObject({ + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + verificationMethod: `${iris.id}#ed25519-key`, + proofPurpose: "assertionMethod", + }); + expect(forwardedSent).toEqual(forwarded); + } finally { + globalThis.fetch = original; + } + }); + }); + + it("delivers unsigned rather than stalling when the stored key will not import", async () => { + const username = `bob-${crypto.randomUUID().slice(0, 8)}`; + const iris = deriveIris(BASE, username); + const stub = testEnv.ACTOR.get(testEnv.ACTOR.idFromName(iris.id)); + // Shape-plausible enough to reach the DO (the forwarded header bypasses + // `resolveConfig`), but Web Crypto refuses it. + const broken: JsonWebKey = { + kty: "OKP", + crv: "Ed25519", + x: "AAAA", + d: "AA", + }; + + await runInDurableObject(stub, async (instance, state) => { + const original = globalThis.fetch; + const bodies: Record[] = []; + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + bodies.push( + JSON.parse( + new TextDecoder().decode(init?.body as ArrayBufferView), + ) as Record, + ); + return new Response(null, { status: 202 }); + }) as unknown as typeof fetch; + const deliver = () => + instance.fetch( + new Request(`${iris.id}/__deliver`, { + headers: { + [INTERNAL_HEADERS.config]: forwardedHeader(username, { + integrityKey: broken, + }), + }, + }), + ); + + try { + await deliver(); + state.storage.sql.exec( + "INSERT INTO delivery (inbox, json, attempts, next_at) VALUES (?, ?, 0, 0)", + "https://peer.example/inbox", + JSON.stringify({ type: "Update", actor: iris.id }), + ); + const res = await deliver(); + + expect(res.status).toBeLessThan(500); + expect(bodies).toHaveLength(1); + expect(bodies[0]!.proof).toBeUndefined(); + expect( + state.storage.sql.exec("SELECT seq FROM delivery").toArray(), + ).toEqual([]); + } finally { + globalThis.fetch = original; + } + }); + }); + + it("stops signing once the key is removed from config", async () => { + const username = `bob-${crypto.randomUUID().slice(0, 8)}`; + const iris = deriveIris(BASE, username); + const stub = testEnv.ACTOR.get(testEnv.ACTOR.idFromName(iris.id)); + + await runInDurableObject(stub, async (instance, state) => { + const original = globalThis.fetch; + const bodies: Record[] = []; + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + bodies.push( + JSON.parse( + new TextDecoder().decode(init?.body as ArrayBufferView), + ) as Record, + ); + return new Response(null, { status: 202 }); + }) as unknown as typeof fetch; + const deliverWith = (header: string) => + instance.fetch( + new Request(`${iris.id}/__deliver`, { + headers: { [INTERNAL_HEADERS.config]: header }, + }), + ); + + try { + await deliverWith(forwardedHeader(username, { integrityKey })); + await deliverWith(forwardedHeader(username)); + state.storage.sql.exec( + "INSERT INTO delivery (inbox, json, attempts, next_at) VALUES (?, ?, 0, 0)", + "https://peer.example/inbox", + JSON.stringify({ type: "Update", actor: iris.id }), + ); + await deliverWith(forwardedHeader(username)); + + expect(bodies).toHaveLength(1); + expect(bodies[0]!.proof).toBeUndefined(); + } finally { + globalThis.fetch = original; + } + }); + }); +}); diff --git a/packages/activitypub/src/integrity.test.ts b/packages/activitypub/src/integrity.test.ts new file mode 100644 index 00000000..b6282268 --- /dev/null +++ b/packages/activitypub/src/integrity.test.ts @@ -0,0 +1,163 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import { + decodeMultikey, + verifyProof, + type JsonObject, +} from "@dwk/vc/data-integrity"; + +import { DATA_INTEGRITY_CONTEXT } from "./as2.js"; +import { + assertIntegrityKey, + base64urlToBytes, + buildAssertionMethod, + importIntegritySigner, + integrityKeyId, + signOwnActivity, + type IntegritySigner, +} from "./integrity.js"; + +const ACTOR = "https://social.example/users/bob"; + +let jwk: JsonWebKey; +let integrity: IntegritySigner; + +beforeAll(async () => { + const pair = (await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + jwk = (await crypto.subtle.exportKey("jwk", pair.privateKey)) as JsonWebKey; + integrity = await importIntegritySigner(ACTOR, jwk); +}); + +/** Verify `doc`'s proof against `ACTOR`'s published assertion method. */ +function verifyAsActor(doc: JsonObject) { + const method = buildAssertionMethod(ACTOR, jwk); + return verifyProof(doc, { + resolveVerificationMethod: (id) => + id === method.id + ? { + id, + type: "Multikey", + controller: method.controller as string, + publicKeyMultibase: method.publicKeyMultibase as string, + } + : undefined, + expectedController: ACTOR, + }); +} + +describe("assertIntegrityKey", () => { + it("accepts an Ed25519 private JWK", () => { + expect(() => assertIntegrityKey(jwk)).not.toThrow(); + }); + + it.each([ + [{ kty: "RSA" }, /Ed25519 JWK/], + [{ kty: "OKP", crv: "X25519", x: "AA", d: "AA" }, /Ed25519 JWK/], + [{ kty: "OKP", crv: "Ed25519", x: "AA" }, /missing `d`/], + [{ kty: "OKP", crv: "Ed25519", x: "AAAA", d: "AA" }, /32-byte/], + ])("rejects %j", (bad, message) => { + expect(() => assertIntegrityKey(bad as JsonWebKey)).toThrow(message); + }); + + it("rejects a seed that is not 32 bytes", () => { + expect(() => assertIntegrityKey({ ...jwk, d: "AAAA" })).toThrow( + /32-byte base64url Ed25519 seed/, + ); + }); +}); + +describe("buildAssertionMethod", () => { + it("publishes the JWK's public half as a FEP-521a Multikey", () => { + const method = buildAssertionMethod(ACTOR, jwk); + expect(method).toMatchObject({ + id: `${ACTOR}#ed25519-key`, + type: "Multikey", + controller: ACTOR, + }); + const { rawPublicKey } = decodeMultikey( + method.publicKeyMultibase as string, + ); + expect(rawPublicKey).toEqual(base64urlToBytes(jwk.x!)); + }); +}); + +describe("signOwnActivity", () => { + const now = () => Date.UTC(2026, 9, 28, 12, 0, 0); + const create = { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${ACTOR}/activities/1`, + type: "Create", + actor: ACTOR, + object: { + id: `${ACTOR}/notes/1`, + type: "Note", + attributedTo: ACTOR, + content: "Hello world", + }, + }; + + it("adds an eddsa-jcs-2022 proof that verifies against the actor's Multikey", async () => { + const signed = JSON.parse( + await signOwnActivity(JSON.stringify(create), integrity, now), + ) as JsonObject; + + expect(signed["@context"]).toEqual([ + "https://www.w3.org/ns/activitystreams", + DATA_INTEGRITY_CONTEXT, + ]); + expect(signed.proof).toEqual({ + "@context": signed["@context"], + type: "DataIntegrityProof", + cryptosuite: "eddsa-jcs-2022", + created: "2026-10-28T12:00:00Z", + verificationMethod: integrityKeyId(ACTOR), + proofPurpose: "assertionMethod", + proofValue: expect.stringMatching(/^z/) as unknown as string, + }); + expect(await verifyAsActor(signed)).toEqual({ + verified: true, + errors: [], + }); + }); + + it("produces a proof that fails once the activity is tampered with", async () => { + const signed = JSON.parse( + await signOwnActivity(JSON.stringify(create), integrity, now), + ) as JsonObject & { object: { content: string } }; + signed.object.content = "Goodbye world"; + expect((await verifyAsActor(signed)).verified).toBe(false); + }); + + it("accepts an embedded actor object", async () => { + const signed = JSON.parse( + await signOwnActivity( + JSON.stringify({ ...create, actor: { id: ACTOR, type: "Person" } }), + integrity, + now, + ), + ) as JsonObject; + expect(signed.proof).toBeDefined(); + }); + + it.each([ + [ + "another actor's activity", + { ...create, actor: "https://remote.example/users/alice" }, + ], + [ + "an already-proofed activity", + { ...create, proof: { type: "DataIntegrityProof" } }, + ], + ["a fragment-identified activity", { ...create, id: `${ACTOR}#accepts/1` }], + ])("leaves %s unchanged", async (_label, activity) => { + const json = JSON.stringify(activity); + expect(await signOwnActivity(json, integrity, now)).toBe(json); + }); + + it("leaves unparseable JSON unchanged", async () => { + expect(await signOwnActivity("{nope", integrity, now)).toBe("{nope"); + }); +}); diff --git a/packages/activitypub/src/integrity.ts b/packages/activitypub/src/integrity.ts new file mode 100644 index 00000000..06a21de5 --- /dev/null +++ b/packages/activitypub/src/integrity.ts @@ -0,0 +1,184 @@ +/** + * FEP-8b32 Object Integrity Proofs for this actor's outbound activities. + * + * When the deployment configures an Ed25519 `integrityKey`, the actor document + * publishes its public half as a FEP-521a `assertionMethod` `Multikey`, and + * every activity this actor authors is delivered with a top-level + * `DataIntegrityProof` (`eddsa-jcs-2022`), so a recipient can authenticate it + * independently of the HTTP signature — e.g. after it has been forwarded. + * Activities authored by anyone else (inbox forwarding, relayed objects) are + * never signed: a proof asserts authorship. + * + * The proof pipeline itself (JCS canonicalization, hashing, Ed25519 signing, + * Multikey encoding) is `@dwk/vc/data-integrity`'s, not reimplemented here. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md + */ + +import { + addProof, + encodeEd25519Multikey, + importSigner, + type JsonObject, + type Signer, +} from "@dwk/vc/data-integrity"; + +import { AS2_NS, DATA_INTEGRITY_CONTEXT, type JsonValue } from "./as2.js"; + +/** The verification-method id an actor's integrity proofs name. */ +export function integrityKeyId(actorId: string): string { + return `${actorId}#ed25519-key`; +} + +/** Decode unpadded base64url (a JWK member) to bytes. */ +export function base64urlToBytes(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** Byte length of a base64url JWK member, or -1 when absent or malformed. */ +function decodedLength(value: unknown): number { + if (typeof value !== "string") return -1; + try { + return base64urlToBytes(value).length; + } catch { + return -1; + } +} + +/** + * Validate a configured integrity key: an Ed25519 private JWK (`kty: "OKP"`, + * `crv: "Ed25519"`, a 32-byte `x`, and a 32-byte `d`). Throws a descriptive + * error otherwise, so a misconfigured key fails loudly at startup. Whether `d` + * matches `x` is only knowable by importing the key, which is async; the DO + * handles a key that still fails to import by delivering without a proof. + */ +export function assertIntegrityKey(jwk: JsonWebKey): void { + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") { + throw new Error( + '@dwk/activitypub: `integrityKey` must be an Ed25519 JWK (kty "OKP", crv "Ed25519")', + ); + } + if (typeof jwk.d !== "string" || jwk.d === "") { + throw new Error( + "@dwk/activitypub: `integrityKey` must be a private JWK (missing `d`)", + ); + } + if (decodedLength(jwk.x) !== 32) { + throw new Error( + "@dwk/activitypub: `integrityKey.x` must be a 32-byte base64url Ed25519 public key", + ); + } + if (decodedLength(jwk.d) !== 32) { + throw new Error( + "@dwk/activitypub: `integrityKey.d` must be a 32-byte base64url Ed25519 seed", + ); + } +} + +/** + * The FEP-521a `assertionMethod` entry publishing `jwk`'s public half as a + * `Multikey` controlled by `actorId`. + */ +export function buildAssertionMethod( + actorId: string, + jwk: JsonWebKey, +): Record { + return { + id: integrityKeyId(actorId), + type: "Multikey", + controller: actorId, + publicKeyMultibase: encodeEd25519Multikey(base64urlToBytes(jwk.x!)), + }; +} + +/** An imported integrity key, ready to sign this actor's activities. */ +export interface IntegritySigner { + readonly actorId: string; + readonly verificationMethod: string; + readonly signer: Signer; +} + +/** Import `jwk` as `actorId`'s integrity signer. */ +export async function importIntegritySigner( + actorId: string, + jwk: JsonWebKey, +): Promise { + return { + actorId, + verificationMethod: integrityKeyId(actorId), + signer: await importSigner(jwk), + }; +} + +/** The IRI of an activity's `actor`, whether a string or an `{ id }` object. */ +function actorOf(activity: Record): string | undefined { + const actor = activity.actor; + if (typeof actor === "string") return actor; + if (actor !== null && typeof actor === "object" && !Array.isArray(actor)) { + const id = (actor as Record).id; + if (typeof id === "string") return id; + } + return undefined; +} + +/** + * Add a FEP-8b32 integrity proof to `activityJson` if, and only if, it is + * authored by the signer's actor and does not already carry a proof. Anything + * else — another actor's activity being forwarded, an already-proofed one, an + * activity with a fragment `id` (FEP-8b32: such objects SHOULD NOT carry + * proofs), or unparseable JSON — is returned unchanged. + * + * `@context` gains the Data Integrity context (it defines the `proof` term); + * the proof copies the resulting `@context`, as the cryptosuite requires. + */ +export async function signOwnActivity( + activityJson: string, + integrity: IntegritySigner, + now: () => number = () => Date.now(), +): Promise { + let activity: unknown; + try { + activity = JSON.parse(activityJson); + } catch { + return activityJson; + } + if ( + activity === null || + typeof activity !== "object" || + Array.isArray(activity) + ) { + return activityJson; + } + const doc = activity as Record; + if ( + actorOf(doc) !== integrity.actorId || + doc.proof !== undefined || + (typeof doc.id === "string" && doc.id.includes("#")) + ) { + return activityJson; + } + const context = doc["@context"]; + const contexts: unknown[] = + context === undefined + ? [AS2_NS] + : Array.isArray(context) + ? [...context] + : [context]; + if (!contexts.includes(DATA_INTEGRITY_CONTEXT)) { + contexts.push(DATA_INTEGRITY_CONTEXT); + } + const secured = await addProof( + { ...doc, "@context": contexts } as JsonObject, + integrity.signer, + { + verificationMethod: integrity.verificationMethod, + created: new Date(now()), + }, + ); + return JSON.stringify(secured); +} diff --git a/packages/activitypub/src/log.ts b/packages/activitypub/src/log.ts index be14dd06..2ef8d87a 100644 --- a/packages/activitypub/src/log.ts +++ b/packages/activitypub/src/log.ts @@ -58,6 +58,12 @@ export const ActivityPubLogEvent = { CircuitOpened: "activitypub.delivery.circuit_opened", /** A host whose circuit had opened is reachable again. Field: `targetHost`. */ CircuitClosed: "activitypub.delivery.circuit_closed", + /** + * The configured FEP-8b32 `integrityKey` could not be imported, so this + * actor's deliveries go out without an integrity proof (they still carry the + * HTTP signature). Logged once per distinct key. Field: `errorName`. + */ + IntegrityKeyInvalid: "activitypub.integrity.key_invalid", /** * The default key resolver could not resolve an inbound signer's key — * surfaces *why*, since `SignatureRejected`'s `key_unresolved` reason alone diff --git a/packages/activitypub/src/object.ts b/packages/activitypub/src/object.ts index 2bd17d59..070e4d6c 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -56,6 +56,11 @@ import { } from "./delivery.js"; import { DEFAULT_USER_AGENT } from "./http.js"; import { signGetRequest } from "./signature.js"; +import { + importIntegritySigner, + signOwnActivity, + type IntegritySigner, +} from "./integrity.js"; import type { ActivityPubEnv } from "./config.js"; /** How long a seen activity `id` is remembered for dedup (7 days). */ @@ -209,6 +214,9 @@ function isSafeTarget(actor: string): boolean { export class ActivityPubObject extends DurableObject { readonly #sql: SqlStorage; #config: ForwardedConfig | null = null; + /** The imported FEP-8b32 signer, keyed by the persisted actor + JWK it came from. */ + #integrityCache: { key: string; signer: IntegritySigner | null } | null = + null; constructor(state: DurableObjectState, env: ActivityPubEnv) { super(state, env); @@ -3386,6 +3394,7 @@ export class ActivityPubObject extends DurableObject { */ async #processDeliveries(): Promise { const signer = this.#deliverySigner(); + const integrity = await this.#integritySigner(); const userAgent = this.#deliveryUserAgent(); const now = Date.now(); const due = this.#sql @@ -3441,9 +3450,14 @@ export class ActivityPubObject extends DurableObject { } const preference = this.#signaturePreference(targetHost); try { + // FEP-8b32: this actor's own activities carry an integrity proof; a + // forwarded/relayed one is left as-is (see `signOwnActivity`). + const body = integrity + ? await signOwnActivity(row.json, integrity) + : row.json; const result = await deliverActivity( row.inbox, - row.json, + body, signer, fetch, userAgent, @@ -4119,6 +4133,37 @@ export class ActivityPubObject extends DurableObject { }; } + /** + * The FEP-8b32 integrity signer for this actor's deliveries, or `null` when + * no integrity key is configured. Read from the persisted copy (the alarm + * can wake on a cold isolate) and imported once per distinct key. + */ + async #integritySigner(): Promise { + const raw = this.#kvGet("integrityKey"); + const actorId = this.#kvGet("integrityActor"); + if (!raw || !actorId) return null; + const cacheKey = `${actorId}\n${raw}`; + if (this.#integrityCache?.key !== cacheKey) { + let signer: IntegritySigner | null = null; + try { + signer = await importIntegritySigner( + actorId, + JSON.parse(raw) as JsonWebKey, + ); + } catch (error) { + // `resolveConfig` checks the key's shape, but a key Web Crypto still + // refuses (or a corrupt stored copy) must not stall every delivery, + // forwarded ones included: deliver unsigned and say why, once per + // distinct key (the failure is cached below). + this.#logDelivery(ActivityPubLogEvent.IntegrityKeyInvalid, { + errorName: error instanceof Error ? error.name : "unknown", + }); + } + this.#integrityCache = { key: cacheKey, signer }; + } + return this.#integrityCache.signer; + } + #deliverySigner(): { keyId: string; privateKeyPem: string } | null { const keyId = this.#kvGet("keyId"); const privateKeyPem = this.#kvGet("privateKeyPem"); @@ -4146,6 +4191,17 @@ export class ActivityPubObject extends DurableObject { this.#kvPut("keyId", config.keyId); } this.#kvPut("userAgent", config.userAgent); + // The FEP-8b32 integrity key signs from the alarm too. Cleared when the + // config drops it, so a retired key stops signing (its public half is no + // longer published, so its proofs could no longer verify anyway). + if (config.integrityKey) { + this.#kvPut("integrityKey", JSON.stringify(config.integrityKey)); + this.#kvPut("integrityActor", config.iris.id); + } else { + this.#sql.exec( + `DELETE FROM kv WHERE k IN ('integrityKey', 'integrityActor')`, + ); + } // Persist the retry policy too: an alarm can wake on a cold isolate where // `#config` is null, and the backoff must still honor the configured policy // rather than silently fall back to defaults. diff --git a/packages/activitypub/tsconfig.json b/packages/activitypub/tsconfig.json index b9337601..c2442437 100644 --- a/packages/activitypub/tsconfig.json +++ b/packages/activitypub/tsconfig.json @@ -15,6 +15,7 @@ "@dwk/calendar": ["../calendar/src/index.ts"], "@dwk/safe-fetch": ["../safe-fetch/src/index.ts"], "@dwk/http-signatures": ["../http-signatures/src/index.ts"], + "@dwk/vc/data-integrity": ["../vc/src/data-integrity.ts"], "@dwk/mcp": ["../mcp/src/index.ts"], // `@dwk/mcp`'s own source (path-mapped above) imports `@dwk/dpop` for its // auth bridge, and that import resolves under THIS program's paths too. diff --git a/packages/conformance-target/tsconfig.json b/packages/conformance-target/tsconfig.json index 1ab28ff4..a879dd58 100644 --- a/packages/conformance-target/tsconfig.json +++ b/packages/conformance-target/tsconfig.json @@ -15,6 +15,7 @@ "@dwk/remotestorage": ["../remotestorage/src/index.ts"], "@dwk/solid-pod": ["../solid-pod/src/index.ts"], "@dwk/vc": ["../vc/src/index.ts"], + "@dwk/vc/data-integrity": ["../vc/src/data-integrity.ts"], "@dwk/webauthn": ["../webauthn/src/index.ts"], "@dwk/webfinger": ["../webfinger/src/index.ts"], "@dwk/webmention": ["../webmention/src/index.ts"], diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 0695f703..49de498c 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -36,6 +36,7 @@ "@dwk/solid-pod": ["../solid-pod/src/index.ts"], "@dwk/store": ["../store/src/index.ts"], "@dwk/vc": ["../vc/src/index.ts"], + "@dwk/vc/data-integrity": ["../vc/src/data-integrity.ts"], "@dwk/wac": ["../wac/src/index.ts"], "@dwk/webauthn": ["../webauthn/src/index.ts"], "@dwk/webdav": ["../webdav/src/index.ts"], diff --git a/packages/vc/CLAUDE.md b/packages/vc/CLAUDE.md index f667cace..ec6e9c7f 100644 --- a/packages/vc/CLAUDE.md +++ b/packages/vc/CLAUDE.md @@ -29,3 +29,10 @@ multicodec encoding, and XSD datetime utilities. credential. Revocation flips a bit in the D1-backed status store. - **Authorization.** The `authorize` callback in config controls who can issue/ verify/revoke. This is caller-provided, not hardcoded. +- **`@dwk/vc/data-integrity` subpath.** `data-integrity.ts` (plus the + `encodeEd25519Multikey`/`decodeMultikey` re-exports) is also published as its + own entry point, so a consumer that only signs or verifies proofs + (`@dwk/activitypub`'s FEP-8b32 integrity proofs) skips the credential, + `did:web` and status-list endpoint code. Keep that module free of imports + beyond `datetime.ts`, `jcs.ts` and `multibase.ts`, like the + `@dwk/ldn/discovery` precedent. diff --git a/packages/vc/README.md b/packages/vc/README.md index a294be60..3dae8ee3 100644 --- a/packages/vc/README.md +++ b/packages/vc/README.md @@ -33,6 +33,13 @@ runtime. Signing/verification mirror `@dwk/dpop` and `@dwk/http-signatures`: asymmetric only, an explicit cryptosuite allow-list, and keys validated against the claimed suite. +The proof pipeline is also published on its own as the +**`@dwk/vc/data-integrity`** subpath (`importSigner`, `addProof`, +`verifyProof`, plus the `encodeEd25519Multikey`/`decodeMultikey` helpers), so a +consumer that only signs or verifies proofs, such as `@dwk/activitypub`'s +FEP-8b32 object integrity proofs, imports it without the credential, `did:web` +and status-list endpoint code. + ## Usage ```ts diff --git a/packages/vc/package.json b/packages/vc/package.json index 1059da96..db743cfe 100644 --- a/packages/vc/package.json +++ b/packages/vc/package.json @@ -30,6 +30,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./data-integrity": { + "types": "./dist/data-integrity.d.ts", + "import": "./dist/data-integrity.js" } }, "files": [ diff --git a/packages/vc/src/data-integrity.ts b/packages/vc/src/data-integrity.ts index 55e870ed..ff02a7af 100644 --- a/packages/vc/src/data-integrity.ts +++ b/packages/vc/src/data-integrity.ts @@ -17,6 +17,9 @@ * validated against the claimed cryptosuite. * * Pure aside from Web Crypto: plain-data in, plain-data out, no runtime bindings. + * Also published as the `@dwk/vc/data-integrity` subpath, so a consumer that + * only needs the proof pipeline (e.g. `@dwk/activitypub`'s FEP-8b32 object + * integrity proofs) imports it without the credential/`did:web` endpoint code. * * @see https://www.w3.org/TR/vc-di-eddsa/ * @see https://www.w3.org/TR/vc-di-ecdsa/ @@ -31,6 +34,10 @@ import { MULTIBASE_BASE58BTC, } from "./multibase.js"; +// The Multikey helpers a proof publisher needs to advertise its key, re-exported +// so `@dwk/vc/data-integrity` is self-contained. +export { decodeMultikey, encodeEd25519Multikey } from "./multibase.js"; + /** A JSON object with string keys — a credential, proof, or proof config. */ export type JsonObject = { [key: string]: JcsValue | undefined }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67eb44ff..e57af0ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@dwk/safe-fetch': specifier: workspace:* version: link:../safe-fetch + '@dwk/vc': + specifier: workspace:* + version: link:../vc '@dwk/webfinger': specifier: workspace:* version: link:../webfinger diff --git a/spec/packages/activitypub.md b/spec/packages/activitypub.md index c3b6b111..28b87469 100644 --- a/spec/packages/activitypub.md +++ b/spec/packages/activitypub.md @@ -117,6 +117,22 @@ large media; bodies **MUST** stream and **MUST NOT** be buffered in the DO. actor DO's SQLite (`sig_pref`) for 7 days; deliveries to it skip the RFC 9421 knock until the row expires, so a peer that later adds RFC 9421 support is rediscovered. Hosts are not recorded for any other outcome. +- **FEP-8b32 object integrity proofs** (outbound). With an `integrityKey` + (Ed25519 private JWK) configured, the actor document adds the Multikey and + Data Integrity contexts and a FEP-521a `assertionMethod` + (`#ed25519-key`, `type: "Multikey"`, `publicKeyMultibase`). The DO + adds a top-level `eddsa-jcs-2022` `DataIntegrityProof` + (`proofPurpose: "assertionMethod"`) to a delivery only when the activity's + `actor` is this actor, it has no `proof` yet, and its `id` is not a fragment + IRI (FEP-8b32: such objects SHOULD NOT carry proofs). The Data Integrity + context is appended to the activity's `@context` before signing. Forwarded + and relayed activities are delivered unchanged. The proof is applied at send + time and never stored, so each attempt (including every retry) is signed + afresh with its own `created`. Removing `integrityKey` from config stops + signing at the next forwarded request. A key that passes the startup shape + check but fails to import in the DO is logged once + (`activitypub.integrity.key_invalid`), and deliveries go out without a proof + instead of stalling. Inbound proof verification is not yet implemented. - Outbound delivery has a per-host **circuit breaker** (actor DO SQLite, `peer_circuit`). Five consecutive retryable failures to one host (network error, `5xx`, `408`, `429`), counted across all of its delivery rows, open