Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
25 changes: 25 additions & 0 deletions changes.d/fedify/kv-cache-ttl.md
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions docs/manual/federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>({
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<void>({
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.*
Expand Down
140 changes: 140 additions & 0 deletions docs/manual/kv.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>({
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`
-------------------------------

Expand Down
29 changes: 29 additions & 0 deletions packages/fedify/src/federation/federation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,35 @@ export interface FederationOptions<TContextData> {
*/
kvPrefixes?: Partial<FederationKvPrefixes>;

/**
* 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.
Expand Down
13 changes: 12 additions & 1 deletion packages/fedify/src/federation/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,11 @@ export interface InboxHandlerParameters<TContextData> {
publicKey: KvKey;
acceptSignatureNonce: KvKey;
};
/**
* The TTL for public keys cached under `kvPrefixes.publicKey`.
* @since 2.4.0
*/
publicKeyTtl?: Temporal.Duration;
queue?: MessageQueue;
actorDispatcher?: ActorDispatcher<TContextData>;
inboxListeners?: ActivityListenerSet<InboxContext<TContextData>>;
Expand Down Expand Up @@ -1331,6 +1336,7 @@ async function handleInboxInternal<TContextData>(
inboxContextFactory,
kv,
kvPrefixes,
publicKeyTtl,
queue,
actorDispatcher,
inboxListeners,
Expand Down Expand Up @@ -1405,7 +1411,12 @@ async function handleInboxInternal<TContextData>(
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 =
Expand Down
46 changes: 46 additions & 0 deletions packages/fedify/src/federation/keycache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
dahlia marked this conversation as resolved.
Loading
Loading