From 89702732b466b33784ed102095458922a9b12eb3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 17 Aug 2026 19:20:02 +0900 Subject: [PATCH 1/4] Normalise as:Public when it's Follow object --- packages/fedify/src/compat/public-audience.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/fedify/src/compat/public-audience.ts diff --git a/packages/fedify/src/compat/public-audience.ts b/packages/fedify/src/compat/public-audience.ts new file mode 100644 index 000000000..d826ea83d --- /dev/null +++ b/packages/fedify/src/compat/public-audience.ts @@ -0,0 +1,36 @@ +import { getLogger } from "@logtape/logtape"; +import { PUBLIC_COLLECTION } from "@fedify/vocab"; + +const logger = getLogger(["fedify", "compat", "public-audience"]); + +export function normalizePublicFollowObject( + jsonLd: unknown, +): unknown { + if (typeof jsonLd !== "object" || jsonLd === null) { + return jsonLd; + } + + try { + const record = jsonLd as Record; + if ( + record.type === "Follow" && + (record.object === "as:Public" || record.object === "Public") + ) { + const normalized = { + ...record, + object: PUBLIC_COLLECTION.href, + }; + + return normalized; + } + } catch (error) { + logger.debug( + "Failed to normalize public follow object; sending the activity as is.\n{error}", + { + error, + }, + ); + } + + return jsonLd; +} From c9fe3ea2faeeffbd77ef15de2e2234c968718835 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 18 Aug 2026 13:40:08 +0900 Subject: [PATCH 2/4] Normalise CURIE when sends activities --- packages/fedify/src/compat/mod.ts | 1 + packages/fedify/src/federation/middleware.ts | 2 ++ packages/fedify/src/sig/proof.ts | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/compat/mod.ts b/packages/fedify/src/compat/mod.ts index 7d447a70d..5a1a81fb2 100644 --- a/packages/fedify/src/compat/mod.ts +++ b/packages/fedify/src/compat/mod.ts @@ -1,2 +1,3 @@ export * from "./transformers.ts"; export * from "./types.ts"; +export * from "./public-audience.ts"; diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 00c113fd0..b39048c7e 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -48,6 +48,7 @@ import { } from "@opentelemetry/semantic-conventions"; import metadata from "../../deno.json" with { type: "json" }; import { getDefaultActivityTransformers } from "../compat/transformers.ts"; +import { normalizePublicFollowObject } from "../compat/public-audience.ts"; import type { ActivityTransformer } from "../compat/types.ts"; import { getNodeInfo, type GetNodeInfoOptions } from "../nodeinfo/client.ts"; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; @@ -1361,6 +1362,7 @@ export class FederationImpl ); } else { try { + jsonLd = normalizePublicFollowObject(jsonLd); jsonLd = await signJsonLd(jsonLd, rsaKey.privateKey, rsaKey.keyId, { contextLoader, tracerProvider: this.tracerProvider, diff --git a/packages/fedify/src/sig/proof.ts b/packages/fedify/src/sig/proof.ts index fe00938b5..69f5c38de 100644 --- a/packages/fedify/src/sig/proof.ts +++ b/packages/fedify/src/sig/proof.ts @@ -11,6 +11,7 @@ import { SpanStatusCode, trace, type TracerProvider } from "@opentelemetry/api"; import { encodeHex } from "byte-encodings/hex"; import serialize from "json-canon"; import metadata from "../../deno.json" with { type: "json" }; +import { normalizePublicFollowObject } from "../compat/public-audience.ts"; import { fetchKey, type FetchKeyResult, @@ -66,11 +67,12 @@ export async function createProof( throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name); } const objectWithoutProofs = object.clone({ proofs: [] }); - const compactMsg = await objectWithoutProofs.toJsonLd({ + let compactMsg = await objectWithoutProofs.toJsonLd({ format: "compact", contextLoader, context, }); + compactMsg = normalizePublicFollowObject(compactMsg); const msgCanon = serialize(compactMsg); const encoder = new TextEncoder(); const msgBytes = encoder.encode(msgCanon); From 45410896a7db4ad603488fc54f65365f7024af16 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 18 Aug 2026 14:02:32 +0900 Subject: [PATCH 3/4] Cover relay Follow signing paths Normalize compact relay Follow payloads before deciding whether to add an RSA Linked Data signature. This keeps Ed25519 proofs consistent with the JSON-LD sent on the wire. Add regressions for middleware delivery and Object Integrity Proof verification. https://github.com/fedify-dev/fedify/issues/998 Assisted-by: Codex:gpt-5.6-sol --- .../fedify/src/federation/middleware.test.ts | 31 +++++++++++++++++ packages/fedify/src/federation/middleware.ts | 2 +- packages/fedify/src/sig/proof.test.ts | 34 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index f72a18a04..7063019eb 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1691,6 +1691,7 @@ test("FederationImpl.sendActivity()", async (t) => { let verified: ("http" | "ld" | "proof")[] | null = null; let request: Request | null = null; + let receivedJson: unknown = null; fetchMock.post("https://example.com/inbox", async (cl) => { verified = []; request = cl.request!.clone() as Request; @@ -1699,6 +1700,7 @@ test("FederationImpl.sendActivity()", async (t) => { contextLoader: mockDocumentLoader, }; let json = await cl.request!.json(); + receivedJson = json; if (await verifyJsonLd(json, options)) verified.push("ld"); json = detachSignature(json); let activity = await verifyObject(vocab.Activity, json, options); @@ -1808,6 +1810,35 @@ test("FederationImpl.sendActivity()", async (t) => { ); }); + await t.step("normalizes a relay Follow object before sending", async () => { + const follow = new vocab.Follow({ + id: new URL("https://example.com/activities/follow-relay"), + actor: new URL("https://example.com/person2"), + object: vocab.PUBLIC_COLLECTION, + }); + const inboxes = { + "https://example.com/inbox": { + actorIds: ["https://example.com/recipient"], + sharedInbox: false, + }, + }; + + verified = null; + receivedJson = null; + await federation.sendActivity( + [{ privateKey: ed25519PrivateKey, keyId: ed25519Multikey.id! }], + inboxes, + follow, + { context }, + ); + + assertEquals( + (receivedJson as Record).object, + vocab.PUBLIC_COLLECTION.href, + ); + assertEquals(verified, ["proof"]); + }); + fetchMock.hardReset(); }); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index b39048c7e..5f23ac692 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1346,6 +1346,7 @@ export class FederationImpl format: "compact", contextLoader, }); + jsonLd = normalizePublicFollowObject(jsonLd); if (rsaKey == null) { logger.warn( "No supported key found to create a Linked Data signature for " + @@ -1362,7 +1363,6 @@ export class FederationImpl ); } else { try { - jsonLd = normalizePublicFollowObject(jsonLd); jsonLd = await signJsonLd(jsonLd, rsaKey.privateKey, rsaKey.keyId, { contextLoader, tracerProvider: this.tracerProvider, diff --git a/packages/fedify/src/sig/proof.test.ts b/packages/fedify/src/sig/proof.test.ts index f78a40092..246417dce 100644 --- a/packages/fedify/src/sig/proof.test.ts +++ b/packages/fedify/src/sig/proof.test.ts @@ -3,13 +3,16 @@ import { Create, type CryptographicKey, DataIntegrityProof, + Follow, Multikey, Note, Place, + PUBLIC_COLLECTION, } from "@fedify/vocab"; import { decodeMultibase, importMultibaseKey } from "@fedify/vocab-runtime"; import { assertEquals, assertInstanceOf, assertRejects } from "@std/assert"; import { decodeHex, encodeHex } from "byte-encodings/hex"; +import { normalizePublicFollowObject } from "../compat/public-audience.ts"; import { ed25519Multikey, ed25519PrivateKey, @@ -266,6 +269,37 @@ test("signObject()", async () => { ); }); +test("signObject() signs a normalized relay Follow object", async () => { + const follow = new Follow({ + id: new URL("https://example.com/activities/follow-relay"), + actor: new URL("https://example.com/person2"), + object: PUBLIC_COLLECTION, + }); + const signed = await signObject( + follow, + ed25519PrivateKey, + ed25519Multikey.id!, + { contextLoader: mockDocumentLoader }, + ); + const compact = await signed.toJsonLd({ + format: "compact", + contextLoader: mockDocumentLoader, + }); + const normalized = normalizePublicFollowObject(compact) as Record< + string, + unknown + >; + + assertEquals(normalized.object, PUBLIC_COLLECTION.href); + assertInstanceOf( + await verifyObject(Follow, normalized, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }), + Follow, + ); +}); + test("verifyProof()", async () => { const cache: Record = {}; const options: VerifyProofOptions = { From 2d294c7be2b5cd8f22366d24b12fe1e421e65f16 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 18 Aug 2026 17:02:19 +0900 Subject: [PATCH 4/4] Add comments --- packages/fedify/src/compat/public-audience.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/fedify/src/compat/public-audience.ts b/packages/fedify/src/compat/public-audience.ts index d826ea83d..4357bfad0 100644 --- a/packages/fedify/src/compat/public-audience.ts +++ b/packages/fedify/src/compat/public-audience.ts @@ -3,6 +3,14 @@ import { PUBLIC_COLLECTION } from "@fedify/vocab"; const logger = getLogger(["fedify", "compat", "public-audience"]); +/** + * Rewrites the compact `as:Public` or `Public` CURIE in the `object` field of a + * serialized Follow activity to the full ActivityStreams Public collection URI. + * + * Some ActivityPub implementations compare the field as a plain URL + * without applying JSON-LD expansion, causing them to reject public-addressed + * Follow activities that use a compact IRI. This helper works around that gap. + */ export function normalizePublicFollowObject( jsonLd: unknown, ): unknown {