From f4829001d7c404b8fc0e44725bad002313e31350 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:39:02 +0000 Subject: [PATCH 1/3] feat(activitypub,vc): outbound FEP-8b32 object integrity proofs Add an optional `integrityKey` (Ed25519 private JWK). The actor document publishes it as a FEP-521a assertionMethod Multikey, and the DO adds an eddsa-jcs-2022 DataIntegrityProof to each activity this actor authors at delivery time; forwarded activities are left untouched. The proof pipeline comes from @dwk/vc, now also exported as the `@dwk/vc/data-integrity` subpath. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LRGjmHE99WESN5HBERxSj --- .../activitypub-fep-8b32-integrity-proofs.md | 5 + .changeset/vc-data-integrity-subpath.md | 5 + packages/activitypub/README.md | 20 ++ packages/activitypub/package.json | 1 + packages/activitypub/src/as2.ts | 19 ++ packages/activitypub/src/config.ts | 18 ++ packages/activitypub/src/handler.ts | 10 + packages/activitypub/src/index.test.ts | 167 +++++++++++++++++ packages/activitypub/src/integrity.test.ts | 160 ++++++++++++++++ packages/activitypub/src/integrity.ts | 173 ++++++++++++++++++ packages/activitypub/src/object.ts | 48 ++++- packages/activitypub/tsconfig.json | 1 + packages/vc/README.md | 7 + packages/vc/package.json | 4 + packages/vc/src/data-integrity.ts | 7 + pnpm-lock.yaml | 3 + spec/packages/activitypub.md | 13 ++ 17 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 .changeset/activitypub-fep-8b32-integrity-proofs.md create mode 100644 .changeset/vc-data-integrity-subpath.md create mode 100644 packages/activitypub/src/integrity.test.ts create mode 100644 packages/activitypub/src/integrity.ts diff --git a/.changeset/activitypub-fep-8b32-integrity-proofs.md b/.changeset/activitypub-fep-8b32-integrity-proofs.md new file mode 100644 index 00000000..53166be0 --- /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, and a malformed key fails at startup. 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/README.md b/packages/activitypub/README.md index 6dd558aa..3ec0bd73 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 publishToken: env.AP_PUBLISH_TOKEN, // optional: enables POST /outbox software: { name: "anglesite", version: "1.2.3" }, // NodeInfo }); @@ -94,6 +95,25 @@ 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, stored as a secret) 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. + +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..3578c7ff 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,169 @@ 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("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..f2cbd28c --- /dev/null +++ b/packages/activitypub/src/integrity.test.ts @@ -0,0 +1,160 @@ +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, + 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); + }); +}); + +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, + ); + const x = btoa(String.fromCharCode(...rawPublicKey)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + expect(x).toBe(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..8642be1f --- /dev/null +++ b/packages/activitypub/src/integrity.ts @@ -0,0 +1,173 @@ +/** + * 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. */ +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; +} + +/** + * Validate a configured integrity key: an Ed25519 private JWK (`kty: "OKP"`, + * `crv: "Ed25519"`, a 32-byte `x`, and `d`). Throws a descriptive error + * otherwise, so a misconfigured key fails loudly at startup. + */ +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`)", + ); + } + let x: Uint8Array; + try { + x = base64urlToBytes(typeof jwk.x === "string" ? jwk.x : ""); + } catch { + x = new Uint8Array(); + } + if (x.length !== 32) { + throw new Error( + "@dwk/activitypub: `integrityKey.x` must be a 32-byte base64url Ed25519 public key", + ); + } +} + +/** + * 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/object.ts b/packages/activitypub/src/object.ts index 2bd17d59..786b03ff 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,8 @@ 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; constructor(state: DurableObjectState, env: ActivityPubEnv) { super(state, env); @@ -3386,6 +3393,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 +3449,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 +4132,28 @@ 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) { + this.#integrityCache = { + key: cacheKey, + signer: await importIntegritySigner( + actorId, + JSON.parse(raw) as JsonWebKey, + ), + }; + } + return this.#integrityCache.signer; + } + #deliverySigner(): { keyId: string; privateKeyPem: string } | null { const keyId = this.#kvGet("keyId"); const privateKeyPem = this.#kvGet("privateKeyPem"); @@ -4146,6 +4181,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/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..655f6b76 100644 --- a/spec/packages/activitypub.md +++ b/spec/packages/activitypub.md @@ -117,6 +117,19 @@ 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, so it is never stored. Removing `integrityKey` from config stops + signing at the next forwarded request. 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 From f7da72bd58ec443d7afed1b1887124f38bc94a5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:45:53 +0000 Subject: [PATCH 2/3] fix(activitypub,vc): address FEP-8b32 review feedback Deliver unsigned (logging activitypub.integrity.key_invalid once per key) when the stored integrity key fails to import, instead of letting the alarm throw and stall every delivery. Also check the seed length at startup, reuse base64urlToBytes in tests, note per-attempt re-signing in the spec, document the secret-binding requirement, and describe the new subpath and FEP-8b32 constraints in the per-package CLAUDE.md files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LRGjmHE99WESN5HBERxSj --- .../activitypub-fep-8b32-integrity-proofs.md | 2 +- packages/activitypub/CLAUDE.md | 8 +++ packages/activitypub/README.md | 11 +++- packages/activitypub/src/index.test.ts | 56 +++++++++++++++++++ packages/activitypub/src/integrity.test.ts | 13 +++-- packages/activitypub/src/integrity.ts | 31 ++++++---- packages/activitypub/src/log.ts | 6 ++ packages/activitypub/src/object.ts | 22 ++++++-- packages/vc/CLAUDE.md | 7 +++ spec/packages/activitypub.md | 9 ++- 10 files changed, 137 insertions(+), 28 deletions(-) diff --git a/.changeset/activitypub-fep-8b32-integrity-proofs.md b/.changeset/activitypub-fep-8b32-integrity-proofs.md index 53166be0..22720552 100644 --- a/.changeset/activitypub-fep-8b32-integrity-proofs.md +++ b/.changeset/activitypub-fep-8b32-integrity-proofs.md @@ -2,4 +2,4 @@ "@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, and a malformed key fails at startup. +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/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 3ec0bd73..e65d0dde 100644 --- a/packages/activitypub/README.md +++ b/packages/activitypub/README.md @@ -53,7 +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 + 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 }); @@ -98,8 +98,8 @@ 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, stored as a secret) and the actor: +`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 @@ -109,6 +109,11 @@ Set `integrityKey` to an Ed25519 private JWK (for example the output of 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 diff --git a/packages/activitypub/src/index.test.ts b/packages/activitypub/src/index.test.ts index 3578c7ff..39fe7f3d 100644 --- a/packages/activitypub/src/index.test.ts +++ b/packages/activitypub/src/index.test.ts @@ -2043,6 +2043,62 @@ describe("FEP-8b32 integrity proofs", () => { }); }); + 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); diff --git a/packages/activitypub/src/integrity.test.ts b/packages/activitypub/src/integrity.test.ts index f2cbd28c..b6282268 100644 --- a/packages/activitypub/src/integrity.test.ts +++ b/packages/activitypub/src/integrity.test.ts @@ -9,6 +9,7 @@ import { import { DATA_INTEGRITY_CONTEXT } from "./as2.js"; import { assertIntegrityKey, + base64urlToBytes, buildAssertionMethod, importIntegritySigner, integrityKeyId, @@ -60,6 +61,12 @@ describe("assertIntegrityKey", () => { ])("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", () => { @@ -73,11 +80,7 @@ describe("buildAssertionMethod", () => { const { rawPublicKey } = decodeMultikey( method.publicKeyMultibase as string, ); - const x = btoa(String.fromCharCode(...rawPublicKey)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - expect(x).toBe(jwk.x); + expect(rawPublicKey).toEqual(base64urlToBytes(jwk.x!)); }); }); diff --git a/packages/activitypub/src/integrity.ts b/packages/activitypub/src/integrity.ts index 8642be1f..06a21de5 100644 --- a/packages/activitypub/src/integrity.ts +++ b/packages/activitypub/src/integrity.ts @@ -32,7 +32,7 @@ export function integrityKeyId(actorId: string): string { } /** Decode unpadded base64url (a JWK member) to bytes. */ -function base64urlToBytes(value: string): Uint8Array { +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); @@ -40,10 +40,22 @@ function base64urlToBytes(value: string): Uint8Array { 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 `d`). Throws a descriptive error - * otherwise, so a misconfigured key fails loudly at startup. + * `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") { @@ -56,17 +68,16 @@ export function assertIntegrityKey(jwk: JsonWebKey): void { "@dwk/activitypub: `integrityKey` must be a private JWK (missing `d`)", ); } - let x: Uint8Array; - try { - x = base64urlToBytes(typeof jwk.x === "string" ? jwk.x : ""); - } catch { - x = new Uint8Array(); - } - if (x.length !== 32) { + 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", + ); + } } /** 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 786b03ff..070e4d6c 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -215,7 +215,8 @@ 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; + #integrityCache: { key: string; signer: IntegritySigner | null } | null = + null; constructor(state: DurableObjectState, env: ActivityPubEnv) { super(state, env); @@ -4143,13 +4144,22 @@ export class ActivityPubObject extends DurableObject { if (!raw || !actorId) return null; const cacheKey = `${actorId}\n${raw}`; if (this.#integrityCache?.key !== cacheKey) { - this.#integrityCache = { - key: cacheKey, - signer: await importIntegritySigner( + 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; } 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/spec/packages/activitypub.md b/spec/packages/activitypub.md index 655f6b76..28b87469 100644 --- a/spec/packages/activitypub.md +++ b/spec/packages/activitypub.md @@ -127,9 +127,12 @@ large media; bodies **MUST** stream and **MUST NOT** be buffered in the DO. 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, so it is never stored. Removing `integrityKey` from config stops - signing at the next forwarded request. Inbound proof verification is not yet - implemented. + 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 From aae273d2c6d38599b0ac859bfd0eb5f2cc590931 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:51:15 +0000 Subject: [PATCH 3/3] fix(server,conformance-target): map @dwk/vc/data-integrity for source typecheck Both packages path-map @dwk/activitypub to its source, so they must also map the new `@dwk/vc/data-integrity` subpath it imports; CI typechecks before building, when vc's dist does not exist yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LRGjmHE99WESN5HBERxSj --- packages/conformance-target/tsconfig.json | 1 + packages/server/tsconfig.json | 1 + 2 files changed, 2 insertions(+) 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"],