Skip to content

Commit 0da7be0

Browse files
authored
Merge pull request #1027 from heeoneie/1017-kv-cache-ttl
Bound the lifetime of public-key and signature-spec caches
2 parents 5c417eb + 4f762f2 commit 0da7be0

13 files changed

Lines changed: 956 additions & 12 deletions

File tree

‎CHANGES.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ To be released.
1010

1111
### @fedify/fedify
1212

13+
- Changed cached actor public keys and remembered per-origin HTTP Message
14+
Signatures specs to expire, so a `KvStore` that never sees an explicit
15+
clear no longer accumulates entries for actors and origins that have
16+
stopped federating. Keys expire after 30 days and specs after 90 days
17+
by default, and both windows are configurable through the new
18+
`FederationOptions.publicKeyTtl` and
19+
`FederationOptions.httpMessageSignaturesSpecTtl` options.
20+
[[#1017], [#1027] by Heewon Chae\]
21+
22+
- Shortening a window trades storage for remote requests: an expired
23+
key has to be refetched before the next signature verification, and
24+
an expired spec has to be relearned by double-knocking on the next
25+
delivery. Refetching fails while the peer is unavailable, so a very
26+
short window makes verification depend on the peer being reachable.
27+
- Entries written by earlier versions of Fedify have no expiry and are
28+
left as they are; they gain one the next time they are written. See
29+
the new *Clearing legacy cache entries* section of the
30+
[key–value store guide] to clear them proactively instead of waiting.
31+
1332
- Fixed `verifyProof()` so Ed25519 JCS proofs authenticate every received
1433
proof option except `proofValue`, including `expires`, `domain`,
1534
`challenge`, `nonce`, and extension options. It now rejects expired or
@@ -108,6 +127,7 @@ To be released.
108127
`esnext.temporal` lib reference.
109128
[[#823], [#925]]
110129

130+
[key–value store guide]: https://fedify.dev/manual/kv
111131
[FEP-ef61]: https://w3id.org/fep/ef61
112132
[FEP-8b32]: https://w3id.org/fep/8b32
113133
[FEP-fe34]: https://w3id.org/fep/fe34
@@ -133,6 +153,8 @@ To be released.
133153
[#930]: https://github.com/fedify-dev/fedify/issues/930
134154
[#934]: https://github.com/fedify-dev/fedify/pull/934
135155
[#968]: https://github.com/fedify-dev/fedify/pull/968
156+
[#1017]: https://github.com/fedify-dev/fedify/issues/1017
157+
[#1027]: https://github.com/fedify-dev/fedify/pull/1027
136158

137159
### @fedify/astro
138160

‎changes.d/fedify/kv-cache-ttl.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
links:
3+
'#1017': https://github.com/fedify-dev/fedify/issues/1017
4+
'#1027': https://github.com/fedify-dev/fedify/pull/1027
5+
---
6+
- Changed cached actor public keys and remembered per-origin HTTP Message
7+
Signatures specs to expire, so a `KvStore` that never sees an explicit
8+
clear no longer accumulates entries for actors and origins that have
9+
stopped federating. Keys expire after 30 days and specs after 90 days
10+
by default, and both windows are configurable through the new
11+
`FederationOptions.publicKeyTtl` and
12+
`FederationOptions.httpMessageSignaturesSpecTtl` options.
13+
[[#1017], [#1027] by Heewon Chae]
14+
15+
- Shortening a window trades storage for remote requests: an expired
16+
key has to be refetched before the next signature verification, and
17+
an expired spec has to be relearned by double-knocking on the next
18+
delivery. Refetching fails while the peer is unavailable, so a very
19+
short window makes verification depend on the peer being reachable.
20+
- Entries written by earlier versions of Fedify have no expiry and are
21+
left as they are; they gain one the next time they are written. See
22+
the new *Clearing legacy cache entries* section of the
23+
[key–value store guide] to clear them proactively instead of waiting.
24+
25+
[key–value store guide]: https://fedify.dev/manual/kv

‎docs/manual/federation.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,49 @@ that the `Federation` object uses:
9696

9797
[double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions
9898

99+
### `publicKeyTtl`
100+
101+
*This API is available since Fedify 2.4.0.*
102+
103+
The `~FederationOptions.publicKeyTtl` property is the time-to-live for
104+
a remote actor's public key cached under
105+
`~FederationKvPrefixes.publicKey`. It is 30 days by default. Once
106+
an entry expires, the next signature verification that needs the key
107+
refetches it from the remote server and caches it again:
108+
109+
~~~~ typescript twoslash
110+
import { createFederation, MemoryKvStore } from "@fedify/fedify";
111+
112+
const federation = createFederation<void>({
113+
kv: new MemoryKvStore(),
114+
publicKeyTtl: { days: 7 }, // [!code highlight]
115+
});
116+
~~~~
117+
118+
### `httpMessageSignaturesSpecTtl`
119+
120+
*This API is available since Fedify 2.4.0.*
121+
122+
The `~FederationOptions.httpMessageSignaturesSpecTtl` property is
123+
the time-to-live for a remote origin's remembered HTTP Message Signatures
124+
spec cached under `~FederationKvPrefixes.httpMessageSignaturesSpec`.
125+
It is 90 days by default. Once an entry expires, the next delivery to that
126+
origin relearns the spec by [double-knocking] and remembers it again:
127+
128+
~~~~ typescript twoslash
129+
import { createFederation, MemoryKvStore } from "@fedify/fedify";
130+
131+
const federation = createFederation<void>({
132+
kv: new MemoryKvStore(),
133+
httpMessageSignaturesSpecTtl: { days: 30 }, // [!code highlight]
134+
});
135+
~~~~
136+
137+
> [!TIP]
138+
> Both TTLs trade storage against remote requests. See
139+
> [*Bounding how long cache entries live*](./kv.md#bounding-how-long-cache-entries-live)
140+
> for what shortening or lengthening them costs.
141+
99142
### `queue`
100143

101144
*This API is available since Fedify 0.5.0.*

‎docs/manual/kv.md‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,146 @@ for ordinary reads and listings.
585585
[Netlify Functions]: https://docs.netlify.com/build/functions/overview/
586586

587587

588+
Bounding how long cache entries live
589+
------------------------------------
590+
591+
*This section is relevant since Fedify 2.4.0.*
592+
593+
Fedify keeps two caches in your `KvStore`: cached actor public keys and
594+
remembered per-origin HTTP Message Signatures specs. Since Fedify 2.4.0
595+
both are written with a time-to-live, so a `KvStore` that never sees an
596+
explicit clear no longer accumulates entries for actors and origins that have
597+
stopped federating. The defaults are 30 days for cached keys and 90 days for
598+
remembered specs, and applications can override them through
599+
`~FederationOptions.publicKeyTtl` and
600+
`~FederationOptions.httpMessageSignaturesSpecTtl`:
601+
602+
~~~~ typescript twoslash
603+
import { createFederation, MemoryKvStore } from "@fedify/fedify";
604+
605+
const federation = createFederation<void>({
606+
kv: new MemoryKvStore(),
607+
publicKeyTtl: { days: 7 }, // [!code highlight]
608+
httpMessageSignaturesSpecTtl: { days: 30 }, // [!code highlight]
609+
});
610+
~~~~
611+
612+
Both TTLs are a retention tradeoff, not a free cleanup knob. A shorter TTL
613+
keeps less in the store and bounds how long a revoked or rotated key or an
614+
outdated spec stays cached, but every expiry costs a request to the remote
615+
server: verification has to refetch the key, and delivery has to relearn the
616+
spec by [double-knocking] again. That refetch is not guaranteed to succeed—if
617+
the peer is down, unreachable, or has removed the actor when the entry expires,
618+
verification fails where it would have succeeded from cache. Lengthening
619+
a TTL inverts the tradeoff: fewer remote requests and more tolerance of
620+
unavailable peers, at the cost of holding stale entries longer.
621+
622+
Pick the shorter end when your store is under space pressure or you need
623+
revoked keys to fall out quickly, and the longer end when you federate with
624+
peers that are frequently unavailable.
625+
626+
[double-knocking]: https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions
627+
628+
629+
Clearing legacy cache entries
630+
-----------------------------
631+
632+
*This section is relevant since Fedify 2.4.0.*
633+
634+
Entries written by Fedify 2.3 or earlier have no TTL. They are *not*
635+
migrated or expired automatically: they simply stay in your `KvStore` until
636+
something overwrites them, which is the same behavior Fedify has always had.
637+
Leaving them alone is a perfectly valid choice—Fedify keeps serving and
638+
refreshing them as before, and they get a TTL the next time they are written.
639+
640+
If you would rather not wait for that, you can clear the old entries yourself.
641+
Both caches live under their `~FederationOptions.kvPrefixes` entries, which
642+
are `["_fedify", "publicKey"]` and
643+
`["_fedify", "httpMessageSignaturesSpec"]` *by default*:
644+
645+
- `~FederationKvPrefixes.publicKey` — cached actor public keys
646+
- `~FederationKvPrefixes.httpMessageSignaturesSpec` — remembered HTTP
647+
Message Signatures specs
648+
649+
These are defaults, not fixed values. If you passed your own `kvPrefixes` to
650+
`createFederation()`, substitute your prefixes for `_fedify`, `publicKey`, and
651+
`httpMessageSignaturesSpec` in every example below. The same goes for the
652+
adapter-level namespacing described in each subsection: [`RedisKvStore`]
653+
prepends its own `keyPrefix`, and [`PostgresKvStore`] stores rows in its own
654+
`tableName`.
655+
656+
Clearing these entries costs the remote requests described in the previous
657+
section: the caches are soft state that Fedify relearns on demand, but every
658+
cleared key has to be refetched before it can be used again, and that refetch
659+
fails while the peer is unavailable. Prefer clearing them while your peers
660+
are reachable, and clear only the prefixes you actually need to reclaim.
661+
662+
### Clearing entries in `RedisKvStore`
663+
664+
[`RedisKvStore`] stores every key under a shared prefix (`"fedify::"` by
665+
default, configurable via `RedisKvStoreOptions.keyPrefix`), followed by the
666+
`KvKey` parts joined with `"::"`. Collect the whole scan result before
667+
deleting anything—deleting keys while `--scan` is still iterating can make
668+
the cursor skip entries:
669+
670+
~~~~ bash
671+
for pattern in 'fedify::_fedify::publicKey::*' \
672+
'fedify::_fedify::httpMessageSignaturesSpec::*'; do
673+
redis-cli --scan --pattern "$pattern" > /tmp/fedify-keys.txt
674+
test -s /tmp/fedify-keys.txt && xargs -a /tmp/fedify-keys.txt redis-cli del
675+
rm -f /tmp/fedify-keys.txt
676+
done
677+
~~~~
678+
679+
Replace the leading `fedify::` with your own `keyPrefix` if you configured
680+
a custom one, and the `_fedify::publicKey` and
681+
`_fedify::httpMessageSignaturesSpec` parts with your own `kvPrefixes`.
682+
683+
### Clearing entries in `PostgresKvStore`
684+
685+
[`PostgresKvStore`] stores every entry as a row keyed by a `text[]` column
686+
(the table is named `fedify_kv_v2` by default, configurable via
687+
`PostgresKvStoreOptions.tableName`). Delete the two Fedify caches with:
688+
689+
~~~~ sql
690+
DELETE FROM fedify_kv_v2
691+
WHERE array_length(key, 1) >= 2 AND key[1:2] = ARRAY['_fedify', 'publicKey'];
692+
693+
DELETE FROM fedify_kv_v2
694+
WHERE array_length(key, 1) >= 2
695+
AND key[1:2] = ARRAY['_fedify', 'httpMessageSignaturesSpec'];
696+
~~~~
697+
698+
Replace `fedify_kv_v2` with your own `tableName` if you configured a custom
699+
one, and the array literals with your own `kvPrefixes`.
700+
701+
### Clearing entries in other `KvStore` implementations
702+
703+
For any other `KvStore`, iterate the two prefixes with [`~KvStore.list()`],
704+
collect the keys, and delete them afterwards. Deleting while the iterator is
705+
still open can make an implementation skip entries, the same way it does with
706+
`redis-cli --scan`:
707+
708+
~~~~ typescript twoslash
709+
import type { KvKey, KvStore } from "@fedify/fedify";
710+
const kv = null as unknown as KvStore;
711+
// ---cut-before---
712+
const prefixes: KvKey[] = [
713+
["_fedify", "publicKey"],
714+
["_fedify", "httpMessageSignaturesSpec"],
715+
];
716+
for (const prefix of prefixes) {
717+
const keys: KvKey[] = [];
718+
for await (const entry of kv.list(prefix)) keys.push(entry.key);
719+
for (const key of keys) await kv.delete(key);
720+
}
721+
~~~~
722+
723+
Substitute your own `kvPrefixes` for the two prefixes if you configured them.
724+
725+
[`~KvStore.list()`]: https://jsr.io/@fedify/fedify/doc/federation/~/KvStore#list
726+
727+
588728
Implementing a custom `KvStore`
589729
-------------------------------
590730

‎packages/fedify/src/federation/federation.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,35 @@ export interface FederationOptions<TContextData> {
935935
*/
936936
kvPrefixes?: Partial<FederationKvPrefixes>;
937937

938+
/**
939+
* The time-to-live for a remote actor's public key cached under
940+
* {@link FederationKvPrefixes.publicKey}. Once it expires, the next
941+
* signature verification that needs the key refetches it from the remote
942+
* server and caches it again.
943+
*
944+
* Shortening it bounds how long a revoked or rotated key stays in the cache,
945+
* at the cost of more requests to remote servers; refetching an expired key
946+
* fails while the peer is unavailable, so a very short value makes
947+
* verification depend on the peer being reachable.
948+
* @default `{ days: 30 }`
949+
* @since 2.4.0
950+
*/
951+
publicKeyTtl?: Temporal.DurationLike;
952+
953+
/**
954+
* The time-to-live for a remote origin's remembered HTTP Message Signatures
955+
* spec cached under {@link FederationKvPrefixes.httpMessageSignaturesSpec}.
956+
* Once it expires, the next delivery to that origin relearns the spec by
957+
* double-knocking and remembers it again.
958+
*
959+
* Shortening it makes Fedify notice a peer's spec upgrade sooner, at the
960+
* cost of an extra signed request per delivery whenever the first spec tried
961+
* is rejected.
962+
* @default `{ days: 90 }`
963+
* @since 2.4.0
964+
*/
965+
httpMessageSignaturesSpecTtl?: Temporal.DurationLike;
966+
938967
/**
939968
* The message queue for sending and receiving activities. If not provided,
940969
* activities will not be queued and will be processed immediately.

‎packages/fedify/src/federation/handler.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,11 @@ export interface InboxHandlerParameters<TContextData> {
12571257
publicKey: KvKey;
12581258
acceptSignatureNonce: KvKey;
12591259
};
1260+
/**
1261+
* The TTL for public keys cached under `kvPrefixes.publicKey`.
1262+
* @since 2.4.0
1263+
*/
1264+
publicKeyTtl?: Temporal.Duration;
12601265
queue?: MessageQueue;
12611266
actorDispatcher?: ActorDispatcher<TContextData>;
12621267
inboxListeners?: ActivityListenerSet<InboxContext<TContextData>>;
@@ -1331,6 +1336,7 @@ async function handleInboxInternal<TContextData>(
13311336
inboxContextFactory,
13321337
kv,
13331338
kvPrefixes,
1339+
publicKeyTtl,
13341340
queue,
13351341
actorDispatcher,
13361342
inboxListeners,
@@ -1405,7 +1411,12 @@ async function handleInboxInternal<TContextData>(
14051411
headers: { "Content-Type": "text/plain; charset=utf-8" },
14061412
});
14071413
}
1408-
const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, ctx);
1414+
const keyCache = new KvKeyCache(kv, kvPrefixes.publicKey, {
1415+
documentLoader: ctx.documentLoader,
1416+
contextLoader: ctx.contextLoader,
1417+
tracerProvider,
1418+
keyTtl: publicKeyTtl,
1419+
});
14091420
const jsonWithoutSig = detachSignature(json);
14101421
const hasLdSignature = hasSignature(json);
14111422
const canAttemptAlternateAuthAfterLdSignatureFailure =

0 commit comments

Comments
 (0)