Unverified Commit 89278560 authored by Hong Minhee's avatar Hong Minhee
Browse files

Verify Object Integrity Proofs

parent 43162fc2
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -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.
+30 −17
Original line number Diff line number Diff line
@@ -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,
@@ -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);
@@ -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);
@@ -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;
@@ -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,
+76 −17
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ import {
} from "../runtime/docloader.ts";
import { mockDocumentLoader } from "../testing/docloader.ts";
import {
  ed25519Multikey,
  ed25519PrivateKey,
  ed25519PublicKey,
  rsaPrivateKey2,
@@ -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();
@@ -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>({
@@ -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, []);
@@ -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();
@@ -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 () => {
+1 −1
Original line number Diff line number Diff line
@@ -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 });
+1 −1
Original line number Diff line number Diff line
@@ -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