@oxy.so/federation 1.0.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/actorObject.js +216 -0
- package/dist/cjs/apContext.js +48 -0
- package/dist/cjs/apUri.js +132 -0
- package/dist/cjs/httpSignature.js +187 -0
- package/dist/cjs/index.js +99 -0
- package/dist/cjs/networkIdentity.js +487 -0
- package/dist/cjs/node/actorResolver.js +625 -0
- package/dist/cjs/node/actorRouter.js +307 -0
- package/dist/cjs/node/delivery.js +415 -0
- package/dist/cjs/node/identityBridge.js +133 -0
- package/dist/cjs/node/inboundDispatch.js +268 -0
- package/dist/cjs/node/index.js +63 -0
- package/dist/cjs/node/signedFetch.js +122 -0
- package/dist/cjs/node/webfingerRouter.js +166 -0
- package/dist/cjs/urls.js +55 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/actorObject.js +210 -0
- package/dist/esm/apContext.js +45 -0
- package/dist/esm/apUri.js +126 -0
- package/dist/esm/httpSignature.js +179 -0
- package/dist/esm/index.js +65 -0
- package/dist/esm/networkIdentity.js +472 -0
- package/dist/esm/node/actorResolver.js +620 -0
- package/dist/esm/node/actorRouter.js +304 -0
- package/dist/esm/node/delivery.js +412 -0
- package/dist/esm/node/identityBridge.js +130 -0
- package/dist/esm/node/inboundDispatch.js +263 -0
- package/dist/esm/node/index.js +51 -0
- package/dist/esm/node/signedFetch.js +119 -0
- package/dist/esm/node/webfingerRouter.js +163 -0
- package/dist/esm/urls.js +50 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/actorObject.d.ts +182 -0
- package/dist/types/apContext.d.ts +35 -0
- package/dist/types/apUri.d.ts +107 -0
- package/dist/types/httpSignature.d.ts +113 -0
- package/dist/types/index.d.ts +336 -0
- package/dist/types/networkIdentity.d.ts +509 -0
- package/dist/types/node/actorResolver.d.ts +287 -0
- package/dist/types/node/actorRouter.d.ts +108 -0
- package/dist/types/node/delivery.d.ts +248 -0
- package/dist/types/node/identityBridge.d.ts +84 -0
- package/dist/types/node/inboundDispatch.d.ts +156 -0
- package/dist/types/node/index.d.ts +51 -0
- package/dist/types/node/signedFetch.d.ts +74 -0
- package/dist/types/node/webfingerRouter.d.ts +62 -0
- package/dist/types/urls.d.ts +55 -0
- package/package.json +119 -0
- package/src/__tests__/actorObject.test.ts +258 -0
- package/src/__tests__/actorResolver.test.ts +252 -0
- package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
- package/src/__tests__/apUri.test.ts +53 -0
- package/src/__tests__/delivery.test.ts +432 -0
- package/src/__tests__/federationHost.test.ts +281 -0
- package/src/__tests__/httpSignature.test.ts +343 -0
- package/src/__tests__/inboundDispatch.test.ts +381 -0
- package/src/__tests__/index.test.ts +8 -0
- package/src/__tests__/networkIdentity.test.ts +525 -0
- package/src/__tests__/routers.test.ts +460 -0
- package/src/__tests__/urls.test.ts +26 -0
- package/src/actorObject.ts +313 -0
- package/src/apContext.ts +45 -0
- package/src/apUri.ts +161 -0
- package/src/httpSignature.ts +282 -0
- package/src/index.ts +419 -0
- package/src/networkIdentity.ts +731 -0
- package/src/node/actorResolver.ts +839 -0
- package/src/node/actorRouter.ts +438 -0
- package/src/node/delivery.ts +729 -0
- package/src/node/identityBridge.ts +230 -0
- package/src/node/inboundDispatch.ts +420 -0
- package/src/node/index.ts +136 -0
- package/src/node/signedFetch.ts +177 -0
- package/src/node/webfingerRouter.ts +226 -0
- package/src/urls.ts +71 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { canonicalFederationHost, createDomainPolicy, isSameFederationHost } from '../index';
|
|
2
|
+
import { createInboundDispatcher, type InboundDispatcherConfig } from '../node/inboundDispatch';
|
|
3
|
+
import {
|
|
4
|
+
ActorResolver,
|
|
5
|
+
type ActorResolverConfig,
|
|
6
|
+
type FederatedActorRecordBase,
|
|
7
|
+
} from '../node/actorResolver';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ONE ANSWER TO "IS THIS THE SAME HOST", PROVEN ON THE WIRE.
|
|
11
|
+
*
|
|
12
|
+
* `canonicalFederationHost` is exported so that a consumer comparing a host
|
|
13
|
+
* against this engine's blocklist — a transparency page, an irreversible content
|
|
14
|
+
* purge — uses the engine's own rule instead of a copy that agrees by
|
|
15
|
+
* inspection. That guarantee is worth exactly as much as the evidence that the
|
|
16
|
+
* exported function and the code the wire actually runs cannot disagree, so the
|
|
17
|
+
* agreement is asserted rather than asserted-about:
|
|
18
|
+
*
|
|
19
|
+
* - the INBOUND path: `processInboxActivity` parses a verified actor URI and
|
|
20
|
+
* drops the activity when its host is blocked;
|
|
21
|
+
* - the OUTBOUND-resolution path: `ActorResolver.fetchRemoteActor` screens the
|
|
22
|
+
* URI's host before any network I/O;
|
|
23
|
+
* - and the raw-host path a consumer calls directly, `isBlockedDomain`.
|
|
24
|
+
*
|
|
25
|
+
* Each is driven with the awkward spellings — case, `www.`, a trailing dot, an
|
|
26
|
+
* internationalised host in both punycode and unicode — and its verdict is
|
|
27
|
+
* compared against `isSameFederationHost`. A case where they differ is the bug
|
|
28
|
+
* this export exists to make impossible.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The blocked instance every case in this file is compared against. */
|
|
32
|
+
const BLOCKED_ENTRY = 'spam.example';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The awkward spellings, as an ACTOR URI (what the wire carries) plus the
|
|
36
|
+
* bare-host spelling a blocklist would hold.
|
|
37
|
+
*
|
|
38
|
+
* `expectedBlocked` is written out rather than derived, so the table states the
|
|
39
|
+
* intended behaviour independently of the functions under test; the agreement
|
|
40
|
+
* assertions then check the wire and the exported predicate against it AND
|
|
41
|
+
* against each other.
|
|
42
|
+
*/
|
|
43
|
+
const CASES: ReadonlyArray<{
|
|
44
|
+
name: string;
|
|
45
|
+
actorUri: string;
|
|
46
|
+
/** The blocklist entry to compare against; the default block entry unless stated. */
|
|
47
|
+
entry?: string;
|
|
48
|
+
expectedBlocked: boolean;
|
|
49
|
+
}> = [
|
|
50
|
+
{ name: 'exact host', actorUri: 'https://spam.example/users/bob', expectedBlocked: true },
|
|
51
|
+
{ name: 'uppercase host', actorUri: 'https://SPAM.EXAMPLE/users/bob', expectedBlocked: true },
|
|
52
|
+
{ name: 'www. on the wire', actorUri: 'https://www.spam.example/users/bob', expectedBlocked: true },
|
|
53
|
+
{
|
|
54
|
+
name: 'www. on the blocklist entry, bare on the wire',
|
|
55
|
+
actorUri: 'https://spam.example/users/bob',
|
|
56
|
+
entry: 'www.spam.example',
|
|
57
|
+
expectedBlocked: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'whitespace around the blocklist entry',
|
|
61
|
+
actorUri: 'https://spam.example/users/bob',
|
|
62
|
+
entry: ' Spam.Example ',
|
|
63
|
+
expectedBlocked: true,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
// The fully-qualified spelling is a DIFFERENT string here and on the wire
|
|
67
|
+
// alike — `new URL('https://spam.example./x').hostname` keeps the dot — so a
|
|
68
|
+
// blocklist entry without one does not match it. Widening that is a policy
|
|
69
|
+
// decision, not a canonicalisation one; what matters for this file is that
|
|
70
|
+
// the exported rule and the engine reach the same verdict.
|
|
71
|
+
name: 'trailing dot on the wire (not matched by a dot-less entry)',
|
|
72
|
+
actorUri: 'https://spam.example./users/bob',
|
|
73
|
+
expectedBlocked: false,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'internationalised host, entry written in punycode',
|
|
77
|
+
actorUri: 'https://xn--ber-goa.example/users/bob',
|
|
78
|
+
entry: 'xn--ber-goa.example',
|
|
79
|
+
expectedBlocked: true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
// The URL parser applies IDNA ToASCII, so the wire host is punycode; an entry
|
|
83
|
+
// typed in unicode is a different string and matches nothing.
|
|
84
|
+
name: 'internationalised host, entry written in unicode',
|
|
85
|
+
actorUri: 'https://über.example/users/bob',
|
|
86
|
+
entry: 'über.example',
|
|
87
|
+
expectedBlocked: false,
|
|
88
|
+
},
|
|
89
|
+
{ name: 'unrelated host', actorUri: 'https://mastodon.social/users/alice', expectedBlocked: false },
|
|
90
|
+
{
|
|
91
|
+
name: 'blocked host as a subdomain prefix',
|
|
92
|
+
actorUri: 'https://spam.example.evil.test/users/bob',
|
|
93
|
+
expectedBlocked: false,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'blocked host as a suffix',
|
|
97
|
+
actorUri: 'https://notspam.example/users/bob',
|
|
98
|
+
expectedBlocked: false,
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
/** The host the engine screens, extracted exactly as every wire path extracts it. */
|
|
103
|
+
function wireHost(actorUri: string): string {
|
|
104
|
+
return new URL(actorUri).hostname;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function entryOf(testCase: (typeof CASES)[number]): string {
|
|
108
|
+
return testCase.entry ?? BLOCKED_ENTRY;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function policyFor(entry: string) {
|
|
112
|
+
return createDomainPolicy({ domain: 'mention.earth', blockedDomains: [entry] });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- the wire paths ----------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run one activity through the real inbound dispatcher and report whether the
|
|
119
|
+
* domain gate dropped it. Every collaborator below the gate records instead of
|
|
120
|
+
* acting, so "reached the content handler" is unambiguous.
|
|
121
|
+
*/
|
|
122
|
+
async function inboundDroppedActivity(actorUri: string, entry: string): Promise<boolean> {
|
|
123
|
+
const contentActivities: string[] = [];
|
|
124
|
+
const config: InboundDispatcherConfig = {
|
|
125
|
+
isBlockedDomain: policyFor(entry).isBlockedDomain,
|
|
126
|
+
validateActivity: () => ({ ok: true, type: 'Create' }),
|
|
127
|
+
identity: {
|
|
128
|
+
resolveUserByUsername: async () => null,
|
|
129
|
+
bridgeFollow: async () => undefined,
|
|
130
|
+
bridgeUnfollow: async () => undefined,
|
|
131
|
+
},
|
|
132
|
+
consent: { isSharingEnabledFromUser: () => true },
|
|
133
|
+
actorResolver: { getOrFetchActor: async () => null },
|
|
134
|
+
follows: {
|
|
135
|
+
upsertInboundAccepted: async () => undefined,
|
|
136
|
+
findInboundFollow: async () => null,
|
|
137
|
+
deleteFollowById: async () => undefined,
|
|
138
|
+
findActorOxyUserId: async () => null,
|
|
139
|
+
markOutboundAcceptedByActivityId: async () => false,
|
|
140
|
+
markOutboundAcceptedAnyPending: async () => false,
|
|
141
|
+
markOutboundRejected: async () => undefined,
|
|
142
|
+
},
|
|
143
|
+
delivery: { sendAccept: async () => undefined },
|
|
144
|
+
onContentActivity: async (_activity, verifiedActorUri) => {
|
|
145
|
+
contentActivities.push(verifiedActorUri);
|
|
146
|
+
},
|
|
147
|
+
logger: { debug: () => undefined, info: () => undefined, warn: () => undefined },
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
await createInboundDispatcher(config).processInboxActivity(
|
|
151
|
+
{ type: 'Create', id: `${actorUri}/statuses/1`, actor: actorUri },
|
|
152
|
+
actorUri,
|
|
153
|
+
);
|
|
154
|
+
return contentActivities.length === 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A sentinel the actor resolver's transports throw, so a call is unmistakable. */
|
|
158
|
+
class TransportReached extends Error {
|
|
159
|
+
constructor() {
|
|
160
|
+
super('transport reached');
|
|
161
|
+
this.name = 'TransportReached';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Run one actor URI through the real resolver and report whether the domain gate
|
|
167
|
+
* refused it BEFORE any network I/O. Both transports throw on contact, so a
|
|
168
|
+
* recorded call means the gate let the host through.
|
|
169
|
+
*/
|
|
170
|
+
async function actorFetchRefusedBeforeIo(actorUri: string, entry: string): Promise<boolean> {
|
|
171
|
+
const transportCalls: string[] = [];
|
|
172
|
+
const config: ActorResolverConfig<FederatedActorRecordBase> = {
|
|
173
|
+
federationEnabled: true,
|
|
174
|
+
signedFetch: async (url) => {
|
|
175
|
+
transportCalls.push(url);
|
|
176
|
+
throw new TransportReached();
|
|
177
|
+
},
|
|
178
|
+
fetchWebFinger: async (url) => {
|
|
179
|
+
transportCalls.push(url);
|
|
180
|
+
throw new TransportReached();
|
|
181
|
+
},
|
|
182
|
+
isBlockedDomain: policyFor(entry).isBlockedDomain,
|
|
183
|
+
normalizeFederatedAcct: (acct) => acct?.trim().toLowerCase() || undefined,
|
|
184
|
+
domainFromAcct: (acct) => acct.split('@')[1],
|
|
185
|
+
firstStringUrl: () => undefined,
|
|
186
|
+
store: {
|
|
187
|
+
findActorByUri: async () => null,
|
|
188
|
+
upsertActor: async () => null,
|
|
189
|
+
findActorByPublicKeyId: async () => null,
|
|
190
|
+
setActorOxyUserId: async () => undefined,
|
|
191
|
+
tombstoneActor: async () => null,
|
|
192
|
+
},
|
|
193
|
+
identity: {
|
|
194
|
+
resolveExternalUser: async () => null,
|
|
195
|
+
reportActorGone: async () => 'skipped',
|
|
196
|
+
},
|
|
197
|
+
text: {
|
|
198
|
+
inlineField: (value) => (typeof value === 'string' ? value : ''),
|
|
199
|
+
inlineDisplayName: (raw) => raw,
|
|
200
|
+
sanitizeFieldValue: (html) => html,
|
|
201
|
+
htmlToPlainText: (html) => html,
|
|
202
|
+
},
|
|
203
|
+
logger: { info: () => undefined, warn: () => undefined },
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const actor = await new ActorResolver(config).fetchRemoteActor(actorUri);
|
|
207
|
+
expect(actor).toBeNull();
|
|
208
|
+
return transportCalls.length === 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- the exported rule -------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe('canonicalFederationHost', () => {
|
|
214
|
+
it('trims, lowercases and strips one leading www.', () => {
|
|
215
|
+
expect(canonicalFederationHost('Spam.Example')).toBe('spam.example');
|
|
216
|
+
expect(canonicalFederationHost(' spam.example ')).toBe('spam.example');
|
|
217
|
+
expect(canonicalFederationHost('WWW.Spam.EXAMPLE')).toBe('spam.example');
|
|
218
|
+
expect(canonicalFederationHost('www.www.spam.example')).toBe('www.spam.example');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('leaves a trailing dot, a punycode host and an unrelated host alone', () => {
|
|
222
|
+
expect(canonicalFederationHost('spam.example.')).toBe('spam.example.');
|
|
223
|
+
expect(canonicalFederationHost('XN--BER-GOA.example')).toBe('xn--ber-goa.example');
|
|
224
|
+
expect(canonicalFederationHost('wwwspam.example')).toBe('wwwspam.example');
|
|
225
|
+
expect(canonicalFederationHost('')).toBe('');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('does not convert a unicode host to its punycode wire form', () => {
|
|
229
|
+
expect(canonicalFederationHost('ÜBER.example')).toBe('über.example');
|
|
230
|
+
expect(canonicalFederationHost('über.example')).not.toBe(new URL('https://über.example').hostname);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('isSameFederationHost', () => {
|
|
235
|
+
it('is symmetric across case and the www. prefix', () => {
|
|
236
|
+
expect(isSameFederationHost('spam.example', 'WWW.Spam.EXAMPLE')).toBe(true);
|
|
237
|
+
expect(isSameFederationHost('WWW.Spam.EXAMPLE', 'spam.example')).toBe(true);
|
|
238
|
+
expect(isSameFederationHost('www.spam.example', 'spam.example')).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('separates hosts that merely look alike', () => {
|
|
242
|
+
expect(isSameFederationHost('spam.example', 'spam.example.')).toBe(false);
|
|
243
|
+
expect(isSameFederationHost('spam.example', 'notspam.example')).toBe(false);
|
|
244
|
+
expect(isSameFederationHost('spam.example', 'spam.example.evil.test')).toBe(false);
|
|
245
|
+
expect(isSameFederationHost('über.example', 'xn--ber-goa.example')).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('treats a blank as naming no host, so it matches nothing — including another blank', () => {
|
|
249
|
+
expect(isSameFederationHost('', '')).toBe(false);
|
|
250
|
+
expect(isSameFederationHost(' ', 'spam.example')).toBe(false);
|
|
251
|
+
expect(isSameFederationHost('spam.example', '')).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// --- the agreement -----------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
describe('the exported rule and the wire agree on every awkward spelling', () => {
|
|
258
|
+
it('covers both verdicts, so agreement cannot be vacuous', () => {
|
|
259
|
+
expect(CASES.some((c) => c.expectedBlocked)).toBe(true);
|
|
260
|
+
expect(CASES.some((c) => !c.expectedBlocked)).toBe(true);
|
|
261
|
+
expect(CASES).toHaveLength(11);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it.each(CASES)('$name', async (testCase) => {
|
|
265
|
+
const entry = entryOf(testCase);
|
|
266
|
+
const host = wireHost(testCase.actorUri);
|
|
267
|
+
const expected = testCase.expectedBlocked;
|
|
268
|
+
|
|
269
|
+
// The exported rule, as a consumer would ask it.
|
|
270
|
+
expect(isSameFederationHost(entry, host)).toBe(expected);
|
|
271
|
+
|
|
272
|
+
// The policy, as the engine's own callers ask it.
|
|
273
|
+
expect(policyFor(entry).isBlockedDomain(host)).toBe(expected);
|
|
274
|
+
|
|
275
|
+
// The inbound wire path: a dropped activity never reaches the content handler.
|
|
276
|
+
expect(await inboundDroppedActivity(testCase.actorUri, entry)).toBe(expected);
|
|
277
|
+
|
|
278
|
+
// The actor-resolution wire path: a refused host causes no network I/O.
|
|
279
|
+
expect(await actorFetchRefusedBeforeIo(testCase.actorUri, entry)).toBe(expected);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
signRequest,
|
|
4
|
+
verifyHttpSignature,
|
|
5
|
+
HTTP_SIGNATURE_ALGORITHM,
|
|
6
|
+
DEFAULT_SIGNED_CONTENT_TYPE,
|
|
7
|
+
type HttpSignatureSigner,
|
|
8
|
+
} from '../index';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GOLDEN HTTP-signature vector (byte-frozen).
|
|
12
|
+
*
|
|
13
|
+
* These literals were produced by Mention's ORIGINAL `crypto.ts` `signRequest`
|
|
14
|
+
* (draft-cavage-http-signatures-12) for a FIXED RSA key + a FIXED clock, then
|
|
15
|
+
* asserted byte-identical against this engine's `signRequest`. A change to the
|
|
16
|
+
* covered-header list, its order, the signing-string bytes, the signature
|
|
17
|
+
* params, or the digest would break this test — which is the point: a drift here
|
|
18
|
+
* silently kills ALL federation, so it is locked to the exact bytes remote
|
|
19
|
+
* servers (Mastodon et al.) verify against.
|
|
20
|
+
*
|
|
21
|
+
* The signature bytes are deterministic because RSASSA-PKCS1-v1_5 over a fixed
|
|
22
|
+
* signing string + fixed key is deterministic, so the golden is reproducible.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// A fixed, throwaway RSA-2048 test key (never used anywhere else).
|
|
26
|
+
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
|
|
27
|
+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCiZQEzhw+dKDGE
|
|
28
|
+
q7hhpcxsdRacYcJMWDFgMCdQO+VNCcRKJmtyjkQVb/HdiAn06cdDTHlM6blcRNtJ
|
|
29
|
+
Mbo5QKE1LxNH7BwhmfJ6cGdjNfUW2fh9oL5I+yEAKWRvaMsN/3JQLd2hkuBshz2/
|
|
30
|
+
FKRozgNlGvp5FZxmnHcoSYIDzDvoD16IpJsKfL7mgipy+JayIewJedvsiCTjzz9T
|
|
31
|
+
QCsjuODmiB2+NZhKyI0i0vgC2ggt9Mb8VgfPgCtlG9BN2exVhRCSH4TagNviW3Uv
|
|
32
|
+
ZpdcLB+M2cycjtqtYiJGCEXZaRMkiqm9AAMuXktiBBNjCsPNtv6WGbmmaarNPe5y
|
|
33
|
+
7aN+zf6TAgMBAAECggEAATrYmSZNtPf9Sq7uP4xnkZlgFCEdZ+ycZckXkyD7qetd
|
|
34
|
+
WYkUST17QIT6L55RzPwJmUs2o/bP2OYLRHD5oxNdOoTiatSxRdk09T5tWgVU7NkL
|
|
35
|
+
wQ/QQRyTHGgz2DAn/DF8u89icqXPyKKhkhU68DGXOah2+yccackyPH40sN4Bb3l4
|
|
36
|
+
kIc1G6guSW54B0rPau73ngSZjc8lR5b6L37FIKV3aEw8+jFHxoCVxUJbaQ1wYf/l
|
|
37
|
+
FzHFM9y1ktdeuWTYj+idHyp8yn3P5H/sD3ynSyz8LNVe9+Ny/2CdNbBQfQFoM9Wq
|
|
38
|
+
WpNUQJtU7hLX4ccf02eKNwVx5tMQOMWbCNEEx4Zj8QKBgQDWYMyTmj8faWCFB0nq
|
|
39
|
+
nZUhNO5Dd4doimFbbowbUNbfEX1NSvO3FHm9PqRWdoe1ib13lrqjdEM/Co+jUGu+
|
|
40
|
+
6h24H5DATr/Ky1vgeSVo6eiAY8/m/X4J9cDklXTCNbbolvqmFoMzRn9tSnuCs4Fh
|
|
41
|
+
UVW04E9flX5xzEAziL6jjRSeiQKBgQDB7Hl8E0A7HHZXUR3jDq2EsQQqYmrw7Hd2
|
|
42
|
+
TcYjCWvdgVMNxzJsPdvS5PnZCpgSoVJtnC4DaC2RoslDHlF8+gNEnhXAxj4IKSV1
|
|
43
|
+
udc3IXyFSvh2bCG5FKvFAyzIPtQZwFlqgrffPYh0fcZ7Y+Klx9bpJCvf28+wwBdy
|
|
44
|
+
fM9x0tyNOwKBgQCDVa4/RyIgxlghZ4O7Pmtceqb1okbMnupiL2maWn4pDvfq4F5K
|
|
45
|
+
7Tpf2/6mEdu2NfpjR250MQf5mSjCbsRzo84tPPlbN2N8g/V3ogBvM84CyiNWajpL
|
|
46
|
+
M8nGwGFVkb7K46QPGH+sbCYo+JaOThaXXlLZiwpVjqp2YSF78OyKGiZlsQKBgHyw
|
|
47
|
+
a1CfJC6d122/Z4MmXeWy2CXUkESHFy0HRv4iQav0So3SZhZ5E84fkpK+oBdiiRiX
|
|
48
|
+
UnK4WoyI6fXxGZ5NNyq4pu4DycD/i+mNa9cz/dfK48VpM6nIo8WSjAnZdBF2v0ef
|
|
49
|
+
81BkRUf501RlXkcQHpxbuKZAtONGMA1aORxL46ofAoGBAMGixHxEm/kR2IDojwSz
|
|
50
|
+
Fy+kNal2NcJ+FNXtWuDpxsZ/ZPbcFZo4oBinMuXYhBZW42XfmzVF7rXECiO0Hqk8
|
|
51
|
+
uAxjXwx8G/LX9Gcuox4VfCuykAZkDL24HnVQVakSJJtJHNMlyY4rjnW85DL32jaI
|
|
52
|
+
yErcwyo5HiEb9dAKGiHgaHuQ
|
|
53
|
+
-----END PRIVATE KEY-----
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const TEST_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
57
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAomUBM4cPnSgxhKu4YaXM
|
|
58
|
+
bHUWnGHCTFgxYDAnUDvlTQnESiZrco5EFW/x3YgJ9OnHQ0x5TOm5XETbSTG6OUCh
|
|
59
|
+
NS8TR+wcIZnyenBnYzX1Ftn4faC+SPshAClkb2jLDf9yUC3doZLgbIc9vxSkaM4D
|
|
60
|
+
ZRr6eRWcZpx3KEmCA8w76A9eiKSbCny+5oIqcviWsiHsCXnb7Igk488/U0ArI7jg
|
|
61
|
+
5ogdvjWYSsiNItL4AtoILfTG/FYHz4ArZRvQTdnsVYUQkh+E2oDb4lt1L2aXXCwf
|
|
62
|
+
jNnMnI7arWIiRghF2WkTJIqpvQADLl5LYgQTYwrDzbb+lhm5pmmqzT3ucu2jfs3+
|
|
63
|
+
kwIDAQAB
|
|
64
|
+
-----END PUBLIC KEY-----
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const ACTOR_URI = 'https://mastodon.social/users/alice';
|
|
68
|
+
const KEY_ID = `${ACTOR_URI}#main-key`;
|
|
69
|
+
const INBOX_URL = 'https://mention.earth/ap/inbox';
|
|
70
|
+
const GET_URL = 'https://remote.example/users/bob/outbox?page=true';
|
|
71
|
+
|
|
72
|
+
// The exact instant the golden vector was frozen at.
|
|
73
|
+
const FIXED_MS = Date.parse('2026-07-16T12:00:00.000Z');
|
|
74
|
+
const FIXED_DATE_HEADER = 'Thu, 16 Jul 2026 12:00:00 GMT';
|
|
75
|
+
|
|
76
|
+
// --- The frozen golden bytes (see file header). ---
|
|
77
|
+
const GOLDEN = {
|
|
78
|
+
getSigningString: `(request-target): get /users/bob/outbox?page=true
|
|
79
|
+
host: remote.example
|
|
80
|
+
date: ${FIXED_DATE_HEADER}`,
|
|
81
|
+
getSignature:
|
|
82
|
+
'keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="IzEGSjHBzcHzBRJOyiVkuVecnix0Q5PzPcULn+VJlRQTozf2Wyks6vmrhEmn5fH7SiyslV1BuUwak5ZKlnh4X33SvmaRU87+X1sJi/OoBrlYXJDaHdf7gTZ2XxOYGzGyCC45BSopH+QQmu+05nUts3kO7FE7U7tm0u+DQZ7bWBPAf1sfgtZwcnEIWGyj5GHVmLIXn7H3oydq+CePAh/ZS6D2+WwUdi07hwkOki6Z2F21IQd/Q6kHiWa7xa7PtLpzPgzfDGCBlPUZ7Txh/zh647dVAX0HAVzGZ6G+SNC+Y9EAt+CrUJKun7iFmnhWhogxX10m48p5l6kKmAufbjb0Kg=="',
|
|
83
|
+
postBody:
|
|
84
|
+
'{"@context":"https://www.w3.org/ns/activitystreams","type":"Create","id":"https://mention.earth/ap/users/alice/activities/1","actor":"https://mention.earth/ap/users/alice"}',
|
|
85
|
+
postDigest: 'SHA-256=oRgduk5xFPsdFoqJgp46OSzBnw4QjKP2vAdqvqtbYkA=',
|
|
86
|
+
postSigningString: `(request-target): post /ap/inbox
|
|
87
|
+
host: mention.earth
|
|
88
|
+
date: ${FIXED_DATE_HEADER}
|
|
89
|
+
digest: SHA-256=oRgduk5xFPsdFoqJgp46OSzBnw4QjKP2vAdqvqtbYkA=
|
|
90
|
+
content-type: application/activity+json`,
|
|
91
|
+
postSignature:
|
|
92
|
+
'keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="gn8c6OJ1JcIHm5LIFOcUCbPQIepblvF11sHRmZ/y/UvrXxNUxemLzI/rp1mpI4MR8V/4F65EWX0XTklC1jkQY1QgzQqXaC9absLmSdm6ys5MBt/rP+FoCVkLx6wAutjW3LFnaEmn5r9vT3cTuGHZeSFHbwMs27bxFsLf/8xwXre/qUVTCc0TE1EsnUQYyGFfL0EeRydYlgCE9T19RO13mkYcvNpS2rXS8AeQ7/7zjCNsbHhPJjWQ+g2z91yVUrMJB5m8VhKo/nGDruuTDRhUArEg3xuue5VffoikSxifNN5mQx7bcyAMjZBxcCgLUeAnEA71Y00nA5r6pgKPeFzKlA=="',
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
// Deterministic RSA-SHA256 signer with the fixed key (mirrors oxy-api /federation/sign).
|
|
96
|
+
const sign: HttpSignatureSigner = async (_keyId, signingString) => {
|
|
97
|
+
const s = crypto.createSign('sha256');
|
|
98
|
+
s.update(signingString);
|
|
99
|
+
s.end();
|
|
100
|
+
return s.sign(TEST_PRIVATE_KEY_PEM, 'base64');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A signer that ALSO captures the exact signing string it received. */
|
|
104
|
+
function capturingSigner(): { fn: HttpSignatureSigner; signingString: () => string } {
|
|
105
|
+
let captured = '';
|
|
106
|
+
return {
|
|
107
|
+
fn: async (keyId, signingString) => {
|
|
108
|
+
captured = signingString;
|
|
109
|
+
return sign(keyId, signingString);
|
|
110
|
+
},
|
|
111
|
+
signingString: () => captured,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fetchPublicKey = async (keyId: string) =>
|
|
116
|
+
keyId === KEY_ID ? { publicKeyPem: TEST_PUBLIC_KEY_PEM, actorUri: ACTOR_URI } : null;
|
|
117
|
+
|
|
118
|
+
/** Reproduce how Express lowercases req.headers, folding content-type as a real header. */
|
|
119
|
+
function lowerHeaders(headers: Record<string, string>): Record<string, string> {
|
|
120
|
+
const lowered = Object.fromEntries(
|
|
121
|
+
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]),
|
|
122
|
+
);
|
|
123
|
+
if (lowered.digest && !lowered['content-type']) {
|
|
124
|
+
lowered['content-type'] = DEFAULT_SIGNED_CONTENT_TYPE;
|
|
125
|
+
}
|
|
126
|
+
return lowered;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Freeze the wall clock so `new Date().toUTCString()` and the ±10min verify skew
|
|
130
|
+
// are deterministic (both OLD and NEW read `new Date()` at call time).
|
|
131
|
+
const RealDate = Date;
|
|
132
|
+
beforeAll(() => {
|
|
133
|
+
class FixedDate extends RealDate {
|
|
134
|
+
constructor(...args: ConstructorParameters<typeof Date>) {
|
|
135
|
+
if (args.length === 0) {
|
|
136
|
+
super(FIXED_MS);
|
|
137
|
+
} else {
|
|
138
|
+
super(...args);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
static now(): number {
|
|
142
|
+
return FIXED_MS;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
(globalThis as { Date: typeof Date }).Date = FixedDate as unknown as typeof Date;
|
|
146
|
+
});
|
|
147
|
+
afterAll(() => {
|
|
148
|
+
(globalThis as { Date: typeof Date }).Date = RealDate;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('signRequest — golden byte vector', () => {
|
|
152
|
+
it('uses the frozen algorithm parameter and default signed content type', () => {
|
|
153
|
+
expect(HTTP_SIGNATURE_ALGORITHM).toBe('rsa-sha256');
|
|
154
|
+
expect(DEFAULT_SIGNED_CONTENT_TYPE).toBe('application/activity+json');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('produces the byte-identical Signature + signing string for a GET (no body)', async () => {
|
|
158
|
+
const cap = capturingSigner();
|
|
159
|
+
const headers = await signRequest(cap.fn, KEY_ID, 'GET', GET_URL);
|
|
160
|
+
|
|
161
|
+
expect(cap.signingString()).toBe(GOLDEN.getSigningString);
|
|
162
|
+
expect(headers.Signature).toBe(GOLDEN.getSignature);
|
|
163
|
+
expect(headers.Host).toBe('remote.example');
|
|
164
|
+
expect(headers.Date).toBe(FIXED_DATE_HEADER);
|
|
165
|
+
// A GET carries no body → no Digest, and content-type is NOT signed.
|
|
166
|
+
expect(headers.Digest).toBeUndefined();
|
|
167
|
+
expect(cap.signingString()).not.toContain('digest:');
|
|
168
|
+
expect(cap.signingString()).not.toContain('content-type:');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('produces the byte-identical Signature + digest + signing string for a POST (with body)', async () => {
|
|
172
|
+
const cap = capturingSigner();
|
|
173
|
+
const headers = await signRequest(cap.fn, KEY_ID, 'POST', INBOX_URL, GOLDEN.postBody);
|
|
174
|
+
|
|
175
|
+
expect(headers.Digest).toBe(GOLDEN.postDigest);
|
|
176
|
+
expect(cap.signingString()).toBe(GOLDEN.postSigningString);
|
|
177
|
+
expect(headers.Signature).toBe(GOLDEN.postSignature);
|
|
178
|
+
expect(headers.Host).toBe('mention.earth');
|
|
179
|
+
expect(headers.Date).toBe(FIXED_DATE_HEADER);
|
|
180
|
+
// The covered-header list order is load-bearing.
|
|
181
|
+
expect(headers.Signature).toContain('headers="(request-target) host date digest content-type"');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('honours a caller-supplied content type in the signed string', async () => {
|
|
185
|
+
const cap = capturingSigner();
|
|
186
|
+
await signRequest(cap.fn, KEY_ID, 'POST', INBOX_URL, GOLDEN.postBody, {
|
|
187
|
+
contentType: 'application/ld+json',
|
|
188
|
+
});
|
|
189
|
+
expect(cap.signingString()).toContain('content-type: application/ld+json');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('verifyHttpSignature', () => {
|
|
194
|
+
it('verifies a signature produced by signRequest and returns the actor URI', async () => {
|
|
195
|
+
const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
|
|
196
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
197
|
+
|
|
198
|
+
const result = await verifyHttpSignature(
|
|
199
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers: lowerHeaders(signed), body },
|
|
200
|
+
fetchPublicKey,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(result.verified).toBe(true);
|
|
204
|
+
expect(result.actorUri).toBe(ACTOR_URI);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rejects when the Signature header is missing', async () => {
|
|
208
|
+
const result = await verifyHttpSignature(
|
|
209
|
+
{ method: 'POST', path: '/ap/inbox', headers: {}, body: '' },
|
|
210
|
+
fetchPublicKey,
|
|
211
|
+
);
|
|
212
|
+
expect(result.verified).toBe(false);
|
|
213
|
+
expect(result.reason).toBe('missing-signature');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('rejects when the public key cannot be fetched', async () => {
|
|
217
|
+
const body = JSON.stringify({ type: 'Create' });
|
|
218
|
+
const signed = await signRequest(sign, 'https://other/key#main', 'POST', INBOX_URL, body);
|
|
219
|
+
const result = await verifyHttpSignature(
|
|
220
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers: lowerHeaders(signed), body },
|
|
221
|
+
fetchPublicKey,
|
|
222
|
+
);
|
|
223
|
+
expect(result.verified).toBe(false);
|
|
224
|
+
expect(result.reason).toBe('key-fetch-failed');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('rejects when the body is tampered after signing (digest mismatch)', async () => {
|
|
228
|
+
const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
|
|
229
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
230
|
+
const result = await verifyHttpSignature(
|
|
231
|
+
{
|
|
232
|
+
method: 'POST',
|
|
233
|
+
path: new URL(INBOX_URL).pathname,
|
|
234
|
+
headers: lowerHeaders(signed),
|
|
235
|
+
body: JSON.stringify({ type: 'Create', id: 'https://remote/a/TAMPERED' }),
|
|
236
|
+
},
|
|
237
|
+
fetchPublicKey,
|
|
238
|
+
);
|
|
239
|
+
expect(result.verified).toBe(false);
|
|
240
|
+
expect(result.reason).toBe('digest-mismatch');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects when the signed string does not match (verify-failed)', async () => {
|
|
244
|
+
const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
|
|
245
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
246
|
+
const result = await verifyHttpSignature(
|
|
247
|
+
{ method: 'POST', path: '/ap/different-inbox', headers: lowerHeaders(signed), body },
|
|
248
|
+
fetchPublicKey,
|
|
249
|
+
);
|
|
250
|
+
expect(result.verified).toBe(false);
|
|
251
|
+
expect(result.reason).toBe('verify-failed');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('rejects when the Date header is outside the allowed skew', async () => {
|
|
255
|
+
const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
|
|
256
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
257
|
+
const stale = lowerHeaders(signed);
|
|
258
|
+
stale.date = new Date(Date.now() - 30 * 60 * 1000).toUTCString(); // 30 min ago
|
|
259
|
+
const result = await verifyHttpSignature(
|
|
260
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers: stale, body },
|
|
261
|
+
fetchPublicKey,
|
|
262
|
+
);
|
|
263
|
+
expect(result.verified).toBe(false);
|
|
264
|
+
expect(result.reason).toBe('date-skew');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('verifyHttpSignature — X-Forwarded-Host (trustForwardedHost)', () => {
|
|
269
|
+
const body = JSON.stringify({ type: 'Create', id: 'https://remote/a/1' });
|
|
270
|
+
|
|
271
|
+
it('verifies via the origin host when X-Forwarded-Host carries the signed apex (trust=true)', async () => {
|
|
272
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
273
|
+
const headers = lowerHeaders(signed);
|
|
274
|
+
headers.host = 'api.mention.earth';
|
|
275
|
+
headers['x-forwarded-host'] = 'mention.earth';
|
|
276
|
+
|
|
277
|
+
const result = await verifyHttpSignature(
|
|
278
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers, body },
|
|
279
|
+
fetchPublicKey,
|
|
280
|
+
{ trustForwardedHost: true },
|
|
281
|
+
);
|
|
282
|
+
expect(result.verified).toBe(true);
|
|
283
|
+
expect(result.actorUri).toBe(ACTOR_URI);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('uses only the first token of a comma-separated X-Forwarded-Host list (trust=true)', async () => {
|
|
287
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
288
|
+
const headers = lowerHeaders(signed);
|
|
289
|
+
headers.host = 'api.mention.earth';
|
|
290
|
+
headers['x-forwarded-host'] = 'mention.earth, proxy-a.internal, proxy-b.internal';
|
|
291
|
+
|
|
292
|
+
const result = await verifyHttpSignature(
|
|
293
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers, body },
|
|
294
|
+
fetchPublicKey,
|
|
295
|
+
{ trustForwardedHost: true },
|
|
296
|
+
);
|
|
297
|
+
expect(result.verified).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('falls back to the Host header when X-Forwarded-Host is absent (trust=true, direct delivery)', async () => {
|
|
301
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
302
|
+
const headers = lowerHeaders(signed);
|
|
303
|
+
expect(headers['x-forwarded-host']).toBeUndefined();
|
|
304
|
+
|
|
305
|
+
const result = await verifyHttpSignature(
|
|
306
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers, body },
|
|
307
|
+
fetchPublicKey,
|
|
308
|
+
{ trustForwardedHost: true },
|
|
309
|
+
);
|
|
310
|
+
expect(result.verified).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('fails when X-Forwarded-Host does not match the signed host (cryptographic host binding)', async () => {
|
|
314
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
315
|
+
const headers = lowerHeaders(signed);
|
|
316
|
+
headers.host = 'api.mention.earth';
|
|
317
|
+
headers['x-forwarded-host'] = 'evil.example';
|
|
318
|
+
|
|
319
|
+
const result = await verifyHttpSignature(
|
|
320
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers, body },
|
|
321
|
+
fetchPublicKey,
|
|
322
|
+
{ trustForwardedHost: true },
|
|
323
|
+
);
|
|
324
|
+
expect(result.verified).toBe(false);
|
|
325
|
+
expect(result.reason).toBe('verify-failed');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('ignores X-Forwarded-Host when trust is off — a rewritten origin Host fails to verify', async () => {
|
|
329
|
+
const signed = await signRequest(sign, KEY_ID, 'POST', INBOX_URL, body);
|
|
330
|
+
const headers = lowerHeaders(signed);
|
|
331
|
+
// Edge rewrote the origin Host; the signed host was mention.earth.
|
|
332
|
+
headers.host = 'api.mention.earth';
|
|
333
|
+
headers['x-forwarded-host'] = 'mention.earth';
|
|
334
|
+
|
|
335
|
+
const result = await verifyHttpSignature(
|
|
336
|
+
{ method: 'POST', path: new URL(INBOX_URL).pathname, headers, body },
|
|
337
|
+
fetchPublicKey,
|
|
338
|
+
{ trustForwardedHost: false },
|
|
339
|
+
);
|
|
340
|
+
expect(result.verified).toBe(false);
|
|
341
|
+
expect(result.reason).toBe('verify-failed');
|
|
342
|
+
});
|
|
343
|
+
});
|