diff --git a/CHANGES.md b/CHANGES.md index 8d4e68889..c7adc38d9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,25 @@ To be released. ### @fedify/fedify + - Changed cached actor public keys and remembered per-origin HTTP Message + Signatures specs to expire, so a `KvStore` that never sees an explicit + clear no longer accumulates entries for actors and origins that have + stopped federating. Keys expire after 30 days and specs after 90 days + by default, and both windows are configurable through the new + `FederationOptions.publicKeyTtl` and + `FederationOptions.httpMessageSignaturesSpecTtl` options. + [[#1017], [#1027] by Heewon Chae\] + + - Shortening a window trades storage for remote requests: an expired + key has to be refetched before the next signature verification, and + an expired spec has to be relearned by double-knocking on the next + delivery. Refetching fails while the peer is unavailable, so a very + short window makes verification depend on the peer being reachable. + - Entries written by earlier versions of Fedify have no expiry and are + left as they are; they gain one the next time they are written. See + the new *Clearing legacy cache entries* section of the + [key–value store guide] to clear them proactively instead of waiting. + - Fixed `verifyProof()` so Ed25519 JCS proofs authenticate every received proof option except `proofValue`, including `expires`, `domain`, `challenge`, `nonce`, and extension options. It now rejects expired or @@ -108,6 +127,7 @@ To be released. `esnext.temporal` lib reference. [[#823], [#925]] +[key–value store guide]: https://fedify.dev/manual/kv [FEP-ef61]: https://w3id.org/fep/ef61 [FEP-8b32]: https://w3id.org/fep/8b32 [FEP-fe34]: https://w3id.org/fep/fe34 @@ -133,6 +153,8 @@ To be released. [#930]: https://github.com/fedify-dev/fedify/issues/930 [#934]: https://github.com/fedify-dev/fedify/pull/934 [#968]: https://github.com/fedify-dev/fedify/pull/968 +[#1017]: https://github.com/fedify-dev/fedify/issues/1017 +[#1027]: https://github.com/fedify-dev/fedify/pull/1027 ### @fedify/astro diff --git a/changes.d/fedify/kv-cache-ttl.md b/changes.d/fedify/kv-cache-ttl.md new file mode 100644 index 000000000..f3caf4e8b --- /dev/null +++ b/changes.d/fedify/kv-cache-ttl.md @@ -0,0 +1,25 @@ +--- +links: + '#1017': https://github.com/fedify-dev/fedify/issues/1017 + '#1027': https://github.com/fedify-dev/fedify/pull/1027 +--- + - Changed cached actor public keys and remembered per-origin HTTP Message + Signatures specs to expire, so a `KvStore` that never sees an explicit + clear no longer accumulates entries for actors and origins that have + stopped federating. Keys expire after 30 days and specs after 90 days + by default, and both windows are configurable through the new + `FederationOptions.publicKeyTtl` and + `FederationOptions.httpMessageSignaturesSpecTtl` options. + [[#1017], [#1027] by Heewon Chae] + + - Shortening a window trades storage for remote requests: an expired + key has to be refetched before the next signature verification, and + an expired spec has to be relearned by double-knocking on the next + delivery. Refetching fails while the peer is unavailable, so a very + short window makes verification depend on the peer being reachable. + - Entries written by earlier versions of Fedify have no expiry and are + left as they are; they gain one the next time they are written. See + the new *Clearing legacy cache entries* section of the + [key–value store guide] to clear them proactively instead of waiting. + +[key–value store guide]: https://fedify.dev/manual/kv diff --git a/docs/manual/federation.md b/docs/manual/federation.md index 42bb3c558..6426e5596 100644 --- a/docs/manual/federation.md +++ b/docs/manual/federation.md @@ -96,6 +96,49 @@ that the `Federation` object uses: [double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions +### `publicKeyTtl` + +*This API is available since Fedify 2.4.0.* + +The `~FederationOptions.publicKeyTtl` property is the time-to-live for +a remote actor's public key cached under +`~FederationKvPrefixes.publicKey`. It is 30 days by default. Once +an entry expires, the next signature verification that needs the key +refetches it from the remote server and caches it again: + +~~~~ typescript twoslash +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + publicKeyTtl: { days: 7 }, // [!code highlight] +}); +~~~~ + +### `httpMessageSignaturesSpecTtl` + +*This API is available since Fedify 2.4.0.* + +The `~FederationOptions.httpMessageSignaturesSpecTtl` property is +the time-to-live for a remote origin's remembered HTTP Message Signatures +spec cached under `~FederationKvPrefixes.httpMessageSignaturesSpec`. +It is 90 days by default. Once an entry expires, the next delivery to that +origin relearns the spec by [double-knocking] and remembers it again: + +~~~~ typescript twoslash +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + httpMessageSignaturesSpecTtl: { days: 30 }, // [!code highlight] +}); +~~~~ + +> [!TIP] +> Both TTLs trade storage against remote requests. See +> [*Bounding how long cache entries live*](./kv.md#bounding-how-long-cache-entries-live) +> for what shortening or lengthening them costs. + ### `queue` *This API is available since Fedify 0.5.0.* diff --git a/docs/manual/kv.md b/docs/manual/kv.md index 542428fb0..98cdb282b 100644 --- a/docs/manual/kv.md +++ b/docs/manual/kv.md @@ -512,6 +512,146 @@ export default { [Cloudflare Workers KV]: https://developers.cloudflare.com/kv/ +Bounding how long cache entries live +------------------------------------ + +*This section is relevant since Fedify 2.4.0.* + +Fedify keeps two caches in your `KvStore`: cached actor public keys and +remembered per-origin HTTP Message Signatures specs. Since Fedify 2.4.0 +both are written with a time-to-live, so a `KvStore` that never sees an +explicit clear no longer accumulates entries for actors and origins that have +stopped federating. The defaults are 30 days for cached keys and 90 days for +remembered specs, and applications can override them through +`~FederationOptions.publicKeyTtl` and +`~FederationOptions.httpMessageSignaturesSpecTtl`: + +~~~~ typescript twoslash +import { createFederation, MemoryKvStore } from "@fedify/fedify"; + +const federation = createFederation({ + kv: new MemoryKvStore(), + publicKeyTtl: { days: 7 }, // [!code highlight] + httpMessageSignaturesSpecTtl: { days: 30 }, // [!code highlight] +}); +~~~~ + +Both TTLs are a retention tradeoff, not a free cleanup knob. A shorter TTL +keeps less in the store and bounds how long a revoked or rotated key or an +outdated spec stays cached, but every expiry costs a request to the remote +server: verification has to refetch the key, and delivery has to relearn the +spec by [double-knocking] again. That refetch is not guaranteed to succeed—if +the peer is down, unreachable, or has removed the actor when the entry expires, +verification fails where it would have succeeded from cache. Lengthening +a TTL inverts the tradeoff: fewer remote requests and more tolerance of +unavailable peers, at the cost of holding stale entries longer. + +Pick the shorter end when your store is under space pressure or you need +revoked keys to fall out quickly, and the longer end when you federate with +peers that are frequently unavailable. + +[double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions + + +Clearing legacy cache entries +----------------------------- + +*This section is relevant since Fedify 2.4.0.* + +Entries written by Fedify 2.3 or earlier have no TTL. They are *not* +migrated or expired automatically: they simply stay in your `KvStore` until +something overwrites them, which is the same behavior Fedify has always had. +Leaving them alone is a perfectly valid choice—Fedify keeps serving and +refreshing them as before, and they get a TTL the next time they are written. + +If you would rather not wait for that, you can clear the old entries yourself. +Both caches live under their `~FederationOptions.kvPrefixes` entries, which +are `["_fedify", "publicKey"]` and +`["_fedify", "httpMessageSignaturesSpec"]` *by default*: + + - `~FederationKvPrefixes.publicKey` — cached actor public keys + - `~FederationKvPrefixes.httpMessageSignaturesSpec` — remembered HTTP + Message Signatures specs + +These are defaults, not fixed values. If you passed your own `kvPrefixes` to +`createFederation()`, substitute your prefixes for `_fedify`, `publicKey`, and +`httpMessageSignaturesSpec` in every example below. The same goes for the +adapter-level namespacing described in each subsection: [`RedisKvStore`] +prepends its own `keyPrefix`, and [`PostgresKvStore`] stores rows in its own +`tableName`. + +Clearing these entries costs the remote requests described in the previous +section: the caches are soft state that Fedify relearns on demand, but every +cleared key has to be refetched before it can be used again, and that refetch +fails while the peer is unavailable. Prefer clearing them while your peers +are reachable, and clear only the prefixes you actually need to reclaim. + +### Clearing entries in `RedisKvStore` + +[`RedisKvStore`] stores every key under a shared prefix (`"fedify::"` by +default, configurable via `RedisKvStoreOptions.keyPrefix`), followed by the +`KvKey` parts joined with `"::"`. Collect the whole scan result before +deleting anything—deleting keys while `--scan` is still iterating can make +the cursor skip entries: + +~~~~ bash +for pattern in 'fedify::_fedify::publicKey::*' \ + 'fedify::_fedify::httpMessageSignaturesSpec::*'; do + redis-cli --scan --pattern "$pattern" > /tmp/fedify-keys.txt + test -s /tmp/fedify-keys.txt && xargs -a /tmp/fedify-keys.txt redis-cli del + rm -f /tmp/fedify-keys.txt +done +~~~~ + +Replace the leading `fedify::` with your own `keyPrefix` if you configured +a custom one, and the `_fedify::publicKey` and +`_fedify::httpMessageSignaturesSpec` parts with your own `kvPrefixes`. + +### Clearing entries in `PostgresKvStore` + +[`PostgresKvStore`] stores every entry as a row keyed by a `text[]` column +(the table is named `fedify_kv_v2` by default, configurable via +`PostgresKvStoreOptions.tableName`). Delete the two Fedify caches with: + +~~~~ sql +DELETE FROM fedify_kv_v2 +WHERE array_length(key, 1) >= 2 AND key[1:2] = ARRAY['_fedify', 'publicKey']; + +DELETE FROM fedify_kv_v2 +WHERE array_length(key, 1) >= 2 + AND key[1:2] = ARRAY['_fedify', 'httpMessageSignaturesSpec']; +~~~~ + +Replace `fedify_kv_v2` with your own `tableName` if you configured a custom +one, and the array literals with your own `kvPrefixes`. + +### Clearing entries in other `KvStore` implementations + +For any other `KvStore`, iterate the two prefixes with [`~KvStore.list()`], +collect the keys, and delete them afterwards. Deleting while the iterator is +still open can make an implementation skip entries, the same way it does with +`redis-cli --scan`: + +~~~~ typescript twoslash +import type { KvKey, KvStore } from "@fedify/fedify"; +const kv = null as unknown as KvStore; +// ---cut-before--- +const prefixes: KvKey[] = [ + ["_fedify", "publicKey"], + ["_fedify", "httpMessageSignaturesSpec"], +]; +for (const prefix of prefixes) { + const keys: KvKey[] = []; + for await (const entry of kv.list(prefix)) keys.push(entry.key); + for (const key of keys) await kv.delete(key); +} +~~~~ + +Substitute your own `kvPrefixes` for the two prefixes if you configured them. + +[`~KvStore.list()`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore#list + + Implementing a custom `KvStore` ------------------------------- diff --git a/packages/fedify/src/federation/federation.ts b/packages/fedify/src/federation/federation.ts index 06bcebd9e..4285be7c0 100644 --- a/packages/fedify/src/federation/federation.ts +++ b/packages/fedify/src/federation/federation.ts @@ -935,6 +935,35 @@ export interface FederationOptions { */ kvPrefixes?: Partial; + /** + * The time-to-live for a remote actor's public key cached under + * {@link FederationKvPrefixes.publicKey}. Once it expires, the next + * signature verification that needs the key refetches it from the remote + * server and caches it again. + * + * Shortening it bounds how long a revoked or rotated key stays in the cache, + * at the cost of more requests to remote servers; refetching an expired key + * fails while the peer is unavailable, so a very short value makes + * verification depend on the peer being reachable. + * @default `{ days: 30 }` + * @since 2.4.0 + */ + publicKeyTtl?: Temporal.DurationLike; + + /** + * The time-to-live for a remote origin's remembered HTTP Message Signatures + * spec cached under {@link FederationKvPrefixes.httpMessageSignaturesSpec}. + * Once it expires, the next delivery to that origin relearns the spec by + * double-knocking and remembers it again. + * + * Shortening it makes Fedify notice a peer's spec upgrade sooner, at the + * cost of an extra signed request per delivery whenever the first spec tried + * is rejected. + * @default `{ days: 90 }` + * @since 2.4.0 + */ + httpMessageSignaturesSpecTtl?: Temporal.DurationLike; + /** * The message queue for sending and receiving activities. If not provided, * activities will not be queued and will be processed immediately. diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index f3c687e8a..a4364c41e 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -1257,6 +1257,11 @@ export interface InboxHandlerParameters { publicKey: KvKey; acceptSignatureNonce: KvKey; }; + /** + * The TTL for public keys cached under `kvPrefixes.publicKey`. + * @since 2.4.0 + */ + publicKeyTtl?: Temporal.Duration; queue?: MessageQueue; actorDispatcher?: ActorDispatcher; inboxListeners?: ActivityListenerSet>; @@ -1331,6 +1336,7 @@ async function handleInboxInternal( inboxContextFactory, kv, kvPrefixes, + publicKeyTtl, queue, actorDispatcher, inboxListeners, @@ -1405,7 +1411,12 @@ async function handleInboxInternal( headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } - const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, ctx); + const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, { + documentLoader: ctx.documentLoader, + contextLoader: ctx.contextLoader, + tracerProvider, + keyTtl: publicKeyTtl, + }); const jsonWithoutSig = detachSignature(json); const hasLdSignature = hasSignature(json); const canAttemptAlternateAuthAfterLdSignatureFailure = diff --git a/packages/fedify/src/federation/keycache.test.ts b/packages/fedify/src/federation/keycache.test.ts index 92a7de8bd..a1e243d14 100644 --- a/packages/fedify/src/federation/keycache.test.ts +++ b/packages/fedify/src/federation/keycache.test.ts @@ -128,3 +128,49 @@ test("KvKeyCache unavailable entries expire", async () => { assertEquals(await cache.get(keyId), undefined); assertEquals(await cache.getFetchError(keyId), undefined); }); + +test("KvKeyCache.keyTtl defaults to 30 days", () => { + const kv = new MemoryKvStore(); + const cache = new KvKeyCache(kv, ["pk"]); + assertEquals(cache.keyTtl.total("day"), 30); +}); + +test("KvKeyCache.keyTtl is configurable", () => { + const kv = new MemoryKvStore(); + const cache = new KvKeyCache(kv, ["pk"], { + keyTtl: Temporal.Duration.from({ days: 7 }), + }); + assertEquals(cache.keyTtl.total("day"), 7); +}); + +test("KvKeyCache cached keys expire after keyTtl", async () => { + const kv = new MemoryKvStore(); + const cache = new KvKeyCache(kv, ["pk"], { + keyTtl: Temporal.Duration.from({ milliseconds: 1 }), + }); + const keyId = new URL("https://example.com/key"); + + await cache.set( + keyId, + new CryptographicKey({ id: keyId }), + ); + // The value is written immediately... + assert(await kv.get(["pk", keyId.href]) != null); + assertInstanceOf(await cache.get(keyId), CryptographicKey); + + // ...but disappears from the underlying KvStore once keyTtl elapses. + await new Promise((resolve) => setTimeout(resolve, 10)); + assertEquals(await kv.get(["pk", keyId.href]), undefined); + + // A miss is reported as `undefined` (key unknown), not `null` (key known + // to be unavailable), so the caller refetches the key instead of treating + // the actor as keyless. + assertEquals(await cache.get(keyId), undefined); + assertEquals(cache.nullKeys.has(keyId.href), false); + + // Refetching and caching the key again repopulates the cache. + await cache.set(keyId, new CryptographicKey({ id: keyId })); + const refetched = await cache.get(keyId); + assertInstanceOf(refetched, CryptographicKey); + assertEquals(refetched.id?.href, keyId.href); +}); diff --git a/packages/fedify/src/federation/keycache.ts b/packages/fedify/src/federation/keycache.ts index 0dacbc3d2..0e8298952 100644 --- a/packages/fedify/src/federation/keycache.ts +++ b/packages/fedify/src/federation/keycache.ts @@ -1,12 +1,26 @@ import { CryptographicKey, Multikey } from "@fedify/vocab"; import type { DocumentLoader } from "@fedify/vocab-runtime"; +import type { TracerProvider } from "@opentelemetry/api"; import type { FetchKeyErrorResult, KeyCache } from "../sig/key.ts"; import type { KvKey, KvStore } from "./kv.ts"; export interface KvKeyCacheOptions { documentLoader?: DocumentLoader; contextLoader?: DocumentLoader; + tracerProvider?: TracerProvider; unavailableKeyTtl?: Temporal.Duration; + + /** + * The TTL for successfully cached keys. `30` days by default. + * + * Entries written by Fedify versions older than 2.4.0 have no TTL and + * are left untouched by this option; see the *Clearing legacy cache + * entries* section of the key–value store guide if you want to expire + * them proactively. + * @default `Temporal.Duration.from({ days: 30 })` + * @since 2.4.0 + */ + keyTtl?: Temporal.Duration; } export class KvKeyCache implements KeyCache { @@ -14,6 +28,7 @@ export class KvKeyCache implements KeyCache { readonly prefix: KvKey; readonly options: KvKeyCacheOptions; readonly unavailableKeyTtl: Temporal.Duration; + readonly keyTtl: Temporal.Duration; readonly nullKeys: Map; constructor(kv: KvStore, prefix: KvKey, options: KvKeyCacheOptions = {}) { @@ -22,6 +37,7 @@ export class KvKeyCache implements KeyCache { this.options = options; this.unavailableKeyTtl = options.unavailableKeyTtl ?? Temporal.Duration.from({ minutes: 10 }); + this.keyTtl = options.keyTtl ?? Temporal.Duration.from({ days: 30 }); this.nullKeys = new Map(); } @@ -76,7 +92,9 @@ export class KvKeyCache implements KeyCache { } this.nullKeys.delete(keyId.href); const serialized = await key.toJsonLd(this.options); - await this.kv.set([...this.prefix, keyId.href], serialized); + await this.kv.set([...this.prefix, keyId.href], serialized, { + ttl: this.keyTtl, + }); } async getFetchError(keyId: URL): Promise { diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index b63335ecb..6aedf60af 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -45,7 +45,11 @@ import personFixture from "../../../fixture/src/fixtures/example.com/person.json import person2Fixture from "../../../fixture/src/fixtures/example.com/person2.json" with { type: "json", }; -import { signRequest, verifyRequest } from "../sig/http.ts"; +import { + type HttpMessageSignaturesSpec, + signRequest, + verifyRequest, +} from "../sig/http.ts"; import type { KeyCache } from "../sig/key.ts"; import { compactJsonLd, @@ -68,7 +72,13 @@ import { getAuthenticatedDocumentLoader } from "../utils/docloader.ts"; import { handleBenchmarkTrigger } from "./bench.ts"; import { CircuitBreaker } from "./circuit-breaker.ts"; import type { Context, GetActorOptions } from "./context.ts"; -import { MemoryKvStore } from "./kv.ts"; +import { + type KvKey, + type KvStore, + type KvStoreListEntry, + type KvStoreSetOptions, + MemoryKvStore, +} from "./kv.ts"; import { recordInboxActivity } from "./metrics.ts"; import { ContextImpl, @@ -11277,6 +11287,274 @@ test("KvSpecDeterminer", async (t) => { spec = await determiner.determineSpec("example.com"); assertEquals(spec, "rfc9421"); }); + + await t.step("should default specTtl to 90 days", () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + const determiner = new KvSpecDeterminer(kv, prefix); + assertEquals(determiner.specTtl.total("day"), 90); + }); + + await t.step( + "should accept a configurable specTtl as a 4th positional argument", + () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + // The existing 3-argument constructor shape must keep working; the + // options object is purely additive. + const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { + specTtl: Temporal.Duration.from({ days: 7 }), + }); + assertEquals(determiner.specTtl.total("day"), 7); + }, + ); + + await t.step( + "should expire, relearn, and remember the spec again", + async () => { + const kv = new MemoryKvStore(); + const prefix = ["test", "spec"] as const; + const determiner = new KvSpecDeterminer(kv, prefix, "rfc9421", { + specTtl: Temporal.Duration.from({ milliseconds: 250 }), + }); + + await determiner.rememberSpec( + "example.com", + "draft-cavage-http-signatures-12", + ); + assertEquals( + await determiner.determineSpec("example.com"), + "draft-cavage-http-signatures-12", + ); + + // Falls back to the default spec once the remembered entry expires, + // which is what makes the next delivery double-knock again. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await determiner.determineSpec("example.com"), "rfc9421"); + + // The relearned spec is remembered again, with the TTL reapplied. + await determiner.rememberSpec( + "example.com", + "draft-cavage-http-signatures-12", + ); + assertEquals( + await determiner.determineSpec("example.com"), + "draft-cavage-http-signatures-12", + ); + }, + ); +}); + +/** + * A `KvStore` that records the TTL every write was made with, so tests can + * assert on TTLs even after the entries themselves have expired. + */ +class TtlRecordingKvStore implements KvStore { + readonly inner: MemoryKvStore = new MemoryKvStore(); + readonly writes: { key: KvKey; ttl?: Temporal.Duration }[] = []; + + get(key: KvKey): Promise { + return this.inner.get(key); + } + + set(key: KvKey, value: unknown, options?: KvStoreSetOptions): Promise { + this.writes.push({ key, ttl: options?.ttl }); + return this.inner.set(key, value, options); + } + + delete(key: KvKey): Promise { + return this.inner.delete(key); + } + + list(prefix?: KvKey): AsyncIterable { + return this.inner.list(prefix); + } + + /** The TTL of the most recent write to `key`, or `undefined` if never set. */ + lastTtl(key: KvKey): Temporal.Duration | undefined { + const matches = this.writes.filter((w) => + w.key.length === key.length && w.key.every((p, i) => p === key[i]) + ); + return matches.length < 1 ? undefined : matches[matches.length - 1].ttl; + } +} + +test("createFederation() defaults the cache TTLs and lets them be overridden", () => { + const defaults = new FederationImpl({ kv: new MemoryKvStore() }); + assertEquals(defaults.publicKeyTtl.total("day"), 30); + assertEquals(defaults.httpMessageSignaturesSpecTtl.total("day"), 90); + + const overridden = new FederationImpl({ + kv: new MemoryKvStore(), + publicKeyTtl: { days: 1 }, + httpMessageSignaturesSpecTtl: { hours: 12 }, + }); + assertEquals(overridden.publicKeyTtl.total("day"), 1); + assertEquals(overridden.httpMessageSignaturesSpecTtl.total("hour"), 12); +}); + +test("createFederation() applies httpMessageSignaturesSpecTtl to remembered specs", async () => { + fetchMock.spyGlobal(); + try { + const attempts: HttpMessageSignaturesSpec[] = []; + fetchMock.post("https://example.com/inbox", async (cl) => { + const request = cl.request!.clone() as Request; + const spec: HttpMessageSignaturesSpec = + request.headers.has("Signature-Input") + ? "rfc9421" + : "draft-cavage-http-signatures-12"; + attempts.push(spec); + // This peer only understands the legacy spec, so the first knock with + // RFC 9421 is rejected and Fedify has to fall back and remember. + if (spec === "rfc9421") return new Response(null, { status: 401 }); + const key = await verifyRequest(request, { + documentLoader: mockDocumentLoader, + contextLoader: mockDocumentLoader, + }); + return new Response(null, { status: key == null ? 401 : 202 }); + }); + + const kv = new TtlRecordingKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + httpMessageSignaturesSpecTtl: { milliseconds: 250 }, + }); + const ctx = federation.createContext( + new URL("https://example.com/"), + undefined, + ); + const specKey: KvKey = [ + "_fedify", + "httpMessageSignaturesSpec", + "https://example.com", + ]; + const send = () => + ctx.sendActivity( + [{ privateKey: rsaPrivateKey2, keyId: rsaPublicKey2.id! }], + { + id: new URL("https://example.com/recipient"), + inboxId: new URL("https://example.com/inbox"), + }, + new vocab.Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/person"), + }), + ); + + // The first delivery double-knocks and then remembers the legacy spec, + // using the TTL the application configured rather than the 90-day default. + await send(); + assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); + assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); + assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + + // While the memory is fresh the second delivery skips the double knock. + attempts.length = 0; + await send(); + assertEquals(attempts, ["draft-cavage-http-signatures-12"]); + + // Once it expires the spec is relearned, remembered again, and delivery + // keeps working through that path. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await kv.get(specKey), undefined); + attempts.length = 0; + await send(); + assertEquals(attempts, ["rfc9421", "draft-cavage-http-signatures-12"]); + assertEquals(await kv.get(specKey), "draft-cavage-http-signatures-12"); + assertEquals(kv.lastTtl(specKey)?.total("millisecond"), 250); + } finally { + fetchMock.hardReset(); + } +}); + +test("createFederation() applies publicKeyTtl to cached public keys", async () => { + fetchMock.spyGlobal(); + try { + // The inbox handler resolves the signing key through the authenticated + // document loader, which goes out over the network, so count the fetches + // there rather than through `documentLoaderFactory`. + let keyFetches = 0; + fetchMock.get("begin:https://example.com/person2", () => { + keyFetches++; + return { + headers: { "Content-Type": "application/activity+json" }, + body: person2Fixture, + }; + }); + + const keyId = "https://example.com/person2#key3"; + const kv = new TtlRecordingKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + contextLoaderFactory: () => mockDocumentLoader, + publicKeyTtl: { milliseconds: 250 }, + }); + const inbox: vocab.Create[] = []; + federation + .setActorDispatcher( + "/users/{identifier}", + (_, identifier) => identifier === "john" ? new vocab.Person({}) : null, + ) + .setKeyPairsDispatcher(() => [{ + privateKey: rsaPrivateKey2, + publicKey: rsaPublicKey2.publicKey!, + }]); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox") + .on(vocab.Create, (_ctx, create) => { + inbox.push(create); + }); + + const deliver = async (): Promise => { + const activity = new vocab.Create({ + id: new URL(`https://example.com/activities/${crypto.randomUUID()}`), + actor: new URL("https://example.com/person2"), + }); + let request = new Request("https://example.com/users/john/inbox", { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + accept: "application/ld+json", + }, + body: JSON.stringify( + await activity.toJsonLd({ contextLoader: mockDocumentLoader }), + ), + }); + request = await signRequest(request, rsaPrivateKey3, new URL(keyId)); + return await federation.fetch(request, { contextData: undefined }); + }; + + const publicKeyKey: KvKey = ["_fedify", "publicKey", keyId]; + + // Verifying the first signed delivery fetches the key and caches it with + // the TTL the application configured rather than the 30-day default. + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 1); + assert(keyFetches > 0); + assert(await kv.get(publicKeyKey) != null); + assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + + // While the cache is warm the key is not refetched. + keyFetches = 0; + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 2); + assertEquals(keyFetches, 0); + + // After the TTL elapses the cache misses, the key is refetched and cached + // again, and signature verification keeps working through that path. + await new Promise((resolve) => setTimeout(resolve, 400)); + assertEquals(await kv.get(publicKeyKey), undefined); + keyFetches = 0; + assertEquals((await deliver()).status, 202); + assertEquals(inbox.length, 3); + assert(keyFetches > 0); + assert(await kv.get(publicKeyKey) != null); + assertEquals(kv.lastTtl(publicKeyKey)?.total("millisecond"), 250); + } finally { + fetchMock.hardReset(); + } }); test("createFederation() instruments documentLoader with activitypub.document.fetch", async () => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 84309d31b..567e7de16 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -603,6 +603,8 @@ export class FederationImpl implements Federation { kv: KvStore; kvPrefixes: FederationKvPrefixes; + publicKeyTtl: Temporal.Duration; + httpMessageSignaturesSpecTtl: Temporal.Duration; inboxQueue?: MessageQueue; outboxQueue?: MessageQueue; fanoutQueue?: MessageQueue; @@ -691,6 +693,12 @@ export class FederationImpl } satisfies FederationKvPrefixes), ...(options.kvPrefixes ?? {}), }; + this.publicKeyTtl = Temporal.Duration.from( + options.publicKeyTtl ?? { days: 30 }, + ); + this.httpMessageSignaturesSpecTtl = Temporal.Duration.from( + options.httpMessageSignaturesSpecTtl ?? { days: 90 }, + ); if (options.queue == null) { this.inboxQueue = undefined; this.outboxQueue = undefined; @@ -893,6 +901,7 @@ export class FederationImpl this.kv, this.kvPrefixes.httpMessageSignaturesSpec, options.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, ), tracerProvider: this.tracerProvider, }), @@ -1428,6 +1437,7 @@ export class FederationImpl this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, ), meterProvider: this.meterProvider, tracerProvider: this.tracerProvider, @@ -2443,6 +2453,7 @@ export class FederationImpl this.kv, this.kvPrefixes.httpMessageSignaturesSpec, this.firstKnock, + { specTtl: this.httpMessageSignaturesSpecTtl }, ), meterProvider: this.meterProvider, tracerProvider: this.tracerProvider, @@ -2878,6 +2889,7 @@ export class FederationImpl inboxContextFactory, kv: this.kv, kvPrefixes: this.kvPrefixes, + publicKeyTtl: this.publicKeyTtl, queue: this.inboxQueue, actorDispatcher: this.actorCallbacks?.dispatcher, inboxListeners: this.inboxListeners, @@ -4122,7 +4134,12 @@ export class ContextImpl implements Context { const keyCache = new KvKeyCache( this.federation.kv, this.federation.kvPrefixes.publicKey, - this, + { + documentLoader: this.documentLoader, + contextLoader: this.contextLoader, + tracerProvider: this.tracerProvider, + keyTtl: this.federation.publicKeyTtl, + }, ); const verified = await verifyObject( Activity, @@ -4582,6 +4599,7 @@ async function forwardActivityInternal( ctx.federation.kv, ctx.federation.kvPrefixes.httpMessageSignaturesSpec, ctx.federation.firstKnock, + { specTtl: ctx.federation.httpMessageSignaturesSpecTtl }, ), }), ); @@ -4898,19 +4916,39 @@ interface SendActivityInternalOptions { readonly context: Context; } +/** + * Options for {@link KvSpecDeterminer}. + * @since 2.4.0 + */ +export interface KvSpecDeterminerOptions { + /** + * The TTL for remembered specs. `90` days by default. + * + * Entries written by Fedify versions older than 2.4.0 have no TTL and + * are left untouched by this option; see the *Clearing legacy cache + * entries* section of the key–value store guide if you want to expire + * them proactively. + * @default `Temporal.Duration.from({ days: 90 })` + */ + specTtl?: Temporal.Duration; +} + export class KvSpecDeterminer implements HttpMessageSignaturesSpecDeterminer { kv: KvStore; prefix: KvKey; defaultSpec: HttpMessageSignaturesSpec; + specTtl: Temporal.Duration; constructor( kv: KvStore, prefix: KvKey, defaultSpec: HttpMessageSignaturesSpec = "rfc9421", + options: KvSpecDeterminerOptions = {}, ) { this.kv = kv; this.prefix = prefix; this.defaultSpec = defaultSpec; + this.specTtl = options.specTtl ?? Temporal.Duration.from({ days: 90 }); } async determineSpec( @@ -4926,7 +4964,7 @@ export class KvSpecDeterminer implements HttpMessageSignaturesSpecDeterminer { origin: string, spec: HttpMessageSignaturesSpec, ): Promise { - await this.kv.set([...this.prefix, origin], spec); + await this.kv.set([...this.prefix, origin], spec, { ttl: this.specTtl }); } }