From 6653c127c942c2d9094fdf7a88a35ab64cad1fc2 Mon Sep 17 00:00:00 2001 From: OH TAEJUN Date: Fri, 2 Oct 2026 21:45:32 +0900 Subject: [PATCH] Use the key cache in RequestContext.getSignedKey() Assisted-by: ChatGPT:gpt-5.6-luna --- .../fedify/src/federation/middleware.test.ts | 152 ++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 13 ++ 2 files changed, 165 insertions(+) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 4ff0de460..8b25fb5a7 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -8,6 +8,7 @@ import { RouterError } from "@fedify/uri-template"; import * as vocab from "@fedify/vocab"; import { Create, + CryptographicKey, getTypeId, lookupObject, Note, @@ -1731,6 +1732,157 @@ test({ assertStrictEquals(clone.federation, ctx.federation); }); + await t.step( + "getSignedKey() uses the key cache", + async () => { + const kv = new MemoryKvStore(); + const keyId = rsaPublicKey2.id!; + let keyFetches = 0; + + const documentLoader = async (url: string) => { + if (url === keyId.href) keyFetches++; + return await mockDocumentLoader(url); + }; + + const federation = createFederation({ + kv, + documentLoaderFactory: () => documentLoader, + contextLoaderFactory: () => mockDocumentLoader, + publicKeyTtl: { days: 7 }, + }); + + const request1 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey2, + keyId, + ); + const request2 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey2, + keyId, + ); + + const ctx1 = federation.createContext(request1, 1); + const ctx2 = federation.createContext(request2, 2); + + assertEquals(await ctx1.getSignedKey(), rsaPublicKey2); + assertEquals(keyFetches, 1); + + assertEquals(await ctx2.getSignedKey(), rsaPublicKey2); + assertEquals(keyFetches, 1); + }, + ); + + await t.step( + "getSignedKey() caches unavailable keys", + async () => { + const kv = new MemoryKvStore(); + const keyId = new URL("https://example.com/keys/missing"); + let keyFetches = 0; + + const documentLoader = async (url: string) => { + if (url === keyId.href) keyFetches++; + return await mockDocumentLoader(url); + }; + + const federation = createFederation({ + kv, + documentLoaderFactory: () => documentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + + const request1 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey2, + keyId, + ); + const request2 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey2, + keyId, + ); + + const ctx1 = federation.createContext(request1, 1); + const ctx2 = federation.createContext(request2, 2); + + assertEquals(await ctx1.getSignedKey(), null); + assertEquals(keyFetches, 1); + + assertEquals(await ctx2.getSignedKey(), null); + assertEquals(keyFetches, 1); + }, + ); + + await t.step( + "getSignedKey() refetches a key that no longer verifies", + async () => { + const kv = new MemoryKvStore(); + const keyId = rsaPublicKey2.id!; + let keyFetches = 0; + + const rotatedPublicKey = new CryptographicKey({ + id: keyId, + publicKey: rsaPublicKey3.publicKey, + }); + + const documentLoader = async (url: string) => { + if (url !== keyId.href) return await mockDocumentLoader(url); + + keyFetches++; + + if (keyFetches === 1) { + return await mockDocumentLoader(url); + } + + return { + contextUrl: null, + documentUrl: url, + document: await rotatedPublicKey.toJsonLd({ + contextLoader: mockDocumentLoader, + }), + }; + }; + + const federation = createFederation({ + kv, + documentLoaderFactory: () => documentLoader, + contextLoaderFactory: () => mockDocumentLoader, + }); + + const request1 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey2, + keyId, + ); + const request2 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey3, + keyId, + ); + + const request3 = await signRequest( + new Request("https://example.com/"), + rsaPrivateKey3, + keyId, + ); + + const ctx1 = federation.createContext(request1, 1); + const ctx2 = federation.createContext(request2, 2); + + const ctx3 = federation.createContext(request3, 3); + + assertEquals(await ctx1.getSignedKey(), rsaPublicKey2); + assertEquals(keyFetches, 1); + + assertEquals(await ctx2.getSignedKey(), rotatedPublicKey); + assertEquals(keyFetches, 2); + + assertEquals(await ctx3.getSignedKey(), rotatedPublicKey); + // The refreshed key was cached, so it is not fetched again: + assertEquals(keyFetches, 2); + }, + ); + fetchMock.hardReset(); }, }); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 414c0c3a0..3216f9e24 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -6211,6 +6211,18 @@ class RequestContextImpl extends ContextImpl options: GetSignedKeyOptions = {}, ): Promise { if (this.#signedKey != null) return this.#signedKey; + + const keyCache = new KvKeyCache( + this.federation.kv, + this.federation.kvPrefixes.publicKey, + { + documentLoader: options.documentLoader ?? this.documentLoader, + contextLoader: options.contextLoader ?? this.contextLoader, + tracerProvider: options.tracerProvider ?? this.tracerProvider, + keyTtl: this.federation.publicKeyTtl, + }, + ); + return this.#signedKey = await verifyRequest(this.request, { ...this, contextLoader: options.contextLoader ?? this.contextLoader, @@ -6219,6 +6231,7 @@ class RequestContextImpl extends ContextImpl maxSignatures: this.federation.maxHttpSignatures, meterProvider: this.meterProvider, tracerProvider: options.tracerProvider ?? this.tracerProvider, + keyCache, }); }