@oxy.so/federation 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -169,19 +169,30 @@ async function verifyHttpSignature(req, fetchPublicKey, options = {}) {
169
169
  return `${name}: ${Array.isArray(value) ? value[0] : value}`;
170
170
  });
171
171
  const signingString = signingParts.join('\n');
172
- const verifier = node_crypto_1.default.createVerify('sha256');
173
- verifier.update(signingString);
174
- verifier.end();
175
- try {
176
- const isValid = verifier.verify(keyData.publicKeyPem, parsed.signature, 'base64');
177
- return {
178
- verified: isValid,
179
- actorUri: isValid ? keyData.actorUri : undefined,
180
- reason: isValid ? undefined : 'verify-failed',
181
- };
182
- }
183
- catch (err) {
184
- options.onDebug?.('HTTP signature verification failed:', err);
185
- return { verified: false, reason: err instanceof Error ? err.message : 'verify-exception' };
172
+ /** `true` when `publicKeyPem` verifies the signature, otherwise why it does not. */
173
+ const check = (publicKeyPem) => {
174
+ const verifier = node_crypto_1.default.createVerify('sha256');
175
+ verifier.update(signingString);
176
+ verifier.end();
177
+ try {
178
+ return verifier.verify(publicKeyPem, parsed.signature, 'base64') || 'verify-failed';
179
+ }
180
+ catch (err) {
181
+ options.onDebug?.('HTTP signature verification failed:', err);
182
+ return err instanceof Error ? err.message : 'verify-exception';
183
+ }
184
+ };
185
+ const verdict = check(keyData.publicKeyPem);
186
+ if (verdict === true)
187
+ return { verified: true, actorUri: keyData.actorUri };
188
+ // The key may only be out of date: an actor that rotates its key keeps the
189
+ // keyId and signs with the new one. Ask the owner once before rejecting.
190
+ const fresh = await fetchPublicKey(parsed.keyId, { refresh: true });
191
+ if (!fresh || fresh.publicKeyPem === keyData.publicKeyPem) {
192
+ return { verified: false, reason: verdict };
186
193
  }
194
+ const freshVerdict = check(fresh.publicKeyPem);
195
+ return freshVerdict === true
196
+ ? { verified: true, actorUri: fresh.actorUri }
197
+ : { verified: false, reason: freshVerdict };
187
198
  }
@@ -32,6 +32,13 @@ const boundedBody_1 = require("./boundedBody");
32
32
  const ACTOR_REFRESH_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
33
33
  /** Staleness threshold after which a cached actor is eligible for a background re-fetch. */
34
34
  const ACTOR_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
35
+ /**
36
+ * How recently fetched an actor must be for its stored key to count as current
37
+ * when a signature fails against it. An actor fetched this recently is not
38
+ * fetched again: the key that failed is the one it publishes, and anyone can
39
+ * send a bad signature naming a real actor's keyId.
40
+ */
41
+ const KEY_REFETCH_MIN_INTERVAL_MS = 60 * 1000; // 1 minute
35
42
  /** The AP content type asked for on signed actor/collection fetches. */
36
43
  const AP_CONTENT_TYPE = 'application/activity+json';
37
44
  /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
@@ -128,6 +135,8 @@ class ActorResolver {
128
135
  this.config = config;
129
136
  /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
130
137
  this.inFlightActorRefreshes = new Set();
138
+ /** Actor key refetches in flight, by actor URI: concurrent failed signatures share one. */
139
+ this.inFlightKeyRefetches = new Map();
131
140
  }
132
141
  /**
133
142
  * Whether an actor URI's host is refused by the instance domain policy. An
@@ -622,14 +631,21 @@ class ActorResolver {
622
631
  * still evaluated honestly and its activity is then dropped as policy, rather
623
632
  * than being reported as a forged signature. The uncached branch still refuses,
624
633
  * because resolving it would mean network I/O toward a blocked host.
634
+ *
635
+ * With `refresh`, the key is fetched again from the actor that owns it — asked
636
+ * when the stored key did not verify a signature, because an actor that rotated
637
+ * its key keeps its keyId. The stored actor is updated with what comes back.
625
638
  */
626
- async fetchPublicKey(keyId) {
639
+ async fetchPublicKey(keyId, options = {}) {
640
+ const stored = await this.config.store.findActorByPublicKeyId(keyId);
627
641
  // keyId is typically the actor URI with #main-key appended
628
- const actorUri = keyId.replace(/#.*$/, '');
629
- // Check local cache first
630
- const cached = await this.config.store.findActorByPublicKeyId(keyId);
631
- if (cached?.publicKeyPem) {
632
- return { publicKeyPem: cached.publicKeyPem, actorUri: cached.uri };
642
+ const actorUri = stored?.uri ?? keyId.replace(/#.*$/, '');
643
+ if (options.refresh) {
644
+ const actor = await this.refetchActorKey(actorUri);
645
+ return actor?.publicKeyPem ? { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri } : null;
646
+ }
647
+ if (stored?.publicKeyPem) {
648
+ return { publicKeyPem: stored.publicKeyPem, actorUri: stored.uri };
633
649
  }
634
650
  // Fetch the actor to get the public key (uses 24h cache)
635
651
  const actor = await this.getOrFetchActor(actorUri);
@@ -637,6 +653,28 @@ class ActorResolver {
637
653
  return null;
638
654
  return { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri };
639
655
  }
656
+ /**
657
+ * Fetch `actorUri` again for its current key, or null when it was fetched too
658
+ * recently for that to tell anything new (see {@link KEY_REFETCH_MIN_INTERVAL_MS})
659
+ * or cannot be fetched. `fetchRemoteActor` refuses a blocked host.
660
+ */
661
+ refetchActorKey(actorUri) {
662
+ let refetch = this.inFlightKeyRefetches.get(actorUri);
663
+ if (!refetch) {
664
+ refetch = (async () => {
665
+ const existing = await this.config.store.findActorByUri(actorUri);
666
+ const fetchedMs = existing?.lastFetchedAt?.getTime();
667
+ if (typeof fetchedMs === 'number' && Date.now() - fetchedMs < KEY_REFETCH_MIN_INTERVAL_MS) {
668
+ return null;
669
+ }
670
+ return this.fetchRemoteActor(actorUri, false, existing?.acct);
671
+ })().finally(() => {
672
+ this.inFlightKeyRefetches.delete(actorUri);
673
+ });
674
+ this.inFlightKeyRefetches.set(actorUri, refetch);
675
+ }
676
+ return refetch;
677
+ }
640
678
  }
641
679
  exports.ActorResolver = ActorResolver;
642
680
  /** Build the remote-actor resolver from an app's storage + identity + transports. */
@@ -87,7 +87,7 @@ function createActorRouter(config) {
87
87
  path: req.originalUrl || req.path,
88
88
  headers: req.headers,
89
89
  body: req.rawBody ?? req.body,
90
- }, (keyId) => config.inbound.fetchPublicKey(keyId), {
90
+ }, (keyId, options) => config.inbound.fetchPublicKey(keyId, options), {
91
91
  trustForwardedHost: config.inbound.trustForwardedHost,
92
92
  onDebug: (message, detail) => logger.debug(message, detail),
93
93
  });
@@ -161,19 +161,30 @@ export async function verifyHttpSignature(req, fetchPublicKey, options = {}) {
161
161
  return `${name}: ${Array.isArray(value) ? value[0] : value}`;
162
162
  });
163
163
  const signingString = signingParts.join('\n');
164
- const verifier = crypto.createVerify('sha256');
165
- verifier.update(signingString);
166
- verifier.end();
167
- try {
168
- const isValid = verifier.verify(keyData.publicKeyPem, parsed.signature, 'base64');
169
- return {
170
- verified: isValid,
171
- actorUri: isValid ? keyData.actorUri : undefined,
172
- reason: isValid ? undefined : 'verify-failed',
173
- };
174
- }
175
- catch (err) {
176
- options.onDebug?.('HTTP signature verification failed:', err);
177
- return { verified: false, reason: err instanceof Error ? err.message : 'verify-exception' };
164
+ /** `true` when `publicKeyPem` verifies the signature, otherwise why it does not. */
165
+ const check = (publicKeyPem) => {
166
+ const verifier = crypto.createVerify('sha256');
167
+ verifier.update(signingString);
168
+ verifier.end();
169
+ try {
170
+ return verifier.verify(publicKeyPem, parsed.signature, 'base64') || 'verify-failed';
171
+ }
172
+ catch (err) {
173
+ options.onDebug?.('HTTP signature verification failed:', err);
174
+ return err instanceof Error ? err.message : 'verify-exception';
175
+ }
176
+ };
177
+ const verdict = check(keyData.publicKeyPem);
178
+ if (verdict === true)
179
+ return { verified: true, actorUri: keyData.actorUri };
180
+ // The key may only be out of date: an actor that rotates its key keeps the
181
+ // keyId and signs with the new one. Ask the owner once before rejecting.
182
+ const fresh = await fetchPublicKey(parsed.keyId, { refresh: true });
183
+ if (!fresh || fresh.publicKeyPem === keyData.publicKeyPem) {
184
+ return { verified: false, reason: verdict };
178
185
  }
186
+ const freshVerdict = check(fresh.publicKeyPem);
187
+ return freshVerdict === true
188
+ ? { verified: true, actorUri: fresh.actorUri }
189
+ : { verified: false, reason: freshVerdict };
179
190
  }
@@ -28,6 +28,13 @@ import { readBodyChunks, ResponseBodyTooLargeError } from './boundedBody.js';
28
28
  const ACTOR_REFRESH_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
29
29
  /** Staleness threshold after which a cached actor is eligible for a background re-fetch. */
30
30
  const ACTOR_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
31
+ /**
32
+ * How recently fetched an actor must be for its stored key to count as current
33
+ * when a signature fails against it. An actor fetched this recently is not
34
+ * fetched again: the key that failed is the one it publishes, and anyone can
35
+ * send a bad signature naming a real actor's keyId.
36
+ */
37
+ const KEY_REFETCH_MIN_INTERVAL_MS = 60 * 1000; // 1 minute
31
38
  /** The AP content type asked for on signed actor/collection fetches. */
32
39
  const AP_CONTENT_TYPE = 'application/activity+json';
33
40
  /** Maximum decompressed response sizes accepted from untrusted federation hosts. */
@@ -124,6 +131,8 @@ export class ActorResolver {
124
131
  this.config = config;
125
132
  /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
126
133
  this.inFlightActorRefreshes = new Set();
134
+ /** Actor key refetches in flight, by actor URI: concurrent failed signatures share one. */
135
+ this.inFlightKeyRefetches = new Map();
127
136
  }
128
137
  /**
129
138
  * Whether an actor URI's host is refused by the instance domain policy. An
@@ -618,14 +627,21 @@ export class ActorResolver {
618
627
  * still evaluated honestly and its activity is then dropped as policy, rather
619
628
  * than being reported as a forged signature. The uncached branch still refuses,
620
629
  * because resolving it would mean network I/O toward a blocked host.
630
+ *
631
+ * With `refresh`, the key is fetched again from the actor that owns it — asked
632
+ * when the stored key did not verify a signature, because an actor that rotated
633
+ * its key keeps its keyId. The stored actor is updated with what comes back.
621
634
  */
622
- async fetchPublicKey(keyId) {
635
+ async fetchPublicKey(keyId, options = {}) {
636
+ const stored = await this.config.store.findActorByPublicKeyId(keyId);
623
637
  // keyId is typically the actor URI with #main-key appended
624
- const actorUri = keyId.replace(/#.*$/, '');
625
- // Check local cache first
626
- const cached = await this.config.store.findActorByPublicKeyId(keyId);
627
- if (cached?.publicKeyPem) {
628
- return { publicKeyPem: cached.publicKeyPem, actorUri: cached.uri };
638
+ const actorUri = stored?.uri ?? keyId.replace(/#.*$/, '');
639
+ if (options.refresh) {
640
+ const actor = await this.refetchActorKey(actorUri);
641
+ return actor?.publicKeyPem ? { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri } : null;
642
+ }
643
+ if (stored?.publicKeyPem) {
644
+ return { publicKeyPem: stored.publicKeyPem, actorUri: stored.uri };
629
645
  }
630
646
  // Fetch the actor to get the public key (uses 24h cache)
631
647
  const actor = await this.getOrFetchActor(actorUri);
@@ -633,6 +649,28 @@ export class ActorResolver {
633
649
  return null;
634
650
  return { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri };
635
651
  }
652
+ /**
653
+ * Fetch `actorUri` again for its current key, or null when it was fetched too
654
+ * recently for that to tell anything new (see {@link KEY_REFETCH_MIN_INTERVAL_MS})
655
+ * or cannot be fetched. `fetchRemoteActor` refuses a blocked host.
656
+ */
657
+ refetchActorKey(actorUri) {
658
+ let refetch = this.inFlightKeyRefetches.get(actorUri);
659
+ if (!refetch) {
660
+ refetch = (async () => {
661
+ const existing = await this.config.store.findActorByUri(actorUri);
662
+ const fetchedMs = existing?.lastFetchedAt?.getTime();
663
+ if (typeof fetchedMs === 'number' && Date.now() - fetchedMs < KEY_REFETCH_MIN_INTERVAL_MS) {
664
+ return null;
665
+ }
666
+ return this.fetchRemoteActor(actorUri, false, existing?.acct);
667
+ })().finally(() => {
668
+ this.inFlightKeyRefetches.delete(actorUri);
669
+ });
670
+ this.inFlightKeyRefetches.set(actorUri, refetch);
671
+ }
672
+ return refetch;
673
+ }
636
674
  }
637
675
  /** Build the remote-actor resolver from an app's storage + identity + transports. */
638
676
  export function createActorResolver(config) {
@@ -84,7 +84,7 @@ export function createActorRouter(config) {
84
84
  path: req.originalUrl || req.path,
85
85
  headers: req.headers,
86
86
  body: req.rawBody ?? req.body,
87
- }, (keyId) => config.inbound.fetchPublicKey(keyId), {
87
+ }, (keyId, options) => config.inbound.fetchPublicKey(keyId, options), {
88
88
  trustForwardedHost: config.inbound.trustForwardedHost,
89
89
  onDebug: (message, detail) => logger.debug(message, detail),
90
90
  });
@@ -74,11 +74,21 @@ export interface VerifyHttpResult {
74
74
  actorUri?: string;
75
75
  reason?: string;
76
76
  }
77
+ /** How {@link FetchPublicKey} is asked for a key. */
78
+ export interface FetchPublicKeyOptions {
79
+ /**
80
+ * Fetch the key from the actor that owns it rather than from any copy kept
81
+ * locally. {@link verifyHttpSignature} asks this once when the key it was
82
+ * given does not verify: an actor that rotated its key signs with one a
83
+ * stored copy has never seen. `null` means nothing newer could be had.
84
+ */
85
+ refresh?: boolean;
86
+ }
77
87
  /**
78
88
  * Resolve a `keyId` to its public key PEM and the actor URI that owns it, or
79
89
  * `null` when the key cannot be fetched (a failed fetch fails verification).
80
90
  */
81
- export type FetchPublicKey = (keyId: string) => Promise<{
91
+ export type FetchPublicKey = (keyId: string, options?: FetchPublicKeyOptions) => Promise<{
82
92
  publicKeyPem: string;
83
93
  actorUri: string;
84
94
  } | null>;
@@ -25,7 +25,7 @@
25
25
  * ActivityPub federation shares. Private-key custody is injected; the key never
26
26
  * enters this package.
27
27
  */
28
- export { signRequest, verifyHttpSignature, HTTP_SIGNATURE_ALGORITHM, DEFAULT_SIGNED_CONTENT_TYPE, type HttpSignatureSigner, type SignRequestOptions, type VerifyHttpRequest, type VerifyHttpResult, type FetchPublicKey, type VerifyHttpSignatureOptions, } from './httpSignature';
28
+ export { signRequest, verifyHttpSignature, HTTP_SIGNATURE_ALGORITHM, DEFAULT_SIGNED_CONTENT_TYPE, type HttpSignatureSigner, type SignRequestOptions, type VerifyHttpRequest, type VerifyHttpResult, type FetchPublicKey, type FetchPublicKeyOptions, type VerifyHttpSignatureOptions, } from './httpSignature';
29
29
  /**
30
30
  * Domain-parameterized ActivityPub URL builders — each app instantiates them once
31
31
  * with its own `FEDERATION_DOMAIN` so every actor stays `@user@its-own-domain`.
@@ -22,6 +22,7 @@ import { type DeriveNetworkIdentity } from '../networkIdentity';
22
22
  import type { NormalizedExternalActor } from '../index';
23
23
  import type { SignedFetch } from './signedFetch';
24
24
  import type { ReportActorGoneOutcome } from './identityBridge';
25
+ import type { FetchPublicKeyOptions } from '../httpSignature';
25
26
  /** The minimal fields the resolver reads off / writes to a stored actor record. */
26
27
  export interface FederatedActorRecordBase {
27
28
  _id?: unknown;
@@ -221,6 +222,8 @@ export declare class ActorResolver<TActor extends FederatedActorRecordBase> {
221
222
  private readonly config;
222
223
  /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
223
224
  private readonly inFlightActorRefreshes;
225
+ /** Actor key refetches in flight, by actor URI: concurrent failed signatures share one. */
226
+ private readonly inFlightKeyRefetches;
224
227
  constructor(config: ActorResolverConfig<TActor>);
225
228
  /**
226
229
  * Whether an actor URI's host is refused by the instance domain policy. An
@@ -312,11 +315,21 @@ export declare class ActorResolver<TActor extends FederatedActorRecordBase> {
312
315
  * still evaluated honestly and its activity is then dropped as policy, rather
313
316
  * than being reported as a forged signature. The uncached branch still refuses,
314
317
  * because resolving it would mean network I/O toward a blocked host.
318
+ *
319
+ * With `refresh`, the key is fetched again from the actor that owns it — asked
320
+ * when the stored key did not verify a signature, because an actor that rotated
321
+ * its key keeps its keyId. The stored actor is updated with what comes back.
315
322
  */
316
- fetchPublicKey(keyId: string): Promise<{
323
+ fetchPublicKey(keyId: string, options?: FetchPublicKeyOptions): Promise<{
317
324
  publicKeyPem: string;
318
325
  actorUri: string;
319
326
  } | null>;
327
+ /**
328
+ * Fetch `actorUri` again for its current key, or null when it was fetched too
329
+ * recently for that to tell anything new (see {@link KEY_REFETCH_MIN_INTERVAL_MS})
330
+ * or cannot be fetched. `fetchRemoteActor` refuses a blocked host.
331
+ */
332
+ private refetchActorKey;
320
333
  }
321
334
  /** Build the remote-actor resolver from an app's storage + identity + transports. */
322
335
  export declare function createActorResolver<TActor extends FederatedActorRecordBase>(config: ActorResolverConfig<TActor>): ActorResolver<TActor>;
@@ -23,6 +23,7 @@
23
23
  import { Router } from 'express';
24
24
  import type { AccountKind } from '@oxy.so/contracts';
25
25
  import type { User } from '@oxy.so/core';
26
+ import { type FetchPublicKey } from '../httpSignature';
26
27
  import type { UrlBuilders } from '../urls';
27
28
  import type { LocalActorBuilder } from '../actorObject';
28
29
  import { type IsActorGone } from './goneActor';
@@ -93,11 +94,11 @@ export interface ActorRouterConfig {
93
94
  getBanner(oxyUserId: string): Promise<string | null>;
94
95
  /** Inbound-delivery adapters. */
95
96
  inbound: {
96
- /** Resolve a `keyId` to its public key PEM + owning actor uri (HTTP-sig verify). */
97
- fetchPublicKey(keyId: string): Promise<{
98
- publicKeyPem: string;
99
- actorUri: string;
100
- } | null>;
97
+ /**
98
+ * Resolve a `keyId` to its public key PEM + owning actor uri (HTTP-sig verify).
99
+ * Asked again with `refresh` when the key it gave does not verify.
100
+ */
101
+ fetchPublicKey: FetchPublicKey;
101
102
  /** Whether to trust `X-Forwarded-Host` when reconstructing the signed host line. */
102
103
  trustForwardedHost: boolean;
103
104
  /** Enqueue a verified inbound activity for async processing (false ⇒ process inline). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/federation",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "Oxy Federation — the app-agnostic ActivityPub identity + follow engine substrate: the network-connector contract, normalized cross-network DTOs, HTTP signatures, actor resolution, the outbound delivery transport + follow lifecycle, the inbound dispatcher, and the webfinger/actor/inbox Express routers. Domain-parameterized so every Oxy app backend federates under its own domain.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -75,12 +75,11 @@
75
75
  "clean": "rm -rf dist",
76
76
  "typescript": "tsc --noEmit",
77
77
  "test": "jest --passWithNoTests",
78
- "lint": "biome lint --error-on-warnings ./src",
79
- "prepublishOnly": "node ../../scripts/assert-bun-publish.mjs && bun run clean && bun run build"
78
+ "lint": "biome lint --error-on-warnings ./src"
80
79
  },
81
80
  "dependencies": {
82
- "@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || 4.11.0-oxy-one.0",
83
- "@oxy.so/core": "^6.14.0"
81
+ "@oxy.so/contracts": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || 4.11.0-oxy-one.0",
82
+ "@oxy.so/core": "^6.14.0 || ^7.0.0"
84
83
  },
85
84
  "peerDependencies": {
86
85
  "express": "^4.18.0 || ^5.0.0"
@@ -173,6 +173,26 @@ describe('fetchPublicKey — signature lookups stay honest', () => {
173
173
  });
174
174
  });
175
175
 
176
+ it('refetches a key on refresh, unless its actor was fetched just now', async () => {
177
+ const keyId = `${ALLOWED_ACTOR}#main-key`;
178
+ const cachedKey = { uri: ALLOWED_ACTOR, publicKeyPem: 'PEM' };
179
+ const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
180
+
181
+ const stale = makeResolver({
182
+ cachedKey,
183
+ cached: { uri: ALLOWED_ACTOR, lastFetchedAt: lastWeek },
184
+ });
185
+ await stale.resolver.fetchPublicKey(keyId, { refresh: true });
186
+ expect(stale.signedFetchCalls).toEqual([ALLOWED_ACTOR]);
187
+
188
+ const fresh = makeResolver({
189
+ cachedKey,
190
+ cached: { uri: ALLOWED_ACTOR, lastFetchedAt: new Date() },
191
+ });
192
+ await expect(fresh.resolver.fetchPublicKey(keyId, { refresh: true })).resolves.toBeNull();
193
+ expect(fresh.signedFetchCalls).toHaveLength(0);
194
+ });
195
+
176
196
  it('will not resolve a blocked domain key that would require network I/O', async () => {
177
197
  const rig = makeResolver({ cachedKey: null, cached: null });
178
198
  await expect(rig.resolver.fetchPublicKey(`${BLOCKED_ACTOR}#main-key`)).resolves.toBeNull();
@@ -249,6 +249,28 @@ describe('verifyHttpSignature', () => {
249
249
  expect(result.reason).toBe('verify-failed');
250
250
  });
251
251
 
252
+ it('asks for the key again once when the stored one is out of date', async () => {
253
+ const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
254
+ const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
255
+ const { publicKey: replacedKey } = crypto.generateKeyPairSync('rsa', {
256
+ modulusLength: 2048,
257
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
258
+ });
259
+ const asked: unknown[] = [];
260
+ const result = await verifyHttpSignature(
261
+ { method: 'POST', path: new URL(INBOX_URL).pathname, headers: lowerHeaders(signed), body },
262
+ async (keyId, options) => {
263
+ asked.push(options);
264
+ return {
265
+ publicKeyPem: options?.refresh ? TEST_PUBLIC_KEY_PEM : replacedKey,
266
+ actorUri: ACTOR_URI,
267
+ };
268
+ },
269
+ );
270
+ expect(result).toEqual({ verified: true, actorUri: ACTOR_URI });
271
+ expect(asked).toEqual([undefined, { refresh: true }]);
272
+ });
273
+
252
274
  it('rejects when the Date header is outside the allowed skew', async () => {
253
275
  const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
254
276
  const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
@@ -155,12 +155,24 @@ export interface VerifyHttpResult {
155
155
  reason?: string;
156
156
  }
157
157
 
158
+ /** How {@link FetchPublicKey} is asked for a key. */
159
+ export interface FetchPublicKeyOptions {
160
+ /**
161
+ * Fetch the key from the actor that owns it rather than from any copy kept
162
+ * locally. {@link verifyHttpSignature} asks this once when the key it was
163
+ * given does not verify: an actor that rotated its key signs with one a
164
+ * stored copy has never seen. `null` means nothing newer could be had.
165
+ */
166
+ refresh?: boolean;
167
+ }
168
+
158
169
  /**
159
170
  * Resolve a `keyId` to its public key PEM and the actor URI that owns it, or
160
171
  * `null` when the key cannot be fetched (a failed fetch fails verification).
161
172
  */
162
173
  export type FetchPublicKey = (
163
174
  keyId: string,
175
+ options?: FetchPublicKeyOptions,
164
176
  ) => Promise<{ publicKeyPem: string; actorUri: string } | null>;
165
177
 
166
178
  /** Options controlling inbound signature verification. */
@@ -264,19 +276,30 @@ export async function verifyHttpSignature(
264
276
  });
265
277
 
266
278
  const signingString = signingParts.join('\n');
267
- const verifier = crypto.createVerify('sha256');
268
- verifier.update(signingString);
269
- verifier.end();
279
+ /** `true` when `publicKeyPem` verifies the signature, otherwise why it does not. */
280
+ const check = (publicKeyPem: string): true | string => {
281
+ const verifier = crypto.createVerify('sha256');
282
+ verifier.update(signingString);
283
+ verifier.end();
284
+ try {
285
+ return verifier.verify(publicKeyPem, parsed.signature, 'base64') || 'verify-failed';
286
+ } catch (err) {
287
+ options.onDebug?.('HTTP signature verification failed:', err);
288
+ return err instanceof Error ? err.message : 'verify-exception';
289
+ }
290
+ };
291
+
292
+ const verdict = check(keyData.publicKeyPem);
293
+ if (verdict === true) return { verified: true, actorUri: keyData.actorUri };
270
294
 
271
- try {
272
- const isValid = verifier.verify(keyData.publicKeyPem, parsed.signature, 'base64');
273
- return {
274
- verified: isValid,
275
- actorUri: isValid ? keyData.actorUri : undefined,
276
- reason: isValid ? undefined : 'verify-failed',
277
- };
278
- } catch (err) {
279
- options.onDebug?.('HTTP signature verification failed:', err);
280
- return { verified: false, reason: err instanceof Error ? err.message : 'verify-exception' };
295
+ // The key may only be out of date: an actor that rotates its key keeps the
296
+ // keyId and signs with the new one. Ask the owner once before rejecting.
297
+ const fresh = await fetchPublicKey(parsed.keyId, { refresh: true });
298
+ if (!fresh || fresh.publicKeyPem === keyData.publicKeyPem) {
299
+ return { verified: false, reason: verdict };
281
300
  }
301
+ const freshVerdict = check(fresh.publicKeyPem);
302
+ return freshVerdict === true
303
+ ? { verified: true, actorUri: fresh.actorUri }
304
+ : { verified: false, reason: freshVerdict };
282
305
  }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  type VerifyHttpRequest,
37
37
  type VerifyHttpResult,
38
38
  type FetchPublicKey,
39
+ type FetchPublicKeyOptions,
39
40
  type VerifyHttpSignatureOptions,
40
41
  } from './httpSignature';
41
42
 
@@ -30,6 +30,7 @@ import type { NormalizedExternalActor } from '../index';
30
30
  import { readBodyChunks, ResponseBodyTooLargeError } from './boundedBody';
31
31
  import type { SignedFetch } from './signedFetch';
32
32
  import type { ReportActorGoneOutcome } from './identityBridge';
33
+ import type { FetchPublicKeyOptions } from '../httpSignature';
33
34
 
34
35
  /**
35
36
  * Minimum interval between background actor refreshes for the same actor.
@@ -40,6 +41,14 @@ const ACTOR_REFRESH_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
40
41
  /** Staleness threshold after which a cached actor is eligible for a background re-fetch. */
41
42
  const ACTOR_STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
42
43
 
44
+ /**
45
+ * How recently fetched an actor must be for its stored key to count as current
46
+ * when a signature fails against it. An actor fetched this recently is not
47
+ * fetched again: the key that failed is the one it publishes, and anyone can
48
+ * send a bad signature naming a real actor's keyId.
49
+ */
50
+ const KEY_REFETCH_MIN_INTERVAL_MS = 60 * 1000; // 1 minute
51
+
43
52
  /** The AP content type asked for on signed actor/collection fetches. */
44
53
  const AP_CONTENT_TYPE = 'application/activity+json';
45
54
 
@@ -342,6 +351,9 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
342
351
  /** Actor URIs with an in-flight background refresh (guards against refresh storms). */
343
352
  private readonly inFlightActorRefreshes = new Set<string>();
344
353
 
354
+ /** Actor key refetches in flight, by actor URI: concurrent failed signatures share one. */
355
+ private readonly inFlightKeyRefetches = new Map<string, Promise<TActor | null>>();
356
+
345
357
  constructor(private readonly config: ActorResolverConfig<TActor>) {}
346
358
 
347
359
  /**
@@ -897,15 +909,26 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
897
909
  * still evaluated honestly and its activity is then dropped as policy, rather
898
910
  * than being reported as a forged signature. The uncached branch still refuses,
899
911
  * because resolving it would mean network I/O toward a blocked host.
912
+ *
913
+ * With `refresh`, the key is fetched again from the actor that owns it — asked
914
+ * when the stored key did not verify a signature, because an actor that rotated
915
+ * its key keeps its keyId. The stored actor is updated with what comes back.
900
916
  */
901
- async fetchPublicKey(keyId: string): Promise<{ publicKeyPem: string; actorUri: string } | null> {
917
+ async fetchPublicKey(
918
+ keyId: string,
919
+ options: FetchPublicKeyOptions = {},
920
+ ): Promise<{ publicKeyPem: string; actorUri: string } | null> {
921
+ const stored = await this.config.store.findActorByPublicKeyId(keyId);
902
922
  // keyId is typically the actor URI with #main-key appended
903
- const actorUri = keyId.replace(/#.*$/, '');
923
+ const actorUri = stored?.uri ?? keyId.replace(/#.*$/, '');
924
+
925
+ if (options.refresh) {
926
+ const actor = await this.refetchActorKey(actorUri);
927
+ return actor?.publicKeyPem ? { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri } : null;
928
+ }
904
929
 
905
- // Check local cache first
906
- const cached = await this.config.store.findActorByPublicKeyId(keyId);
907
- if (cached?.publicKeyPem) {
908
- return { publicKeyPem: cached.publicKeyPem, actorUri: cached.uri };
930
+ if (stored?.publicKeyPem) {
931
+ return { publicKeyPem: stored.publicKeyPem, actorUri: stored.uri };
909
932
  }
910
933
 
911
934
  // Fetch the actor to get the public key (uses 24h cache)
@@ -914,6 +937,29 @@ export class ActorResolver<TActor extends FederatedActorRecordBase> {
914
937
 
915
938
  return { publicKeyPem: actor.publicKeyPem, actorUri: actor.uri };
916
939
  }
940
+
941
+ /**
942
+ * Fetch `actorUri` again for its current key, or null when it was fetched too
943
+ * recently for that to tell anything new (see {@link KEY_REFETCH_MIN_INTERVAL_MS})
944
+ * or cannot be fetched. `fetchRemoteActor` refuses a blocked host.
945
+ */
946
+ private refetchActorKey(actorUri: string): Promise<TActor | null> {
947
+ let refetch = this.inFlightKeyRefetches.get(actorUri);
948
+ if (!refetch) {
949
+ refetch = (async () => {
950
+ const existing = await this.config.store.findActorByUri(actorUri);
951
+ const fetchedMs = existing?.lastFetchedAt?.getTime();
952
+ if (typeof fetchedMs === 'number' && Date.now() - fetchedMs < KEY_REFETCH_MIN_INTERVAL_MS) {
953
+ return null;
954
+ }
955
+ return this.fetchRemoteActor(actorUri, false, existing?.acct);
956
+ })().finally(() => {
957
+ this.inFlightKeyRefetches.delete(actorUri);
958
+ });
959
+ this.inFlightKeyRefetches.set(actorUri, refetch);
960
+ }
961
+ return refetch;
962
+ }
917
963
  }
918
964
 
919
965
  /** Build the remote-actor resolver from an app's storage + identity + transports. */
@@ -26,7 +26,7 @@ import type { AccountKind } from '@oxy.so/contracts';
26
26
  import type { User } from '@oxy.so/core';
27
27
  import { AP_CONTEXT } from '../apContext';
28
28
  import { discoveryCacheControl } from './httpCacheControl';
29
- import { verifyHttpSignature } from '../httpSignature';
29
+ import { type FetchPublicKey, verifyHttpSignature } from '../httpSignature';
30
30
  import type { UrlBuilders } from '../urls';
31
31
  import { INSTANCE_ACTOR_USERNAME, normalizeActorUsername } from '../urls';
32
32
  import type { LocalActorBuilder } from '../actorObject';
@@ -104,8 +104,11 @@ export interface ActorRouterConfig {
104
104
  getBanner(oxyUserId: string): Promise<string | null>;
105
105
  /** Inbound-delivery adapters. */
106
106
  inbound: {
107
- /** Resolve a `keyId` to its public key PEM + owning actor uri (HTTP-sig verify). */
108
- fetchPublicKey(keyId: string): Promise<{ publicKeyPem: string; actorUri: string } | null>;
107
+ /**
108
+ * Resolve a `keyId` to its public key PEM + owning actor uri (HTTP-sig verify).
109
+ * Asked again with `refresh` when the key it gave does not verify.
110
+ */
111
+ fetchPublicKey: FetchPublicKey;
109
112
  /** Whether to trust `X-Forwarded-Host` when reconstructing the signed host line. */
110
113
  trustForwardedHost: boolean;
111
114
  /** Enqueue a verified inbound activity for async processing (false ⇒ process inline). */
@@ -212,7 +215,7 @@ export function createActorRouter(config: ActorRouterConfig): Router {
212
215
  headers: req.headers as Record<string, string | string[] | undefined>,
213
216
  body: req.rawBody ?? req.body,
214
217
  },
215
- (keyId) => config.inbound.fetchPublicKey(keyId),
218
+ (keyId, options) => config.inbound.fetchPublicKey(keyId, options),
216
219
  {
217
220
  trustForwardedHost: config.inbound.trustForwardedHost,
218
221
  onDebug: (message, detail) => logger.debug(message, detail),