@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.
Files changed (78) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/actorObject.js +216 -0
  5. package/dist/cjs/apContext.js +48 -0
  6. package/dist/cjs/apUri.js +132 -0
  7. package/dist/cjs/httpSignature.js +187 -0
  8. package/dist/cjs/index.js +99 -0
  9. package/dist/cjs/networkIdentity.js +487 -0
  10. package/dist/cjs/node/actorResolver.js +625 -0
  11. package/dist/cjs/node/actorRouter.js +307 -0
  12. package/dist/cjs/node/delivery.js +415 -0
  13. package/dist/cjs/node/identityBridge.js +133 -0
  14. package/dist/cjs/node/inboundDispatch.js +268 -0
  15. package/dist/cjs/node/index.js +63 -0
  16. package/dist/cjs/node/signedFetch.js +122 -0
  17. package/dist/cjs/node/webfingerRouter.js +166 -0
  18. package/dist/cjs/urls.js +55 -0
  19. package/dist/esm/.tsbuildinfo +1 -0
  20. package/dist/esm/actorObject.js +210 -0
  21. package/dist/esm/apContext.js +45 -0
  22. package/dist/esm/apUri.js +126 -0
  23. package/dist/esm/httpSignature.js +179 -0
  24. package/dist/esm/index.js +65 -0
  25. package/dist/esm/networkIdentity.js +472 -0
  26. package/dist/esm/node/actorResolver.js +620 -0
  27. package/dist/esm/node/actorRouter.js +304 -0
  28. package/dist/esm/node/delivery.js +412 -0
  29. package/dist/esm/node/identityBridge.js +130 -0
  30. package/dist/esm/node/inboundDispatch.js +263 -0
  31. package/dist/esm/node/index.js +51 -0
  32. package/dist/esm/node/signedFetch.js +119 -0
  33. package/dist/esm/node/webfingerRouter.js +163 -0
  34. package/dist/esm/urls.js +50 -0
  35. package/dist/types/.tsbuildinfo +1 -0
  36. package/dist/types/actorObject.d.ts +182 -0
  37. package/dist/types/apContext.d.ts +35 -0
  38. package/dist/types/apUri.d.ts +107 -0
  39. package/dist/types/httpSignature.d.ts +113 -0
  40. package/dist/types/index.d.ts +336 -0
  41. package/dist/types/networkIdentity.d.ts +509 -0
  42. package/dist/types/node/actorResolver.d.ts +287 -0
  43. package/dist/types/node/actorRouter.d.ts +108 -0
  44. package/dist/types/node/delivery.d.ts +248 -0
  45. package/dist/types/node/identityBridge.d.ts +84 -0
  46. package/dist/types/node/inboundDispatch.d.ts +156 -0
  47. package/dist/types/node/index.d.ts +51 -0
  48. package/dist/types/node/signedFetch.d.ts +74 -0
  49. package/dist/types/node/webfingerRouter.d.ts +62 -0
  50. package/dist/types/urls.d.ts +55 -0
  51. package/package.json +119 -0
  52. package/src/__tests__/actorObject.test.ts +258 -0
  53. package/src/__tests__/actorResolver.test.ts +252 -0
  54. package/src/__tests__/actorResolverNetworkIdentity.test.ts +297 -0
  55. package/src/__tests__/apUri.test.ts +53 -0
  56. package/src/__tests__/delivery.test.ts +432 -0
  57. package/src/__tests__/federationHost.test.ts +281 -0
  58. package/src/__tests__/httpSignature.test.ts +343 -0
  59. package/src/__tests__/inboundDispatch.test.ts +381 -0
  60. package/src/__tests__/index.test.ts +8 -0
  61. package/src/__tests__/networkIdentity.test.ts +525 -0
  62. package/src/__tests__/routers.test.ts +460 -0
  63. package/src/__tests__/urls.test.ts +26 -0
  64. package/src/actorObject.ts +313 -0
  65. package/src/apContext.ts +45 -0
  66. package/src/apUri.ts +161 -0
  67. package/src/httpSignature.ts +282 -0
  68. package/src/index.ts +419 -0
  69. package/src/networkIdentity.ts +731 -0
  70. package/src/node/actorResolver.ts +839 -0
  71. package/src/node/actorRouter.ts +438 -0
  72. package/src/node/delivery.ts +729 -0
  73. package/src/node/identityBridge.ts +230 -0
  74. package/src/node/inboundDispatch.ts +420 -0
  75. package/src/node/index.ts +136 -0
  76. package/src/node/signedFetch.ts +177 -0
  77. package/src/node/webfingerRouter.ts +226 -0
  78. 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
+ });