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

Cache public keys when verifying signatures

parent fd946135
Loading
Loading
Loading
Loading
+16 −0
Original line number Diff line number Diff line
@@ -73,6 +73,21 @@ To be released.
     -  The type of `ActorKeyPairsDispatcher<TContextData>`'s first parameter
        became `Context` (was `TContextData`).

 -  During verifying HTTP Signatures and Object Integrity Proofs, once fetched
    public keys are now cached.  [[#107]]

     -  The `verifyRequest()` function now caches the fetched public keys
        when the `keyCache` option is provided.
     -  The `verifyProof()` function now caches the fetched public keys
        when the `keyCache` option is provided.
     -  The `verifyObject()` function now caches the fetched public keys
        when the `keyCache` option is provided.
     -  Added `KeyCache` interface.
     -  Added `VerifyRequestOptions.keyCache` property.
     -  Added `VerifyProofOptions.keyCache` property.
     -  Added `VerifyObjectOptions.keyCache` property.
     -  Added `FederationKvPrefixes.publicKeyCache` property.

 -  The built-in document loaders now recognize JSON-LD context provided in
    an HTTP `Link` header. [[#6]]

@@ -141,6 +156,7 @@ To be released.
[#92]: https://github.com/dahlia/fedify/pull/92
[#104]: https://github.com/dahlia/fedify/issues/104
[#105]: https://github.com/dahlia/fedify/issues/105
[#107]: https://github.com/dahlia/fedify/issues/107
[Astro]: https://astro.build/


+40 −6
Original line number Diff line number Diff line
@@ -2,13 +2,15 @@ import { getLogger } from "@logtape/logtape";
import { accepts } from "@std/http/negotiation";
import type { DocumentLoader } from "../runtime/docloader.ts";
import { verifyRequest } from "../sig/http.ts";
import type { KeyCache } from "../sig/key.ts";
import { doesActorOwnKey } from "../sig/owner.ts";
import { verifyObject } from "../sig/proof.ts";
import type { Recipient } from "../vocab/actor.ts";
import {
  Activity,
  type CryptographicKey,
  CryptographicKey,
  Link,
  Multikey,
  Object,
  OrderedCollection,
  OrderedCollectionPage,
@@ -313,7 +315,10 @@ export interface InboxHandlerParameters<TContextData> {
  handle: string | null;
  context: RequestContext<TContextData>;
  kv: KvStore;
  kvPrefix: KvKey;
  kvPrefixes: {
    activityIdempotence: KvKey;
    publicKeyCache: KvKey;
  };
  queue?: MessageQueue;
  actorDispatcher?: ActorDispatcher<TContextData>;
  inboxListeners?: InboxListenerSet<TContextData>;
@@ -328,7 +333,7 @@ export async function handleInbox<TContextData>(
    handle,
    context,
    kv,
    kvPrefix,
    kvPrefixes,
    queue,
    actorDispatcher,
    inboxListeners,
@@ -366,9 +371,36 @@ export async function handleInbox<TContextData>(
      headers: { "Content-Type": "text/plain; charset=utf-8" },
    });
  }
  const keyCache: KeyCache = {
    async get(keyId: URL) {
      const serialized = await kv.get([
        ...kvPrefixes.publicKeyCache,
        keyId.href,
      ]);
      if (serialized == null) return null;
      let object: Object;
      try {
        object = await Object.fromJsonLd(serialized, context);
      } catch {
        return null;
      }
      if (object instanceof CryptographicKey || object instanceof Multikey) {
        return object;
      }
      return null;
    },
    async set(keyId: URL, key: CryptographicKey | Multikey) {
      const serialized = await key.toJsonLd(context);
      await kv.set([...kvPrefixes.publicKeyCache, keyId.href], serialized);
    },
  };
  let activity: Activity | null;
  try {
    activity = await verifyObject(Activity, json, context);
    activity = await verifyObject(Activity, json, {
      contextLoader: context.contextLoader,
      documentLoader: context.documentLoader,
      keyCache,
    });
  } catch (error) {
    logger.error("Failed to parse activity:\n{error}", { handle, json, error });
    try {
@@ -387,8 +419,10 @@ export async function handleInbox<TContextData>(
  let httpSigKey: CryptographicKey | null = null;
  if (activity == null) {
    const key = await verifyRequest(request, {
      ...context,
      contextLoader: context.contextLoader,
      documentLoader: context.documentLoader,
      timeWindow: signatureTimeWindow,
      keyCache,
    });
    if (key == null) {
      logger.error("Failed to verify the request signature.", { handle });
@@ -403,7 +437,7 @@ export async function handleInbox<TContextData>(
  }
  const cacheKey = activity.id == null
    ? null
    : [...kvPrefix, activity.id.href] satisfies KvKey;
    : [...kvPrefixes.activityIdempotence, activity.id.href] satisfies KvKey;
  if (cacheKey != null) {
    const cached = await kv.get(cacheKey);
    if (cached === true) {
+9 −1
Original line number Diff line number Diff line
@@ -244,6 +244,13 @@ export interface FederationKvPrefixes {
   * `["_fedify", "remoteDocument"]` by default.
   */
  remoteDocument: KvKey;

  /**
   * The key prefix used for caching public keys.  `["_fedify", "publicKey"]`
   * by default.
   * @since 0.12.0
   */
  publicKeyCache: KvKey;
}

const invokedByCreateFederation = Symbol("invokedByCreateFederation");
@@ -328,6 +335,7 @@ export class Federation<TContextData> {
      ...({
        activityIdempotence: ["_fedify", "activityIdempotence"],
        remoteDocument: ["_fedify", "remoteDocument"],
        publicKeyCache: ["_fedify", "publicKey"],
      } satisfies FederationKvPrefixes),
      ...(options.kvPrefixes ?? {}),
    };
@@ -1851,7 +1859,7 @@ export class Federation<TContextData> {
          handle: route.values.handle ?? null,
          context,
          kv: this.#kv,
          kvPrefix: this.#kvPrefixes.activityIdempotence,
          kvPrefixes: this.#kvPrefixes,
          actorDispatcher: this.#actorCallbacks?.dispatcher,
          inboxListeners: this.#inboxListeners,
          inboxErrorHandler: this.#inboxErrorHandler,
+30 −14
Original line number Diff line number Diff line
@@ -6,7 +6,13 @@ import {
  rsaPublicKey2,
} from "../testing/keys.ts";
import { test } from "../testing/mod.ts";
import { signRequest, verifyRequest } from "./http.ts";
import type { CryptographicKey, Multikey } from "../vocab/vocab.ts";
import {
  signRequest,
  verifyRequest,
  type VerifyRequestOptions,
} from "./http.ts";
import type { KeyCache } from "./key.ts";

test("signRequest()", async () => {
  const request = new Request("https://example.com/", {
@@ -31,7 +37,7 @@ test("signRequest()", async () => {
  );
});

test("verify()", async () => {
test("verifyRequest()", async () => {
  const request = new Request("https://example.com/", {
    method: "POST",
    body: "Hello, world!",
@@ -51,18 +57,28 @@ test("verify()", async () => {
        '+M6DrfkfQuUBw=="', // cSpell: enable
    },
  });
  const key = await verifyRequest(
    request,
    {
  const cache: Record<string, CryptographicKey | Multikey> = {};
  const options: VerifyRequestOptions = {
    contextLoader: mockDocumentLoader,
    documentLoader: mockDocumentLoader,
    currentTime: Temporal.Instant.from("2024-03-05T07:49:44Z"),
    keyCache: {
      get(keyId) {
        return Promise.resolve(cache[keyId.href] ?? null);
      },
  );
  assertEquals(
    key,
    rsaPublicKey1,
  );
      set(keyId, key) {
        cache[keyId.href] = key;
        return Promise.resolve();
      },
    } satisfies KeyCache,
  };
  let key = await verifyRequest(request, options);
  assertEquals(key, rsaPublicKey1);
  assertEquals(cache, { "https://example.com/key": rsaPublicKey1 });
  cache["https://example.com/key"] = rsaPublicKey2;
  key = await verifyRequest(request, options);
  assertEquals(key, rsaPublicKey1);
  assertEquals(cache, { "https://example.com/key": rsaPublicKey1 });

  assertEquals(
    await verifyRequest(
+37 −8
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ import { equals } from "@std/bytes";
import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
import type { DocumentLoader } from "../runtime/docloader.ts";
import { CryptographicKey } from "../vocab/vocab.ts";
import { fetchKey, validateCryptoKey } from "./key.ts";
import { fetchKey, type KeyCache, validateCryptoKey } from "./key.ts";

/**
 * Signs a request using the given private key.
@@ -94,6 +94,12 @@ export interface VerifyRequestOptions {
   * useful for testing.
   */
  currentTime?: Temporal.Instant;

  /**
   * The key cache to use for caching public keys.
   * @since 0.12.0
   */
  keyCache?: KeyCache;
}

/**
@@ -111,10 +117,11 @@ export interface VerifyRequestOptions {
 */
export async function verifyRequest(
  request: Request,
  { documentLoader, contextLoader, timeWindow, currentTime }:
  { documentLoader, contextLoader, timeWindow, currentTime, keyCache }:
    VerifyRequestOptions = {},
): Promise<CryptographicKey | null> {
  const logger = getLogger(["fedify", "sig", "http"]);
  const originalRequest = request;
  request = request.clone();
  const dateHeader = request.headers.get("Date");
  if (dateHeader == null) {
@@ -225,11 +232,13 @@ export async function verifyRequest(
    return null;
  }
  const { keyId, headers, signature } = sigValues;
  const key = await fetchKey(new URL(keyId), CryptographicKey, {
  const keyResult = await fetchKey(new URL(keyId), CryptographicKey, {
    documentLoader,
    contextLoader,
    keyCache,
  });
  if (key == null) return null;
  if (keyResult == null) return null;
  const { key, cached } = keyResult;
  const headerNames = headers.split(/\s+/g);
  if (
    !headerNames.includes("(request-target)") || !headerNames.includes("date")
@@ -266,11 +275,31 @@ export async function verifyRequest(
    new TextEncoder().encode(message),
  );
  if (!verified) {
    if (cached) {
      logger.debug(
        "Failed to verify with the cached key {keyId}; signature {signature} " +
          "is invalid.  Retrying with the freshly fetched key...",
        { keyId, signature, message },
      );
      return await verifyRequest(
        originalRequest,
        {
          documentLoader,
          contextLoader,
          timeWindow,
          currentTime,
          keyCache: {
            get: () => Promise.resolve(null),
            set: async (keyId, key) => await keyCache?.set(keyId, key),
          },
        },
      );
    }
    logger.debug(
      "Failed to verify; signature {signature} is invalid.  " +
        "Check if the key is correct or if the signed message is correct.  " +
        "The message to sign is:\n{message}",
      { signature, message },
      "Failed to verify with the fetched key {keyId}; signature {signature} " +
        "is invalid.  Check if the key is correct or if the signed message " +
        "is correct.  The message to sign is:\n{message}",
      { keyId, signature, message },
    );
    return null;
  }
Loading