Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/fedify/src/compat/mod.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./transformers.ts";
export * from "./types.ts";
export * from "./public-audience.ts";
44 changes: 44 additions & 0 deletions packages/fedify/src/compat/public-audience.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getLogger } from "@logtape/logtape";
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 {
if (typeof jsonLd !== "object" || jsonLd === null) {
return jsonLd;
}

try {
const record = jsonLd as Record<string, unknown>;
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;
}
31 changes: 31 additions & 0 deletions packages/fedify/src/federation/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, unknown>).object,
vocab.PUBLIC_COLLECTION.href,
);
assertEquals(verified, ["proof"]);
});

fetchMock.hardReset();
});

Expand Down
2 changes: 2 additions & 0 deletions packages/fedify/src/federation/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1345,6 +1346,7 @@ export class FederationImpl<TContextData>
format: "compact",
contextLoader,
});
jsonLd = normalizePublicFollowObject(jsonLd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve pre-existing proofs during normalization

When the supplied public Follow already has an Object Integrity Proof created over the compact as:Public representation—for example, a persisted activity signed by an earlier Fedify release—the preceding proof check skips re-signing, but this line changes the signed payload before delivery. Receivers then hash the full-URI representation and reject the existing proof, so normalization must not mutate bytes covered by a retained proof without replacing that proof.

AGENTS.md reference: AGENTS.md:L189-L189

Useful? React with 👍 / 👎.

if (rsaKey == null) {
logger.warn(
"No supported key found to create a Linked Data signature for " +
Expand Down
34 changes: 34 additions & 0 deletions packages/fedify/src/sig/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, CryptographicKey | Multikey | null> = {};
const options: VerifyProofOptions = {
Expand Down
4 changes: 3 additions & 1 deletion packages/fedify/src/sig/proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the serialized object aligned with its proof

When callers use the public signObject() API directly for a Follow targeting PUBLIC_COLLECTION, this hashes the normalized full URI but returns an object that still serializes object as as:Public; consequently, sending that serialization or passing it directly to verifyObject() fails proof verification. The added test masks the mismatch by explicitly calling normalizePublicFollowObject() after serializing, so the signed object itself is not independently usable as promised.

AGENTS.md reference: AGENTS.md:L189-L189

Useful? React with 👍 / 👎.

const msgCanon = serialize(compactMsg);
const encoder = new TextEncoder();
const msgBytes = encoder.encode(msgCanon);
Expand Down
Loading