Loading CHANGES.md +5 −0 Original line number Diff line number Diff line Loading @@ -85,6 +85,11 @@ To be released. - Implemented Object Integrity Proofs. [[FEP-8b32], [#54]] - If there are any Ed25519 key pairs, the `Context.sendActivity()` and `Federation.sendActivity()` methods now make Object Integrity Proofs for the activity to be sent. - If the incoming activity has Object Integrity Proofs, the inbox listener now verifies them and ignores HTTP Signatures (if any). - Added `signObject()` function. - Added `SignObjectOptions` interface. - Added `createProof()` function. Loading federation/handler.ts +30 −17 Original line number Diff line number Diff line Loading @@ -2,10 +2,12 @@ import { getLogger } from "@logtape/logtape"; import { accepts } from "@std/http/negotiation"; import { verifyRequest } from "../sig/http.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { verifyObject } from "../sig/proof.ts"; import type { DocumentLoader } from "../runtime/docloader.ts"; import type { Recipient } from "../vocab/actor.ts"; import { Activity, type CryptographicKey, Link, Object, OrderedCollection, Loading Loading @@ -342,21 +344,9 @@ export async function handleInbox<TContextData>( return await onNotFound(request); } } const key = await verifyRequest(request, { ...context, timeWindow: signatureTimeWindow, }); if (key == null) { logger.error("Failed to verify the request signature.", { handle }); const response = new Response("Failed to verify the request signature.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } let json: unknown; try { json = await request.json(); json = await request.clone().json(); } catch (error) { logger.error("Failed to parse JSON:\n{error}", { handle, error }); await inboxErrorHandler?.(context, error); Loading @@ -365,9 +355,9 @@ export async function handleInbox<TContextData>( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } let activity: Activity; let activity: Activity | null; try { activity = await Activity.fromJsonLd(json, context); activity = await verifyObject(Activity, json, context); } catch (error) { logger.error("Failed to parse activity:\n{error}", { handle, json, error }); await inboxErrorHandler?.(context, error); Loading @@ -376,6 +366,23 @@ export async function handleInbox<TContextData>( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } let httpSigKey: CryptographicKey | null = null; if (activity == null) { const key = await verifyRequest(request, { ...context, timeWindow: signatureTimeWindow, }); if (key == null) { logger.error("Failed to verify the request signature.", { handle }); const response = new Response("Failed to verify the request signature.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } httpSigKey = key; activity = await Activity.fromJsonLd(json, context); } const cacheKey = activity.id == null ? null : [...kvPrefix, activity.id.href] satisfies KvKey; Loading Loading @@ -403,10 +410,16 @@ export async function handleInbox<TContextData>( }); return response; } if (!await doesActorOwnKey(activity, key, context)) { if ( httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, context) ) { logger.error( "The signer ({keyId}) and the actor ({actorId}) do not match.", { activity: json, keyId: key.id?.href, actorId: activity.actorId.href }, { activity: json, keyId: httpSigKey.id?.href, actorId: activity.actorId.href, }, ); const response = new Response("The signer and the actor do not match.", { status: 401, Loading federation/middleware.test.ts +76 −17 Original line number Diff line number Diff line Loading @@ -14,6 +14,7 @@ import { } from "../runtime/docloader.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { ed25519Multikey, ed25519PrivateKey, ed25519PublicKey, rsaPrivateKey2, Loading @@ -26,6 +27,7 @@ import type { Context } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; import { Federation } from "./middleware.ts"; import { RouterError } from "./router.ts"; import { signObject } from "../sig/proof.ts"; Deno.test("Federation.createContext()", async (t) => { const kv = new MemoryKvStore(); Loading Loading @@ -414,6 +416,21 @@ Deno.test("Federation.setInboxListeners()", async (t) => { ); }); mf.mock("GET@/person2", async () => { return new Response( await Deno.readFile( join( dirname(import.meta.dirname!), "testing", "fixtures", "example.com", "person2", ), ), { headers: { "Content-Type": "application/activity+json" } }, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = new Federation<void>({ Loading Loading @@ -451,8 +468,23 @@ Deno.test("Federation.setInboxListeners()", async (t) => { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey!, }]); const options = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; const activity = () => new Create({ id: new URL("https://example.com/activities/" + crypto.randomUUID()), actor: new URL("https://example.com/person2"), }); response = await federation.fetch( new Request("https://example.com/inbox", { method: "POST" }), new Request( "https://example.com/inbox", { method: "POST", body: JSON.stringify(await activity().toJsonLd(options)), }, ), { contextData: undefined }, ); assertEquals(inbox, []); Loading @@ -466,30 +498,32 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(response.status, 404); response = await federation.fetch( new Request("https://example.com/users/john/inbox", { method: "POST" }), new Request( "https://example.com/users/john/inbox", { method: "POST", body: JSON.stringify(await activity().toJsonLd(options)), }, ), { contextData: undefined }, ); assertEquals(inbox, []); assertEquals(response.status, 401); const activity = new Create({ actor: new URL("https://example.com/person"), }); // Personal inbox + HTTP Signatures (RSA) let request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await activity.toJsonLd({ contextLoader: mockDocumentLoader }), ), body: JSON.stringify(await activity().toJsonLd(options)), }); request = await signRequest( request, rsaPrivateKey2, new URL("https://example.com/key2"), rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1], activity); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); Loading @@ -499,28 +533,53 @@ Deno.test("Federation.setInboxListeners()", async (t) => { ["https://example.com/person", "https://example.com/users/john#main-key"], ]); // Shared inbox + HTTP Signatures (RSA) inbox.shift(); request = new Request("https://example.com/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await activity.toJsonLd({ contextLoader: mockDocumentLoader }), ), body: JSON.stringify(await activity().toJsonLd(options)), }); request = await signRequest( request, rsaPrivateKey2, new URL("https://example.com/key2"), rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1], activity); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, []); // Object Integrity Proofs (Ed25519) inbox.shift(); request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await (await signObject( activity(), ed25519PrivateKey, ed25519Multikey.id!, options, )).toJsonLd(options), ), }); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, [ ["https://example.com/person", "https://example.com/users/john#main-key"], ]); }); await t.step("onError()", async () => { Loading federation/send.test.ts +1 −1 Original line number Diff line number Diff line Loading @@ -159,7 +159,7 @@ Deno.test("sendActivity()", async (t) => { }; const reqClone = req.clone(); const jsonLd = await req.json(); const verifiedObject = await verifyObject(jsonLd, options); const verifiedObject = await verifyObject(Activity, jsonLd, options); if (verifiedObject != null) { verified = "proof"; return new Response("", { status: 202 }); Loading sig/proof.test.ts +1 −1 Original line number Diff line number Diff line Loading @@ -309,7 +309,7 @@ Deno.test("verifyObject()", async () => { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; const create = await verifyObject({ const create = await verifyObject(Create, { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", Loading Loading
CHANGES.md +5 −0 Original line number Diff line number Diff line Loading @@ -85,6 +85,11 @@ To be released. - Implemented Object Integrity Proofs. [[FEP-8b32], [#54]] - If there are any Ed25519 key pairs, the `Context.sendActivity()` and `Federation.sendActivity()` methods now make Object Integrity Proofs for the activity to be sent. - If the incoming activity has Object Integrity Proofs, the inbox listener now verifies them and ignores HTTP Signatures (if any). - Added `signObject()` function. - Added `SignObjectOptions` interface. - Added `createProof()` function. Loading
federation/handler.ts +30 −17 Original line number Diff line number Diff line Loading @@ -2,10 +2,12 @@ import { getLogger } from "@logtape/logtape"; import { accepts } from "@std/http/negotiation"; import { verifyRequest } from "../sig/http.ts"; import { doesActorOwnKey } from "../sig/owner.ts"; import { verifyObject } from "../sig/proof.ts"; import type { DocumentLoader } from "../runtime/docloader.ts"; import type { Recipient } from "../vocab/actor.ts"; import { Activity, type CryptographicKey, Link, Object, OrderedCollection, Loading Loading @@ -342,21 +344,9 @@ export async function handleInbox<TContextData>( return await onNotFound(request); } } const key = await verifyRequest(request, { ...context, timeWindow: signatureTimeWindow, }); if (key == null) { logger.error("Failed to verify the request signature.", { handle }); const response = new Response("Failed to verify the request signature.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } let json: unknown; try { json = await request.json(); json = await request.clone().json(); } catch (error) { logger.error("Failed to parse JSON:\n{error}", { handle, error }); await inboxErrorHandler?.(context, error); Loading @@ -365,9 +355,9 @@ export async function handleInbox<TContextData>( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } let activity: Activity; let activity: Activity | null; try { activity = await Activity.fromJsonLd(json, context); activity = await verifyObject(Activity, json, context); } catch (error) { logger.error("Failed to parse activity:\n{error}", { handle, json, error }); await inboxErrorHandler?.(context, error); Loading @@ -376,6 +366,23 @@ export async function handleInbox<TContextData>( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } let httpSigKey: CryptographicKey | null = null; if (activity == null) { const key = await verifyRequest(request, { ...context, timeWindow: signatureTimeWindow, }); if (key == null) { logger.error("Failed to verify the request signature.", { handle }); const response = new Response("Failed to verify the request signature.", { status: 401, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); return response; } httpSigKey = key; activity = await Activity.fromJsonLd(json, context); } const cacheKey = activity.id == null ? null : [...kvPrefix, activity.id.href] satisfies KvKey; Loading Loading @@ -403,10 +410,16 @@ export async function handleInbox<TContextData>( }); return response; } if (!await doesActorOwnKey(activity, key, context)) { if ( httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, context) ) { logger.error( "The signer ({keyId}) and the actor ({actorId}) do not match.", { activity: json, keyId: key.id?.href, actorId: activity.actorId.href }, { activity: json, keyId: httpSigKey.id?.href, actorId: activity.actorId.href, }, ); const response = new Response("The signer and the actor do not match.", { status: 401, Loading
federation/middleware.test.ts +76 −17 Original line number Diff line number Diff line Loading @@ -14,6 +14,7 @@ import { } from "../runtime/docloader.ts"; import { mockDocumentLoader } from "../testing/docloader.ts"; import { ed25519Multikey, ed25519PrivateKey, ed25519PublicKey, rsaPrivateKey2, Loading @@ -26,6 +27,7 @@ import type { Context } from "./context.ts"; import { MemoryKvStore } from "./kv.ts"; import { Federation } from "./middleware.ts"; import { RouterError } from "./router.ts"; import { signObject } from "../sig/proof.ts"; Deno.test("Federation.createContext()", async (t) => { const kv = new MemoryKvStore(); Loading Loading @@ -414,6 +416,21 @@ Deno.test("Federation.setInboxListeners()", async (t) => { ); }); mf.mock("GET@/person2", async () => { return new Response( await Deno.readFile( join( dirname(import.meta.dirname!), "testing", "fixtures", "example.com", "person2", ), ), { headers: { "Content-Type": "application/activity+json" } }, ); }); await t.step("on()", async () => { const authenticatedRequests: [string, string][] = []; const federation = new Federation<void>({ Loading Loading @@ -451,8 +468,23 @@ Deno.test("Federation.setInboxListeners()", async (t) => { privateKey: rsaPrivateKey2, publicKey: rsaPublicKey2.publicKey!, }]); const options = { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; const activity = () => new Create({ id: new URL("https://example.com/activities/" + crypto.randomUUID()), actor: new URL("https://example.com/person2"), }); response = await federation.fetch( new Request("https://example.com/inbox", { method: "POST" }), new Request( "https://example.com/inbox", { method: "POST", body: JSON.stringify(await activity().toJsonLd(options)), }, ), { contextData: undefined }, ); assertEquals(inbox, []); Loading @@ -466,30 +498,32 @@ Deno.test("Federation.setInboxListeners()", async (t) => { assertEquals(response.status, 404); response = await federation.fetch( new Request("https://example.com/users/john/inbox", { method: "POST" }), new Request( "https://example.com/users/john/inbox", { method: "POST", body: JSON.stringify(await activity().toJsonLd(options)), }, ), { contextData: undefined }, ); assertEquals(inbox, []); assertEquals(response.status, 401); const activity = new Create({ actor: new URL("https://example.com/person"), }); // Personal inbox + HTTP Signatures (RSA) let request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await activity.toJsonLd({ contextLoader: mockDocumentLoader }), ), body: JSON.stringify(await activity().toJsonLd(options)), }); request = await signRequest( request, rsaPrivateKey2, new URL("https://example.com/key2"), rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1], activity); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); Loading @@ -499,28 +533,53 @@ Deno.test("Federation.setInboxListeners()", async (t) => { ["https://example.com/person", "https://example.com/users/john#main-key"], ]); // Shared inbox + HTTP Signatures (RSA) inbox.shift(); request = new Request("https://example.com/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await activity.toJsonLd({ contextLoader: mockDocumentLoader }), ), body: JSON.stringify(await activity().toJsonLd(options)), }); request = await signRequest( request, rsaPrivateKey2, new URL("https://example.com/key2"), rsaPrivateKey3, new URL("https://example.com/person2#key3"), ); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1], activity); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, []); // Object Integrity Proofs (Ed25519) inbox.shift(); request = new Request("https://example.com/users/john/inbox", { method: "POST", headers: { "Content-Type": "application/activity+json" }, body: JSON.stringify( await (await signObject( activity(), ed25519PrivateKey, ed25519Multikey.id!, options, )).toJsonLd(options), ), }); response = await federation.fetch(request, { contextData: undefined }); assertEquals(inbox.length, 1); assertEquals(inbox[0][1].actorId, new URL("https://example.com/person2")); assertEquals(response.status, 202); while (authenticatedRequests.length > 0) authenticatedRequests.shift(); assertEquals(authenticatedRequests, []); await inbox[0][0].documentLoader("https://example.com/person"); assertEquals(authenticatedRequests, [ ["https://example.com/person", "https://example.com/users/john#main-key"], ]); }); await t.step("onError()", async () => { Loading
federation/send.test.ts +1 −1 Original line number Diff line number Diff line Loading @@ -159,7 +159,7 @@ Deno.test("sendActivity()", async (t) => { }; const reqClone = req.clone(); const jsonLd = await req.json(); const verifiedObject = await verifyObject(jsonLd, options); const verifiedObject = await verifyObject(Activity, jsonLd, options); if (verifiedObject != null) { verified = "proof"; return new Response("", { status: 202 }); Loading
sig/proof.test.ts +1 −1 Original line number Diff line number Diff line Loading @@ -309,7 +309,7 @@ Deno.test("verifyObject()", async () => { documentLoader: mockDocumentLoader, contextLoader: mockDocumentLoader, }; const create = await verifyObject({ const create = await verifyObject(Create, { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/data-integrity/v1", Loading