Loading CHANGES.md +16 −0 Original line number Diff line number Diff line Loading @@ -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]] Loading Loading @@ -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/ Loading src/federation/handler.ts +40 −6 Original line number Diff line number Diff line Loading @@ -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, Loading Loading @@ -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>; Loading @@ -328,7 +333,7 @@ export async function handleInbox<TContextData>( handle, context, kv, kvPrefix, kvPrefixes, queue, actorDispatcher, inboxListeners, Loading Loading @@ -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 { Loading @@ -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 }); Loading @@ -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) { Loading src/federation/middleware.ts +9 −1 Original line number Diff line number Diff line Loading @@ -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"); Loading Loading @@ -328,6 +335,7 @@ export class Federation<TContextData> { ...({ activityIdempotence: ["_fedify", "activityIdempotence"], remoteDocument: ["_fedify", "remoteDocument"], publicKeyCache: ["_fedify", "publicKey"], } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; Loading Loading @@ -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, Loading src/sig/http.test.ts +30 −14 Original line number Diff line number Diff line Loading @@ -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/", { Loading @@ -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!", Loading @@ -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( Loading src/sig/http.ts +37 −8 Original line number Diff line number Diff line Loading @@ -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. Loading Loading @@ -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; } /** Loading @@ -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) { Loading Loading @@ -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") Loading Loading @@ -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 Loading
CHANGES.md +16 −0 Original line number Diff line number Diff line Loading @@ -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]] Loading Loading @@ -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/ Loading
src/federation/handler.ts +40 −6 Original line number Diff line number Diff line Loading @@ -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, Loading Loading @@ -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>; Loading @@ -328,7 +333,7 @@ export async function handleInbox<TContextData>( handle, context, kv, kvPrefix, kvPrefixes, queue, actorDispatcher, inboxListeners, Loading Loading @@ -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 { Loading @@ -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 }); Loading @@ -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) { Loading
src/federation/middleware.ts +9 −1 Original line number Diff line number Diff line Loading @@ -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"); Loading Loading @@ -328,6 +335,7 @@ export class Federation<TContextData> { ...({ activityIdempotence: ["_fedify", "activityIdempotence"], remoteDocument: ["_fedify", "remoteDocument"], publicKeyCache: ["_fedify", "publicKey"], } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; Loading Loading @@ -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, Loading
src/sig/http.test.ts +30 −14 Original line number Diff line number Diff line Loading @@ -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/", { Loading @@ -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!", Loading @@ -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( Loading
src/sig/http.ts +37 −8 Original line number Diff line number Diff line Loading @@ -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. Loading Loading @@ -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; } /** Loading @@ -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) { Loading Loading @@ -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") Loading Loading @@ -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