@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.
- package/dist/cjs/httpSignature.js +25 -14
- package/dist/cjs/node/actorResolver.js +44 -6
- package/dist/cjs/node/actorRouter.js +1 -1
- package/dist/esm/httpSignature.js +25 -14
- package/dist/esm/node/actorResolver.js +44 -6
- package/dist/esm/node/actorRouter.js +1 -1
- package/dist/types/httpSignature.d.ts +11 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/node/actorResolver.d.ts +14 -1
- package/dist/types/node/actorRouter.d.ts +6 -5
- package/package.json +4 -5
- package/src/__tests__/actorResolver.test.ts +20 -0
- package/src/__tests__/httpSignature.test.ts +22 -0
- package/src/httpSignature.ts +36 -13
- package/src/index.ts +1 -0
- package/src/node/actorResolver.ts +52 -6
- package/src/node/actorRouter.ts +7 -4
|
@@ -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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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);
|
package/src/httpSignature.ts
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
906
|
-
|
|
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. */
|
package/src/node/actorRouter.ts
CHANGED
|
@@ -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
|
-
/**
|
|
108
|
-
|
|
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),
|