@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,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The single builder of a LOCAL user's ActivityPub actor document.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the GET actor route (which serves it as a standalone JSON-LD
|
|
6
|
+
* document) and the outbound `Update` broadcast (which embeds it in an
|
|
7
|
+
* `Update` activity), so a follower's Mastodon renders the same actor whether it
|
|
8
|
+
* was fetched or pushed. Deliberately does NOT include the top-level `@context`:
|
|
9
|
+
* the GET route and the `Update` envelope each own their JSON-LD context, and an
|
|
10
|
+
* embedded actor object must not double-declare it.
|
|
11
|
+
*
|
|
12
|
+
* The exact bytes of this document are load-bearing — Mastodon negative-caches a
|
|
13
|
+
* malformed actor — so the field set, ordering, and the absolute-URL invariant on
|
|
14
|
+
* `icon`/`image` must stay byte-identical across every app that uses the engine.
|
|
15
|
+
*
|
|
16
|
+
* Media resolution is injected ({@link ActorMediaResolver}): the engine holds no
|
|
17
|
+
* knowledge of any app's media pipeline. The app resolves an avatar/banner
|
|
18
|
+
* reference (Oxy file id or URL) to a final absolute URL; the engine enforces the
|
|
19
|
+
* absolute-URL invariant and assembles the AP `Image` object.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = exports.AP_ACTOR_TYPES = void 0;
|
|
23
|
+
exports.isApActorType = isApActorType;
|
|
24
|
+
exports.localActorTypeForAccountKind = localActorTypeForAccountKind;
|
|
25
|
+
exports.createLocalActorBuilder = createLocalActorBuilder;
|
|
26
|
+
const contracts_1 = require("@oxy.so/contracts");
|
|
27
|
+
/**
|
|
28
|
+
* The five actor types AS2 defines — the vocabulary for RECOGNIZING any actor,
|
|
29
|
+
* local or remote, as opposed to {@link LocalActorType} (the subset we emit).
|
|
30
|
+
*
|
|
31
|
+
* An inbound `Update` carrying a profile is dispatched on this: gating it on a
|
|
32
|
+
* hand-written subset is how a receiver silently stops applying profile edits
|
|
33
|
+
* from a whole class of account (a Lemmy community is a `Group`), with no error
|
|
34
|
+
* anywhere — the edit simply never lands.
|
|
35
|
+
*/
|
|
36
|
+
exports.AP_ACTOR_TYPES = [
|
|
37
|
+
'Application',
|
|
38
|
+
'Group',
|
|
39
|
+
'Organization',
|
|
40
|
+
'Person',
|
|
41
|
+
'Service',
|
|
42
|
+
];
|
|
43
|
+
/** Whether an untrusted inbound `type` names an AS2 actor. */
|
|
44
|
+
function isApActorType(value) {
|
|
45
|
+
return typeof value === 'string' && exports.AP_ACTOR_TYPES.includes(value);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Oxy account kind → the AS2 actor type the fediverse is told about it.
|
|
49
|
+
*
|
|
50
|
+
* `satisfies Record<AccountKind, LocalActorType>` is the load-bearing part: a
|
|
51
|
+
* kind added to `@oxy.so/contracts` fails THIS build rather than silently
|
|
52
|
+
* inheriting `Person`, which is how every non-person account came to describe
|
|
53
|
+
* itself as an individual human in the first place.
|
|
54
|
+
*
|
|
55
|
+
* Per kind, and why:
|
|
56
|
+
*
|
|
57
|
+
* - **`personal` → `Person`.** The only kind that is a human login. Unchanged.
|
|
58
|
+
* - **`organization` → `Organization`.** AS2 has the exact word.
|
|
59
|
+
* - **`project` → `Organization`.** Least-wrong of the three available: a
|
|
60
|
+
* project is a collective endeavour, not an individual (`Person`) and not an
|
|
61
|
+
* automated one (`Service`).
|
|
62
|
+
* - **`bot` → `Service`.** Not merely AS2's word for automation — it is
|
|
63
|
+
* literally the value Mastodon writes when a local user ticks "this is an
|
|
64
|
+
* automated account" (`account.rb:224`), so it is the same claim its own
|
|
65
|
+
* users make about themselves. An Oxy `bot` announcing itself as a `Person`
|
|
66
|
+
* is false, and readers specifically want it labelled.
|
|
67
|
+
* - **`channel` → `Organization`.** A channel is a CONTENT identity that can
|
|
68
|
+
* never be logged into and takes no replies, so `Person` is false about it.
|
|
69
|
+
* `Group` would promise forwarding (above). `Service` was the tempting answer
|
|
70
|
+
* and is the WRONG one: it is the automation claim, and a channel is curated
|
|
71
|
+
* by people. Mastodon's `bot?` is exactly `%w(Application Service)`
|
|
72
|
+
* (`account.rb:90`), which paints an **"Automated"** badge with a robot icon
|
|
73
|
+
* (`badges.tsx:69`), drops the account from `SimilarProfilesSource`
|
|
74
|
+
* (`similar_profiles_source.rb:22-36`), and makes its notifications
|
|
75
|
+
* discardable by policy; Lemmy sets `bot_account = true`, hiding it from
|
|
76
|
+
* anyone who turned bots off. `Organization` costs NOTHING measurable: it is
|
|
77
|
+
* accepted by all four implementations' whitelists and compared in none of
|
|
78
|
+
* them — neither `bot?` nor `group?` in Mastodon, `bot_account = false` in
|
|
79
|
+
* Lemmy, `isBot` false in Misskey, an ordinary account in PeerTube.
|
|
80
|
+
*
|
|
81
|
+
* What this does NOT do: it does not stop a remote instance offering a reply box
|
|
82
|
+
* under a channel's post. NO actor type gates that in any of the four — Mastodon
|
|
83
|
+
* has no `canReply` at all (only `canQuote` and `canFeature`) — and AS2 has no
|
|
84
|
+
* interaction-policy field deployed software honours. A reply to a channel is
|
|
85
|
+
* still accepted by the sender's own instance and still dropped on arrival here.
|
|
86
|
+
* This map only stops asserting personhood about things that are not people.
|
|
87
|
+
*/
|
|
88
|
+
exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND = {
|
|
89
|
+
personal: 'Person',
|
|
90
|
+
organization: 'Organization',
|
|
91
|
+
project: 'Organization',
|
|
92
|
+
bot: 'Service',
|
|
93
|
+
channel: 'Organization',
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The AS2 actor type for an Oxy account kind, defaulting to `Person`.
|
|
97
|
+
*
|
|
98
|
+
* Takes `unknown` rather than `AccountKind` on purpose: the value arrives in an
|
|
99
|
+
* Oxy API response, so the static type is a claim about the wire that the wire
|
|
100
|
+
* can break. A deployment whose API knows a kind this package does not would
|
|
101
|
+
* index a miss and emit `type: undefined` — a MALFORMED actor, which Mastodon
|
|
102
|
+
* negative-caches for minutes to hours. `isAccountKind` (contracts' own narrowing,
|
|
103
|
+
* so it cannot drift from the vocabulary) sends an unrecognized or absent kind to
|
|
104
|
+
* `Person`: a valid actor, and the value every actor carried before this map
|
|
105
|
+
* existed.
|
|
106
|
+
*/
|
|
107
|
+
function localActorTypeForAccountKind(kind) {
|
|
108
|
+
return (0, contracts_1.isAccountKind)(kind) ? exports.LOCAL_ACTOR_TYPE_BY_ACCOUNT_KIND[kind] : 'Person';
|
|
109
|
+
}
|
|
110
|
+
/** Map common image extensions to a MIME type for an actor image `mediaType`. */
|
|
111
|
+
const IMAGE_MEDIA_TYPE_BY_EXT = {
|
|
112
|
+
png: 'image/png',
|
|
113
|
+
jpg: 'image/jpeg',
|
|
114
|
+
jpeg: 'image/jpeg',
|
|
115
|
+
gif: 'image/gif',
|
|
116
|
+
webp: 'image/webp',
|
|
117
|
+
avif: 'image/avif',
|
|
118
|
+
};
|
|
119
|
+
/** True when `value` is an absolute `http(s)` URL. */
|
|
120
|
+
function isAbsoluteHttpUrl(value) {
|
|
121
|
+
try {
|
|
122
|
+
return /^https?:$/i.test(new URL(value).protocol);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Build an ActivityPub `Image` object from an already-absolute URL, deriving
|
|
130
|
+
* `mediaType` from the URL extension when recognizable (a bare `Image` with a
|
|
131
|
+
* `url` is spec-valid, so an unknown extension simply omits `mediaType` rather
|
|
132
|
+
* than asserting a wrong one). Shared by the actor `icon` (avatar) and `image`
|
|
133
|
+
* (profile banner) builders.
|
|
134
|
+
*/
|
|
135
|
+
function apImageObject(url) {
|
|
136
|
+
let extension;
|
|
137
|
+
try {
|
|
138
|
+
extension = new URL(url).pathname.split('.').pop()?.toLowerCase();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
extension = url.split('?')[0]?.split('.').pop()?.toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
const mediaType = extension ? IMAGE_MEDIA_TYPE_BY_EXT[extension] : undefined;
|
|
144
|
+
return mediaType ? { type: 'Image', url, mediaType } : { type: 'Image', url };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Build the actor `icon` (avatar) object, enforcing the absolute-URL invariant.
|
|
148
|
+
*
|
|
149
|
+
* ActivityPub consumers such as Mastodon validate that `icon.url` is an absolute
|
|
150
|
+
* URL and REJECT the entire actor document when it is not — so a non-absolute
|
|
151
|
+
* value makes the account undiscoverable. Returns undefined when there is no
|
|
152
|
+
* avatar or no absolute URL can be produced (Mastodon is fine with an
|
|
153
|
+
* avatar-less actor).
|
|
154
|
+
*/
|
|
155
|
+
function buildActorIcon(config, avatar) {
|
|
156
|
+
if (!avatar)
|
|
157
|
+
return undefined;
|
|
158
|
+
const resolved = config.media.resolveAvatar(avatar);
|
|
159
|
+
if (!resolved || !isAbsoluteHttpUrl(resolved)) {
|
|
160
|
+
config.onWarn?.(`[Federation] Omitting actor icon — avatar did not resolve to an absolute URL (ref: ${avatar})`);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return apImageObject(resolved);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build the actor `image` (profile banner/header) object, enforcing the same
|
|
167
|
+
* absolute-URL invariant as {@link buildActorIcon}. Mastodon renders the AP
|
|
168
|
+
* `image` property as the profile HEADER banner.
|
|
169
|
+
*/
|
|
170
|
+
function buildActorImage(config, banner) {
|
|
171
|
+
if (!banner)
|
|
172
|
+
return undefined;
|
|
173
|
+
const resolved = config.media.resolveBanner(banner);
|
|
174
|
+
if (!resolved || !isAbsoluteHttpUrl(resolved)) {
|
|
175
|
+
config.onWarn?.(`[Federation] Omitting actor image — banner did not resolve to an absolute URL (ref: ${banner})`);
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
return apImageObject(resolved);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Build the per-instance local-actor builder. Bind it once with an app's domain +
|
|
182
|
+
* media resolver; call the returned function per user.
|
|
183
|
+
*/
|
|
184
|
+
function createLocalActorBuilder(config) {
|
|
185
|
+
return (params) => {
|
|
186
|
+
const { username, displayName, kind, bio, avatar, profileHeaderImage, publicKey, createdAt } = params;
|
|
187
|
+
const actorObject = {
|
|
188
|
+
id: config.urls.actor(username),
|
|
189
|
+
type: localActorTypeForAccountKind(kind),
|
|
190
|
+
preferredUsername: username,
|
|
191
|
+
name: displayName,
|
|
192
|
+
summary: bio || '',
|
|
193
|
+
url: `https://${config.domain}/@${username}`,
|
|
194
|
+
inbox: config.urls.inbox(username),
|
|
195
|
+
outbox: config.urls.outbox(username),
|
|
196
|
+
featured: config.urls.featured(username),
|
|
197
|
+
followers: config.urls.followers(username),
|
|
198
|
+
following: config.urls.following(username),
|
|
199
|
+
endpoints: { sharedInbox: config.urls.sharedInbox() },
|
|
200
|
+
discoverable: true,
|
|
201
|
+
manuallyApprovesFollowers: false,
|
|
202
|
+
icon: buildActorIcon(config, avatar),
|
|
203
|
+
image: buildActorImage(config, profileHeaderImage),
|
|
204
|
+
publicKey: {
|
|
205
|
+
id: publicKey.keyId,
|
|
206
|
+
owner: config.urls.actor(username),
|
|
207
|
+
publicKeyPem: publicKey.publicKeyPem,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
// `published` (account creation date) is advertised when the API provides it.
|
|
211
|
+
if (createdAt) {
|
|
212
|
+
actorObject.published = new Date(createdAt).toISOString();
|
|
213
|
+
}
|
|
214
|
+
return actorObject;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AP_CONTEXT = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The shared JSON-LD `@context` every Oxy app emits on its ActivityPub actor and
|
|
6
|
+
* activity documents.
|
|
7
|
+
*
|
|
8
|
+
* These term declarations are LOAD-BEARING and must stay byte-identical across
|
|
9
|
+
* apps: a strict JSON-LD consumer DROPS any field whose term is not declared
|
|
10
|
+
* here, and Mastodon negative-caches a malformed actor for minutes/hours. The
|
|
11
|
+
* exact set below matches the proven Mention actor — `as:sensitive`, Mastodon's
|
|
12
|
+
* `toot:votersCount`, and the four interoperating quote-post terms
|
|
13
|
+
* (FEP-044f / FEP-e232 across Mastodon, Fedibird, Misskey and Pleroma/Akkoma).
|
|
14
|
+
*/
|
|
15
|
+
exports.AP_CONTEXT = [
|
|
16
|
+
'https://www.w3.org/ns/activitystreams',
|
|
17
|
+
'https://w3id.org/security/v1',
|
|
18
|
+
// The AS2 core context above defines the `as:` prefix
|
|
19
|
+
// (`as` → `https://www.w3.org/ns/activitystreams#`), so this maps the Note's
|
|
20
|
+
// `sensitive` boolean to `as:sensitive` — the exact term Mastodon defines for
|
|
21
|
+
// it. Without the term declaration a JSON-LD consumer drops `sensitive`.
|
|
22
|
+
//
|
|
23
|
+
// `toot` is Mastodon's extension namespace; `votersCount` (the total unique
|
|
24
|
+
// voters on a poll `Question`) is `toot:votersCount` — the exact term Mastodon
|
|
25
|
+
// emits and reads. Without the declaration a JSON-LD consumer drops it. The
|
|
26
|
+
// `Question`/`oneOf`/`anyOf`/`endTime`/`closed` poll terms are all AS2 core, so
|
|
27
|
+
// they need no extra declaration here.
|
|
28
|
+
//
|
|
29
|
+
// Quote-post interop (FEP-044f / FEP-e232). A quote post carries the quoted
|
|
30
|
+
// object's canonical AP id under FOUR terms so the widest set of servers
|
|
31
|
+
// renders the inline quote: `quote` (FEP-044f, Mastodon 4.4+), `quoteUri`
|
|
32
|
+
// (Fedibird), `_misskey_quote` (Misskey) and `quoteUrl` (Pleroma/Akkoma). Each
|
|
33
|
+
// is typed `@id` (an IRI, not a literal); the `misskey`/`fedibird` namespaces
|
|
34
|
+
// and the AS2 `Link` type back the FEP-e232 `Link` quote tag. Without these
|
|
35
|
+
// declarations a strict JSON-LD consumer DROPS the quote fields.
|
|
36
|
+
{
|
|
37
|
+
sensitive: 'as:sensitive',
|
|
38
|
+
toot: 'http://joinmastodon.org/ns#',
|
|
39
|
+
votersCount: 'toot:votersCount',
|
|
40
|
+
misskey: 'https://misskey-hub.net/ns#',
|
|
41
|
+
fedibird: 'http://fedibird.com/ns#',
|
|
42
|
+
quote: { '@id': 'https://w3id.org/fep/044f#quote', '@type': '@id' },
|
|
43
|
+
quoteUri: { '@id': 'fedibird:quoteUri', '@type': '@id' },
|
|
44
|
+
quoteUrl: { '@id': 'as:quoteUrl', '@type': '@id' },
|
|
45
|
+
_misskey_quote: { '@id': 'misskey:_misskey_quote', '@type': '@id' },
|
|
46
|
+
Link: 'as:Link',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ActivityPub URI parsing + host canonicalisation + per-instance domain policy.
|
|
4
|
+
*
|
|
5
|
+
* `canonicalFederationHost` / `isSameFederationHost` are the one rule for "are
|
|
6
|
+
* these the same host", and every domain comparison the policy makes is built
|
|
7
|
+
* out of them. `extractActorUriFromActivityId` is pure and domain-agnostic. The
|
|
8
|
+
* blocked-domain check and the local-post-id extractor are DOMAIN-SCOPED — they
|
|
9
|
+
* depend on which hosts an app mints its own URIs under and which identity apex
|
|
10
|
+
* publishes its own users — so they come from a per-instance
|
|
11
|
+
* {@link createDomainPolicy} rather than a module-level constant.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.canonicalFederationHost = canonicalFederationHost;
|
|
15
|
+
exports.isSameFederationHost = isSameFederationHost;
|
|
16
|
+
exports.extractActorUriFromActivityId = extractActorUriFromActivityId;
|
|
17
|
+
exports.createDomainPolicy = createDomainPolicy;
|
|
18
|
+
/** Path segments that typically separate an actor path from a post ID in ActivityPub URIs. */
|
|
19
|
+
const POST_PATH_SEGMENTS = new Set(['statuses', 'posts', 'notes', 'objects', 'activities']);
|
|
20
|
+
/**
|
|
21
|
+
* THE FORM THIS ENGINE COMPARES HOSTS IN — trimmed, lowercased, one leading
|
|
22
|
+
* `www.` removed, and nothing else.
|
|
23
|
+
*
|
|
24
|
+
* It is exported because it is not an implementation detail: it decides whether
|
|
25
|
+
* two spellings of a host are the SAME host, and {@link createDomainPolicy} —
|
|
26
|
+
* the blocked-domain gate every inbound activity and every actor fetch passes
|
|
27
|
+
* through — is built out of this exact function. A consumer that keeps its own
|
|
28
|
+
* copy of the rule (a moderation blocklist, a transparency page, a content
|
|
29
|
+
* purge) is keeping a second opinion about which hosts are which, and the moment
|
|
30
|
+
* the two drift the consumer acts on domains the engine never refused. For a
|
|
31
|
+
* consumer whose action is irreversible that difference is deleted content.
|
|
32
|
+
*
|
|
33
|
+
* WHAT IT DELIBERATELY DOES NOT DO
|
|
34
|
+
*
|
|
35
|
+
* It does not strip a TRAILING DOT. `example.com.` is the fully-qualified
|
|
36
|
+
* spelling of `example.com` in DNS, but it is a different string here — and
|
|
37
|
+
* also on the wire, because `new URL('https://example.com./x').hostname`
|
|
38
|
+
* preserves the dot and that value is what the engine feeds in. So the two
|
|
39
|
+
* spellings do not match each other, in this function and in the engine
|
|
40
|
+
* alike. Widening that is a POLICY decision (it makes a blocklist match hosts
|
|
41
|
+
* it does not literally name) and belongs to whoever owns the policy, not to
|
|
42
|
+
* a string transform.
|
|
43
|
+
*
|
|
44
|
+
* It does not perform IDNA. The input is expected to be an ASCII host in the
|
|
45
|
+
* form the WHATWG URL parser produces — `new URL(...).hostname` has already
|
|
46
|
+
* applied ToASCII, so an internationalised host arrives as punycode
|
|
47
|
+
* (`xn--ber-goa.example`). A host spelled in unicode is lowercased but NOT
|
|
48
|
+
* converted, so it will not match its own punycode wire form. Callers that
|
|
49
|
+
* accept operator-typed hosts must convert them before comparing.
|
|
50
|
+
*
|
|
51
|
+
* @param host a bare host — no scheme, no port, no path.
|
|
52
|
+
*/
|
|
53
|
+
function canonicalFederationHost(host) {
|
|
54
|
+
const value = host.trim().toLowerCase();
|
|
55
|
+
return value.startsWith('www.') ? value.slice(4) : value;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether two spellings name the same host under {@link canonicalFederationHost}.
|
|
59
|
+
*
|
|
60
|
+
* This is the question a caller actually has ("is the host on this activity the
|
|
61
|
+
* host we blocked?"), and it exists so that asking it does not require each
|
|
62
|
+
* caller to assemble its own comparison around the normaliser. Assembling one is
|
|
63
|
+
* where the mistakes happen, and they are quiet ones: a comparison that
|
|
64
|
+
* lowercases but forgets `www.`, or that allows `www.` on one side only and so
|
|
65
|
+
* answers differently depending on argument order, looks correct at every call
|
|
66
|
+
* site and is wrong for exactly the hosts an evasive instance will use.
|
|
67
|
+
*
|
|
68
|
+
* A blank string names no host, so it matches nothing — including another blank.
|
|
69
|
+
* That is the same answer {@link DomainPolicy.isBlockedDomain} gives it: a host
|
|
70
|
+
* that is not named is not in any set.
|
|
71
|
+
*/
|
|
72
|
+
function isSameFederationHost(a, b) {
|
|
73
|
+
const canonicalA = canonicalFederationHost(a);
|
|
74
|
+
if (canonicalA.length === 0)
|
|
75
|
+
return false;
|
|
76
|
+
return canonicalA === canonicalFederationHost(b);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Given an ActivityPub activity/object ID (URL), extract the actor URI by
|
|
80
|
+
* trimming everything from the first recognised post-path segment onward.
|
|
81
|
+
*
|
|
82
|
+
* e.g. "https://mastodon.social/users/alice/statuses/12345"
|
|
83
|
+
* → "https://mastodon.social/users/alice"
|
|
84
|
+
*
|
|
85
|
+
* Returns null when the URL is malformed or no post-path segment is found.
|
|
86
|
+
*/
|
|
87
|
+
function extractActorUriFromActivityId(activityId) {
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(activityId);
|
|
90
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
91
|
+
const statusIdx = segments.findIndex((s) => POST_PATH_SEGMENTS.has(s));
|
|
92
|
+
if (statusIdx < 1)
|
|
93
|
+
return null;
|
|
94
|
+
return `${url.origin}/${segments.slice(0, statusIdx).join('/')}`;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the per-instance {@link DomainPolicy} from an app's domain configuration.
|
|
102
|
+
*/
|
|
103
|
+
function createDomainPolicy(config) {
|
|
104
|
+
const localDomains = new Set([
|
|
105
|
+
canonicalFederationHost(config.domain),
|
|
106
|
+
canonicalFederationHost(config.actorDomain ?? config.domain),
|
|
107
|
+
]);
|
|
108
|
+
const identityApex = config.identityApex ? canonicalFederationHost(config.identityApex) : undefined;
|
|
109
|
+
const blocked = new Set();
|
|
110
|
+
for (const d of config.blockedDomains ?? []) {
|
|
111
|
+
blocked.add(canonicalFederationHost(d));
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
isBlockedDomain(domain) {
|
|
115
|
+
const d = canonicalFederationHost(domain);
|
|
116
|
+
return localDomains.has(d) || (identityApex !== undefined && d === identityApex) || blocked.has(d);
|
|
117
|
+
},
|
|
118
|
+
extractLocalPostId(objectUri) {
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = new URL(objectUri);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (!localDomains.has(canonicalFederationHost(parsed.hostname)))
|
|
127
|
+
return null;
|
|
128
|
+
const match = parsed.pathname.match(/^\/ap\/users\/[^/]+\/posts\/([^/]+)\/?$/);
|
|
129
|
+
return match ? match[1] : null;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP Signatures (draft-cavage-http-signatures-12) — the PURE sign/verify
|
|
4
|
+
* crypto that every Oxy app's ActivityPub federation shares.
|
|
5
|
+
*
|
|
6
|
+
* This is the highest-risk surface in the federation engine: the exact bytes of
|
|
7
|
+
* the signing string, the covered-header list and its order, the signature
|
|
8
|
+
* parameters, and the `X-Forwarded-Host` host reconstruction are what remote
|
|
9
|
+
* servers (Mastodon et al.) verify against. A one-character drift silently kills
|
|
10
|
+
* ALL federation, so this module is a byte-for-byte extraction of Mention's
|
|
11
|
+
* proven implementation — with the ONLY behavioural knobs made explicit:
|
|
12
|
+
*
|
|
13
|
+
* - **private-key custody is injected** ({@link HttpSignatureSigner}). The
|
|
14
|
+
* private key NEVER enters this package; the app supplies a `sign(keyId,
|
|
15
|
+
* signingString)` that (for Mention) calls oxy-api `POST /federation/sign`.
|
|
16
|
+
* - **`X-Forwarded-Host` trust is opt-in** ({@link VerifyHttpSignatureOptions.trustForwardedHost}).
|
|
17
|
+
* Mention runs behind a CF-proxied apex that rewrites the origin `Host`, so it
|
|
18
|
+
* passes `true`; a directly-exposed origin leaves it `false`.
|
|
19
|
+
*
|
|
20
|
+
* Lives in the isomorphic `.` entry (no Express / Mongoose): it depends only on
|
|
21
|
+
* the runtime `crypto` builtin (Node / Bun) and is never invoked from browser /
|
|
22
|
+
* React-Native bundles — RN consumers import only the connector TYPES, which are
|
|
23
|
+
* erased at compile time.
|
|
24
|
+
*/
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.DEFAULT_SIGNED_CONTENT_TYPE = exports.HTTP_SIGNATURE_ALGORITHM = void 0;
|
|
30
|
+
exports.signRequest = signRequest;
|
|
31
|
+
exports.verifyHttpSignature = verifyHttpSignature;
|
|
32
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
33
|
+
/** The signature algorithm parameter emitted in (and expected on) the `Signature` header. */
|
|
34
|
+
exports.HTTP_SIGNATURE_ALGORITHM = 'rsa-sha256';
|
|
35
|
+
/**
|
|
36
|
+
* The default content-type folded into the signing string for body-bearing
|
|
37
|
+
* requests. ActivityPub delivery signs `content-type` (some servers — e.g.
|
|
38
|
+
* Threads — require it), and the AP content type is always
|
|
39
|
+
* `application/activity+json`.
|
|
40
|
+
*/
|
|
41
|
+
exports.DEFAULT_SIGNED_CONTENT_TYPE = 'application/activity+json';
|
|
42
|
+
/**
|
|
43
|
+
* Build the HTTP Signature header per draft-cavage-http-signatures-12 and sign it
|
|
44
|
+
* via the injected {@link HttpSignatureSigner} (the private key never enters this
|
|
45
|
+
* package).
|
|
46
|
+
*
|
|
47
|
+
* The spec-correct signing string is composed locally: `(request-target)`, host,
|
|
48
|
+
* date, and — for body-bearing requests — digest and content-type. The composed
|
|
49
|
+
* string is handed to `sign`, and the resulting signature is assembled into the
|
|
50
|
+
* `Signature:` header.
|
|
51
|
+
*
|
|
52
|
+
* Returns the headers to attach to the outbound request (Host, Date, optional
|
|
53
|
+
* Digest, and Signature). Content-Type is set by the deliverer's fetch.
|
|
54
|
+
*/
|
|
55
|
+
async function signRequest(sign, keyId, method, url, body, options = {}) {
|
|
56
|
+
const contentType = options.contentType ?? exports.DEFAULT_SIGNED_CONTENT_TYPE;
|
|
57
|
+
const parsedUrl = new URL(url);
|
|
58
|
+
const date = new Date().toUTCString();
|
|
59
|
+
const headers = {
|
|
60
|
+
Host: parsedUrl.host,
|
|
61
|
+
Date: date,
|
|
62
|
+
};
|
|
63
|
+
const signedHeaderNames = ['(request-target)', 'host', 'date'];
|
|
64
|
+
const signingParts = [
|
|
65
|
+
`(request-target): ${method.toLowerCase()} ${parsedUrl.pathname}${parsedUrl.search}`,
|
|
66
|
+
`host: ${parsedUrl.host}`,
|
|
67
|
+
`date: ${date}`,
|
|
68
|
+
];
|
|
69
|
+
if (body) {
|
|
70
|
+
const digest = node_crypto_1.default.createHash('sha256').update(body).digest('base64');
|
|
71
|
+
headers.Digest = `SHA-256=${digest}`;
|
|
72
|
+
signedHeaderNames.push('digest');
|
|
73
|
+
signingParts.push(`digest: SHA-256=${digest}`);
|
|
74
|
+
// Include content-type in signature (required by some servers like Threads)
|
|
75
|
+
signedHeaderNames.push('content-type');
|
|
76
|
+
signingParts.push(`content-type: ${contentType}`);
|
|
77
|
+
}
|
|
78
|
+
const signingString = signingParts.join('\n');
|
|
79
|
+
const signature = await sign(keyId, signingString);
|
|
80
|
+
headers.Signature = [
|
|
81
|
+
`keyId="${keyId}"`,
|
|
82
|
+
`algorithm="${exports.HTTP_SIGNATURE_ALGORITHM}"`,
|
|
83
|
+
`headers="${signedHeaderNames.join(' ')}"`,
|
|
84
|
+
`signature="${signature}"`,
|
|
85
|
+
].join(',');
|
|
86
|
+
return headers;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse the Signature header from an incoming request.
|
|
90
|
+
*/
|
|
91
|
+
function parseSignatureHeader(signatureHeader) {
|
|
92
|
+
const params = {};
|
|
93
|
+
const regex = /(\w+)="([^"]*)"/g;
|
|
94
|
+
let match = regex.exec(signatureHeader);
|
|
95
|
+
while (match !== null) {
|
|
96
|
+
params[match[1]] = match[2];
|
|
97
|
+
match = regex.exec(signatureHeader);
|
|
98
|
+
}
|
|
99
|
+
if (!params.keyId || !params.signature)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
keyId: params.keyId,
|
|
103
|
+
algorithm: params.algorithm || exports.HTTP_SIGNATURE_ALGORITHM,
|
|
104
|
+
headers: (params.headers || 'date').split(' '),
|
|
105
|
+
signature: params.signature,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Verify the HTTP signature on an incoming request.
|
|
110
|
+
* Returns the actor URI (key owner) if valid, null otherwise.
|
|
111
|
+
*/
|
|
112
|
+
async function verifyHttpSignature(req, fetchPublicKey, options = {}) {
|
|
113
|
+
const signatureHeader = req.headers.signature;
|
|
114
|
+
if (!signatureHeader)
|
|
115
|
+
return { verified: false, reason: 'missing-signature' };
|
|
116
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
117
|
+
if (!parsed)
|
|
118
|
+
return { verified: false, reason: 'invalid-signature-header' };
|
|
119
|
+
const keyData = await fetchPublicKey(parsed.keyId);
|
|
120
|
+
if (!keyData) {
|
|
121
|
+
options.onDebug?.(`Failed to fetch public key for keyId: ${parsed.keyId}`);
|
|
122
|
+
return { verified: false, reason: 'key-fetch-failed' };
|
|
123
|
+
}
|
|
124
|
+
const lowerHeaders = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
125
|
+
// Enforce Date skew (+/- 10 minutes) if present
|
|
126
|
+
const dateHeader = lowerHeaders.date;
|
|
127
|
+
if (dateHeader) {
|
|
128
|
+
const dateVal = Array.isArray(dateHeader) ? dateHeader[0] : dateHeader;
|
|
129
|
+
const parsedDate = Date.parse(dateVal || '');
|
|
130
|
+
if (!Number.isNaN(parsedDate)) {
|
|
131
|
+
const skew = Math.abs(Date.now() - parsedDate);
|
|
132
|
+
if (skew > 10 * 60 * 1000) {
|
|
133
|
+
return { verified: false, reason: 'date-skew' };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// If Digest header is required in signature but missing/invalid, fail early
|
|
138
|
+
if (parsed.headers.includes('digest')) {
|
|
139
|
+
const digestHeader = lowerHeaders.digest;
|
|
140
|
+
const bodyString = typeof req.body === 'string' ? req.body : req.body ? JSON.stringify(req.body) : '';
|
|
141
|
+
if (!digestHeader) {
|
|
142
|
+
return { verified: false, reason: 'missing-digest' };
|
|
143
|
+
}
|
|
144
|
+
const expectedDigest = `SHA-256=${node_crypto_1.default.createHash('sha256').update(bodyString).digest('base64')}`;
|
|
145
|
+
const digestVal = Array.isArray(digestHeader) ? digestHeader[0] : digestHeader;
|
|
146
|
+
if (digestVal !== expectedDigest) {
|
|
147
|
+
return { verified: false, reason: 'digest-mismatch' };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const signingParts = parsed.headers.map((header) => {
|
|
151
|
+
const name = header.toLowerCase();
|
|
152
|
+
if (name === '(request-target)') {
|
|
153
|
+
return `(request-target): ${req.method.toLowerCase()} ${req.path}`;
|
|
154
|
+
}
|
|
155
|
+
// Reconstruct the `host` line from `x-forwarded-host` when the caller trusts
|
|
156
|
+
// it (an edge that rewrites the origin Host forwards the ORIGINAL signed host
|
|
157
|
+
// here; a proxy chain's FIRST comma token is the client-facing host). See
|
|
158
|
+
// VerifyHttpSignatureOptions.trustForwardedHost. Falls back to `host` when the
|
|
159
|
+
// header is absent (direct delivery), preserving direct-delivery behavior.
|
|
160
|
+
if (name === 'host' && options.trustForwardedHost) {
|
|
161
|
+
const forwarded = lowerHeaders['x-forwarded-host'];
|
|
162
|
+
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
163
|
+
const firstToken = forwardedValue?.split(',')[0]?.trim();
|
|
164
|
+
if (firstToken) {
|
|
165
|
+
return `host: ${firstToken}`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const value = lowerHeaders[name];
|
|
169
|
+
return `${name}: ${Array.isArray(value) ? value[0] : value}`;
|
|
170
|
+
});
|
|
171
|
+
const signingString = signingParts.join('\n');
|
|
172
|
+
const verifier = node_crypto_1.default.createVerify('sha256');
|
|
173
|
+
verifier.update(signingString);
|
|
174
|
+
verifier.end();
|
|
175
|
+
try {
|
|
176
|
+
const isValid = verifier.verify(keyData.publicKeyPem, parsed.signature, 'base64');
|
|
177
|
+
return {
|
|
178
|
+
verified: isValid,
|
|
179
|
+
actorUri: isValid ? keyData.actorUri : undefined,
|
|
180
|
+
reason: isValid ? undefined : 'verify-failed',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
options.onDebug?.('HTTP signature verification failed:', err);
|
|
185
|
+
return { verified: false, reason: err instanceof Error ? err.message : 'verify-exception' };
|
|
186
|
+
}
|
|
187
|
+
}
|