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

Key pair injection

parent 59828a24
Loading
Loading
Loading
Loading
+13 −9
Original line number Diff line number Diff line
@@ -4,7 +4,6 @@ import {
  Accept,
  Activity,
  Create,
  CryptographicKey,
  Follow,
  Note,
  Person,
@@ -25,7 +24,7 @@ export const federation = new Federation<Deno.Kv>({
// `Actor` object (`Person` in this case) for a given actor URI.
// The actor dispatch is not only used for the actor URI, but also for
// the WebFinger resource:
federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => {
federation.setActorDispatcher("/users/{handle}", async (ctx, handle, key) => {
  const blog = await getBlog();
  if (blog == null) return null;
  else if (blog.handle !== handle) return null;
@@ -37,12 +36,17 @@ federation.setActorDispatcher("/users/{handle}", async (ctx, handle) => {
    url: new URL("/", ctx.request.url),
    outbox: ctx.getOutboxUri(handle),
    inbox: ctx.getInboxUri(handle),
    publicKey: new CryptographicKey({
      id: new URL(`${ctx.getActorUri(handle)}#main-key`),
      owner: ctx.getActorUri(handle),
      publicKey: blog.publicKey,
    }),
    publicKey: key,
  });
})
  .setKeyPairDispatcher(async (_ctxData, handle) => {
    const blog = await getBlog();
    if (blog == null) return null;
    else if (blog.handle !== handle) return null;
    return {
      publicKey: blog.publicKey,
      privateKey: blog.privateKey,
    };
  });

// Registers the outbox dispatcher, which is responsible for listing
@@ -107,7 +111,7 @@ federation.setInboxListeners("/users/{handle}/inbox")
    const recipient = await follow.getActor(ctx);
    if (!isActor(recipient)) return;
    await ctx.sendActivity(
      { keyId: new URL(`${actorUri}#main-key`), privateKey: blog.privateKey },
      { handle: blog.handle },
      recipient,
      new Accept({
        actor: actorUri,
@@ -115,7 +119,7 @@ federation.setInboxListeners("/users/{handle}/inbox")
      }),
    );
  })
  .on(Undo, async (ctx, undo) => {
  .on(Undo, (_ctx, undo) => {
    console.log({ undo });
  })
  .onError((e) => console.error(e));
+10 −0
Original line number Diff line number Diff line
import { Actor } from "../vocab/actor.ts";
import { CryptographicKey } from "../vocab/mod.ts";
import { Activity } from "../vocab/mod.ts";
import { Page } from "./collection.ts";
import { Context } from "./context.ts";
@@ -9,8 +10,17 @@ import { Context } from "./context.ts";
export type ActorDispatcher<TContextData> = (
  context: Context<TContextData>,
  handle: string,
  key: CryptographicKey | null,
) => Actor | null | Promise<Actor | null>;

/**
 * A callback that dispatches a key pair for an actor.
 */
export type ActorKeyPairDispatcher<TContextData> = (
  contextData: TContextData,
  handle: string,
) => CryptoKeyPair | null | Promise<CryptoKeyPair | null>;

/**
 * A callback that dispatches an outbox.
 */
+74 −35
Original line number Diff line number Diff line
import { validateCryptoKey } from "../httpsig/key.ts";
import { DocumentLoader } from "../runtime/docloader.ts";
import { Actor } from "../vocab/actor.ts";
import { Activity } from "../vocab/mod.ts";
import { ActorKeyPairDispatcher } from "./callback.ts";
import { OutboxMessage } from "./queue.ts";
import { Router, RouterError } from "./router.ts";
import { extractInboxes, sendActivity } from "./send.ts";
import { extractInboxes } from "./send.ts";

/**
 * A context for a request.
 */
export class Context<TContextData> {
  #kv: Deno.Kv;
  #router: Router;

export interface Context<TContextData> {
  /**
   * The request object.
   */
@@ -28,33 +27,75 @@ export class Context<TContextData> {
  readonly url: URL;

  /**
   * Create a new context.
   * @param kv The Deno KV object.
   * @param router The router used for the request.
   * @param request The request object.
   * @param data The user-defined data associated with the context.
   * @param treatHttps Whether to treat the request as HTTPS even if it's not.
   * The document loader for loading remote JSON-LD documents.
   */
  readonly documentLoader: DocumentLoader;

  /**
   * Builds the URI of an actor with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's URI.
   */
  getActorUri(handle: string): URL;

  /**
   * Builds the URI of an actor's outbox with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's outbox URI.
   */
  getOutboxUri(handle: string): URL;

  /**
   * Builds the URI of an actor's inbox with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's inbox URI.
   */
  getInboxUri(handle: string): URL;

  /**
   * Sends an activity to recipients' inboxes.
   * @param sender The sender's handle or sender's key pair.
   * @param recipients The recipients of the activity.
   * @param activity The activity to send.
   * @param options Options for sending the activity.
   */
  sendActivity(
    sender: { keyId: URL; privateKey: CryptoKey } | { handle: string },
    recipients: Actor | Actor[],
    activity: Activity,
    options?: { preferSharedInbox?: boolean },
  ): Promise<void>;
}

export class ContextImpl<TContextData> implements Context<TContextData> {
  #kv: Deno.Kv;
  #router: Router;
  #actorKeyPairDispatcher?: ActorKeyPairDispatcher<TContextData>;

  readonly request: Request;
  readonly data: TContextData;
  readonly url: URL;
  readonly documentLoader: DocumentLoader;

  constructor(
    kv: Deno.Kv,
    router: Router,
    request: Request,
    data: TContextData,
    documentLoader: DocumentLoader,
    actorKeyPairDispatcher?: ActorKeyPairDispatcher<TContextData>,
    treatHttps = false,
  ) {
    this.#kv = kv;
    this.#router = router;
    this.#actorKeyPairDispatcher = actorKeyPairDispatcher;
    this.request = request;
    this.data = data;
    this.documentLoader = documentLoader;
    this.url = new URL(request.url);
    if (treatHttps) this.url.protocol = "https:";
  }

  /**
   * Builds the URI of an actor with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's URI.
   */
  getActorUri(handle: string): URL {
    const path = this.#router.build("actor", { handle });
    if (path == null) {
@@ -63,11 +104,6 @@ export class Context<TContextData> {
    return new URL(path, this.url);
  }

  /**
   * Builds the URI of an actor's outbox with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's outbox URI.
   */
  getOutboxUri(handle: string): URL {
    const path = this.#router.build("outbox", { handle });
    if (path == null) {
@@ -76,11 +112,6 @@ export class Context<TContextData> {
    return new URL(path, this.url);
  }

  /**
   * Builds the URI of an actor's inbox with the given handle.
   * @param handle The actor's handle.
   * @returns The actor's inbox URI.
   */
  getInboxUri(handle: string): URL {
    const path = this.#router.build("inbox", { handle });
    if (path == null) {
@@ -89,15 +120,8 @@ export class Context<TContextData> {
    return new URL(path, this.url);
  }

  /**
   * Sends an activity to recipients' inboxes.
   * @param sender The sender's handle or sender's key pair.
   * @param recipients The recipients of the activity.
   * @param activity The activity to send.
   * @param options Options for sending the activity.
   */
  async sendActivity(
    sender: { keyId: URL; privateKey: CryptoKey },
    sender: { keyId: URL; privateKey: CryptoKey } | { handle: string },
    recipients: Actor | Actor[],
    activity: Activity,
    { preferSharedInbox }: { preferSharedInbox?: boolean } = {},
@@ -107,7 +131,22 @@ export class Context<TContextData> {
        id: new URL(`urn:uuid:${crypto.randomUUID()}`),
      });
    }
    const { keyId, privateKey } = sender;
    let keyId, privateKey;
    if ("handle" in sender) {
      if (this.#actorKeyPairDispatcher == null) {
        throw new Error("No actor key pair dispatcher registered.");
      }
      let keyPair = this.#actorKeyPairDispatcher(this.data, sender.handle);
      if (keyPair instanceof Promise) keyPair = await keyPair;
      if (keyPair == null) {
        throw new Error(`No key pair found for actor ${sender.handle}`);
      }
      keyId = new URL(`${this.getActorUri(sender.handle)}#main-key`);
      privateKey = keyPair.privateKey;
    } else {
      keyId = sender.keyId;
      privateKey = sender.privateKey;
    }
    validateCryptoKey(privateKey, "private");
    const inboxes = extractInboxes({
      recipients: Array.isArray(recipients) ? recipients : [recipients],
+33 −2
Original line number Diff line number Diff line
import { accepts } from "https://deno.land/std@0.217.0/http/mod.ts";
import {
  ActorDispatcher,
  ActorKeyPairDispatcher,
  InboxListener,
  OutboxCounter,
  OutboxCursor,
@@ -15,6 +16,7 @@ import {
  OrderedCollection,
  OrderedCollectionPage,
} from "../vocab/mod.ts";
import { CryptographicKey } from "../vocab/mod.ts";

function acceptsJsonLd(request: Request): boolean {
  const types = accepts(request);
@@ -26,11 +28,25 @@ function acceptsJsonLd(request: Request): boolean {
    types.includes("application/json");
}

export function getActorKey<TContextData>(
  context: Context<TContextData>,
  handle: string,
  keyPair?: CryptoKeyPair | null,
): CryptographicKey | null {
  if (keyPair == null) return null;
  return new CryptographicKey({
    id: new URL(`${context.getActorUri(handle)}#main-key`),
    owner: context.getActorUri(handle),
    publicKey: keyPair.publicKey,
  });
}

export interface ActorHandlerParameters<TContextData> {
  handle: string;
  context: Context<TContextData>;
  documentLoader: DocumentLoader;
  actorDispatcher?: ActorDispatcher<TContextData>;
  actorKeyPairDispatcher?: ActorKeyPairDispatcher<TContextData>;
  onNotFound(request: Request): Response | Promise<Response>;
  onNotAcceptable(request: Request): Response | Promise<Response>;
}
@@ -42,6 +58,7 @@ export async function handleActor<TContextData>(
    context,
    documentLoader,
    actorDispatcher,
    actorKeyPairDispatcher,
    onNotFound,
    onNotAcceptable,
  }: ActorHandlerParameters<TContextData>,
@@ -54,7 +71,13 @@ export async function handleActor<TContextData>(
    const response = onNotAcceptable(request);
    return response instanceof Promise ? await response : response;
  }
  const actor = await actorDispatcher(context, handle);
  const keyPair = actorKeyPairDispatcher?.(context.data, handle);
  const key = getActorKey(
    context,
    handle,
    keyPair instanceof Promise ? await keyPair : keyPair,
  );
  const actor = await actorDispatcher(context, handle, key);
  if (actor == null) {
    const response = onNotFound(request);
    return response instanceof Promise ? await response : response;
@@ -186,6 +209,7 @@ export interface InboxHandlerParameters<TContextData> {
  handle: string;
  context: Context<TContextData>;
  actorDispatcher?: ActorDispatcher<TContextData>;
  actorKeyPairDispatcher?: ActorKeyPairDispatcher<TContextData>;
  inboxListeners: Map<
    new (...args: unknown[]) => Activity,
    InboxListener<TContextData, Activity>
@@ -201,6 +225,7 @@ export async function handleInbox<TContextData>(
    handle,
    context,
    actorDispatcher,
    actorKeyPairDispatcher,
    inboxListeners,
    inboxErrorHandler,
    documentLoader,
@@ -211,7 +236,13 @@ export async function handleInbox<TContextData>(
    const response = onNotFound(request);
    return response instanceof Promise ? await response : response;
  } else {
    const promise = actorDispatcher(context, handle);
    const keyPair = actorKeyPairDispatcher?.(context.data, handle);
    const key = getActorKey(
      context,
      handle,
      keyPair instanceof Promise ? await keyPair : keyPair,
    );
    const promise = actorDispatcher(context, handle, key);
    const actor = promise instanceof Promise ? await promise : promise;
    if (actor == null) {
      const response = onNotFound(request);
+67 −20
Original line number Diff line number Diff line
@@ -7,12 +7,13 @@ import { Activity } from "../vocab/mod.ts";
import { handleWebFinger } from "../webfinger/handler.ts";
import {
  ActorDispatcher,
  ActorKeyPairDispatcher,
  InboxListener,
  OutboxCounter,
  OutboxCursor,
  OutboxDispatcher,
} from "./callback.ts";
import { Context } from "./context.ts";
import { ContextImpl } from "./context.ts";
import { handleActor, handleInbox, handleOutbox } from "./handler.ts";
import { OutboxMessage } from "./queue.ts";
import { Router, RouterError } from "./router.ts";
@@ -37,13 +38,8 @@ export interface FederationParameters {
export class Federation<TContextData> {
  #kv: Deno.Kv;
  #router: Router;
  #actorDispatcher?: ActorDispatcher<TContextData>;
  #outboxCallbacks?: {
    dispatcher: OutboxDispatcher<TContextData>;
    counter?: OutboxCounter<TContextData>;
    firstCursor?: OutboxCursor<TContextData>;
    lastCursor?: OutboxCursor<TContextData>;
  };
  #actorCallbacks?: ActorCallbacks<TContextData>;
  #outboxCallbacks?: OutboxCallbacks<TContextData>;
  #inboxListeners: Map<
    new (...args: unknown[]) => Activity,
    InboxListener<TContextData, Activity>
@@ -103,7 +99,7 @@ export class Federation<TContextData> {
  setActorDispatcher(
    path: string,
    dispatcher: ActorDispatcher<TContextData>,
  ): void {
  ): ActorCallbackSetters<TContextData> {
    if (this.#router.has("actor")) {
      throw new RouterError("Actor dispatcher already set.");
    }
@@ -113,7 +109,17 @@ export class Federation<TContextData> {
        "Path for actor dispatcher must have one variable: {handle}",
      );
    }
    this.#actorDispatcher = dispatcher;
    const callbacks: ActorCallbacks<TContextData> = { dispatcher };
    this.#actorCallbacks = callbacks;
    const setters: ActorCallbackSetters<TContextData> = {
      setKeyPairDispatcher: (
        dispatcher: ActorKeyPairDispatcher<TContextData>,
      ) => {
        callbacks.keyPairDispatcher = dispatcher;
        return setters;
      },
    };
    return setters;
  }

  /**
@@ -138,12 +144,7 @@ export class Federation<TContextData> {
        "Path for outbox dispatcher must have one variable: {handle}",
      );
    }
    const callbacks: {
      dispatcher: OutboxDispatcher<TContextData>;
      counter?: OutboxCounter<TContextData>;
      firstCursor?: OutboxCursor<TContextData>;
      lastCursor?: OutboxCursor<TContextData>;
    } = { dispatcher };
    const callbacks: OutboxCallbacks<TContextData> = { dispatcher };
    this.#outboxCallbacks = callbacks;
    const setters: OutboxCallbackSetters<TContextData> = {
      setCounter(counter: OutboxCounter<TContextData>) {
@@ -215,18 +216,20 @@ export class Federation<TContextData> {
      const response = onNotFound(request);
      return response instanceof Promise ? await response : response;
    }
    const context = new Context(
    const context = new ContextImpl(
      this.#kv,
      this.#router,
      request,
      contextData,
      this.#documentLoader,
      this.#actorCallbacks?.keyPairDispatcher,
      this.#treatHttps,
    );
    switch (route.name) {
      case "webfinger":
        return await handleWebFinger(request, {
          context,
          actorDispatcher: this.#actorDispatcher,
          actorDispatcher: this.#actorCallbacks?.dispatcher,
          onNotFound,
        });
      case "actor":
@@ -234,7 +237,8 @@ export class Federation<TContextData> {
          handle: route.values.handle,
          context,
          documentLoader: this.#documentLoader,
          actorDispatcher: this.#actorDispatcher,
          actorDispatcher: this.#actorCallbacks?.dispatcher,
          actorKeyPairDispatcher: this.#actorCallbacks?.keyPairDispatcher,
          onNotFound,
          onNotAcceptable,
        });
@@ -255,7 +259,8 @@ export class Federation<TContextData> {
          handle: route.values.handle,
          context,
          documentLoader: this.#documentLoader,
          actorDispatcher: this.#actorDispatcher,
          actorDispatcher: this.#actorCallbacks?.dispatcher,
          actorKeyPairDispatcher: this.#actorCallbacks?.keyPairDispatcher,
          inboxListeners: this.#inboxListeners,
          inboxErrorHandler: this.#inboxErrorHandler,
          onNotFound,
@@ -274,6 +279,45 @@ export interface FederationHandlerParameters<TContextData> {
  onNotAcceptable(request: Request): Response | Promise<Response>;
}

interface ActorCallbacks<TContextData> {
  dispatcher?: ActorDispatcher<TContextData>;
  keyPairDispatcher?: ActorKeyPairDispatcher<TContextData>;
}

/**
 * Additional settings for the actor dispatcher.
 *
 * ``` typescript
 * const federation = new Federation<void>({ ... });
 * federation.setActorDispatcher("/users/{handle}", async (ctx, handle, key) => {
 *   ...
 * })
 *   .setKeyPairDispatcher(async (ctxData, handle) => {
 *     ...
 *   });
 * ```
 */
export interface ActorCallbackSetters<TContextData> {
  /**
   * Sets the key pair dispatcher for actors.
   * @param dispatcher A callback that returns the key pair for an actor.
   * @returns The setters object so that settings can be chained.
   */
  setKeyPairDispatcher(
    dispatcher: ActorKeyPairDispatcher<TContextData>,
  ): ActorCallbackSetters<TContextData>;
}

interface OutboxCallbacks<TContextData> {
  dispatcher: OutboxDispatcher<TContextData>;
  counter?: OutboxCounter<TContextData>;
  firstCursor?: OutboxCursor<TContextData>;
  lastCursor?: OutboxCursor<TContextData>;
}

/**
 * Additional settings for the outbox dispatcher.
 */
export interface OutboxCallbackSetters<TContextData> {
  setCounter(
    counter: OutboxCounter<TContextData>,
@@ -288,6 +332,9 @@ export interface OutboxCallbackSetters<TContextData> {
  ): OutboxCallbackSetters<TContextData>;
}

/**
 * Registry for inbox listeners for different activity types.
 */
export interface InboxListenerSetter<TContextData> {
  on<TActivity extends Activity>(
    // deno-lint-ignore no-explicit-any
Loading