@takosjp/yurucommu-core 3.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 +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- package/src/runtime/site-worker.ts +10 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// HTTP Signature verification (companion to ap-signing.ts)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// This is the verification side of HTTP Signatures: actor public-key fetching
|
|
6
|
+
// + cache, strict RFC 7231 IMF-fixdate parsing, signature-header parsing, and
|
|
7
|
+
// RSA verification. `ap-signing.ts` owns the signing side (generateKeyPair /
|
|
8
|
+
// signRequest); together they co-locate all HTTP-signature crypto in one lib.
|
|
9
|
+
//
|
|
10
|
+
// These functions take a plain `Request` plus a `Database` handle rather than a
|
|
11
|
+
// Hono context so the crypto domain stays free of routing types — callers pull
|
|
12
|
+
// `c.req.raw` and `c.get("db")` and pass them in.
|
|
13
|
+
|
|
14
|
+
import { eq } from "drizzle-orm";
|
|
15
|
+
import { actorCache } from "../../db/index.ts";
|
|
16
|
+
import type { Database } from "../../db/index.ts";
|
|
17
|
+
import {
|
|
18
|
+
fetchWithTimeout,
|
|
19
|
+
isSafeRemoteUrl,
|
|
20
|
+
signRequest,
|
|
21
|
+
} from "../federation-helpers.ts";
|
|
22
|
+
import { tryParseRemoteActor } from "./activitypub-validators.ts";
|
|
23
|
+
import {
|
|
24
|
+
buildActorCacheFields,
|
|
25
|
+
getInstanceFetchSignerByDb,
|
|
26
|
+
} from "./activitypub-actor-cache.ts";
|
|
27
|
+
import { logger } from "./logger.ts";
|
|
28
|
+
import { base64ToBytes, bufferToBase64 } from "./base64.ts";
|
|
29
|
+
|
|
30
|
+
const log = logger.child({ component: "activitypub.inbox" });
|
|
31
|
+
|
|
32
|
+
// Maximum allowed clock skew for HTTP signature validation (5 minutes)
|
|
33
|
+
const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000;
|
|
34
|
+
// Only a small clock-AHEAD skew is tolerated. A far-future Date would otherwise
|
|
35
|
+
// (with a symmetric ±5min window) let a captured request stay "fresh" for up to
|
|
36
|
+
// 5 minutes into the future, widening the replay window.
|
|
37
|
+
const MAX_FUTURE_SKEW_MS = 30 * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A signed request's Date must be recent: within the past window (peer clock
|
|
41
|
+
* skew + transit) and at most MAX_FUTURE_SKEW_MS into the future.
|
|
42
|
+
*/
|
|
43
|
+
function withinSignatureWindow(requestDate: Date): boolean {
|
|
44
|
+
const ageMs = Date.now() - requestDate.getTime();
|
|
45
|
+
return ageMs <= MAX_SIGNATURE_AGE_MS && ageMs >= -MAX_FUTURE_SKEW_MS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Actor public-key cache (TTL + in-flight de-dup + key-rotation detection)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
//
|
|
52
|
+
// `fetchActorPublicKey` runs on every inbound activity, so a cold-cache
|
|
53
|
+
// thundering herd against a misbehaving peer used to be able to fan out
|
|
54
|
+
// `O(activities_received)` HTTP fetches against the same actor URL. We
|
|
55
|
+
// dedupe in-flight fetches with a Promise-coalesced cache and refresh
|
|
56
|
+
// cached rows once they pass `ACTOR_CACHE_TTL_MS`.
|
|
57
|
+
//
|
|
58
|
+
// Caveat: `inFlightActorFetches` is a process-local Map. On Cloudflare
|
|
59
|
+
// Workers each isolate/replica has its own Map, so this coalescing is a
|
|
60
|
+
// best-effort same-isolate optimization, NOT a cross-isolate correctness
|
|
61
|
+
// mechanism — a burst spread across isolates can still fan out one fetch per
|
|
62
|
+
// isolate. Cross-isolate correctness comes from the persistent `actorCache`
|
|
63
|
+
// row (the durable dedup) plus the race-safe `onConflictDoUpdate` upsert
|
|
64
|
+
// below; the in-flight Map only trims redundant fetches within one isolate.
|
|
65
|
+
//
|
|
66
|
+
// Key rotation: when we re-fetch and the actor document advertises a
|
|
67
|
+
// different `publicKey.id` than what we have cached, we log it as a
|
|
68
|
+
// rotation event so operators can audit (and the new key takes effect
|
|
69
|
+
// because we overwrite the cached row).
|
|
70
|
+
const ACTOR_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
71
|
+
const inFlightActorFetches = new Map<string, Promise<string | null>>();
|
|
72
|
+
|
|
73
|
+
interface ActorFetchResult {
|
|
74
|
+
publicKeyPem: string;
|
|
75
|
+
publicKeyId: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isCachedActorFresh(lastFetchedAt: string | null): boolean {
|
|
79
|
+
if (!lastFetchedAt) return false;
|
|
80
|
+
const fetchedTime = Date.parse(lastFetchedAt);
|
|
81
|
+
if (!Number.isFinite(fetchedTime)) return false;
|
|
82
|
+
return Date.now() - fetchedTime < ACTOR_CACHE_TTL_MS;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function hasSha256Digest(
|
|
86
|
+
digestHeader: string,
|
|
87
|
+
expectedBase64: string,
|
|
88
|
+
): boolean {
|
|
89
|
+
for (const part of digestHeader.split(",")) {
|
|
90
|
+
const trimmed = part.trim();
|
|
91
|
+
const separator = trimmed.indexOf("=");
|
|
92
|
+
if (separator <= 0) continue;
|
|
93
|
+
const algorithm = trimmed.slice(0, separator).trim().toLowerCase();
|
|
94
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
95
|
+
if (algorithm === "sha-256" && value === expectedBase64) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// HTTP Date parsing (RFC 7231 IMF-fixdate, strict)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
const IMF_FIXDATE_RE =
|
|
107
|
+
/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
108
|
+
|
|
109
|
+
const IMF_MONTH_INDEX: Record<string, number> = {
|
|
110
|
+
Jan: 0,
|
|
111
|
+
Feb: 1,
|
|
112
|
+
Mar: 2,
|
|
113
|
+
Apr: 3,
|
|
114
|
+
May: 4,
|
|
115
|
+
Jun: 5,
|
|
116
|
+
Jul: 6,
|
|
117
|
+
Aug: 7,
|
|
118
|
+
Sep: 8,
|
|
119
|
+
Oct: 9,
|
|
120
|
+
Nov: 10,
|
|
121
|
+
Dec: 11,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function parseImfFixdate(value: string): Date | null {
|
|
125
|
+
const match = IMF_FIXDATE_RE.exec(value);
|
|
126
|
+
if (!match) return null;
|
|
127
|
+
const [, dayStr, monthName, yearStr, hourStr, minuteStr, secondStr] = match;
|
|
128
|
+
const day = Number(dayStr);
|
|
129
|
+
const month = IMF_MONTH_INDEX[monthName];
|
|
130
|
+
const year = Number(yearStr);
|
|
131
|
+
const hour = Number(hourStr);
|
|
132
|
+
const minute = Number(minuteStr);
|
|
133
|
+
const second = Number(secondStr);
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
!Number.isFinite(day) ||
|
|
137
|
+
!Number.isFinite(year) ||
|
|
138
|
+
!Number.isFinite(hour) ||
|
|
139
|
+
!Number.isFinite(minute) ||
|
|
140
|
+
!Number.isFinite(second) ||
|
|
141
|
+
month === undefined
|
|
142
|
+
) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const timestamp = Date.UTC(year, month, day, hour, minute, second);
|
|
147
|
+
const date = new Date(timestamp);
|
|
148
|
+
// Reject rollovers (e.g. "32 Jan" -> "1 Feb"): the produced date must
|
|
149
|
+
// round-trip back to the same calendar components.
|
|
150
|
+
if (
|
|
151
|
+
date.getUTCFullYear() !== year ||
|
|
152
|
+
date.getUTCMonth() !== month ||
|
|
153
|
+
date.getUTCDate() !== day ||
|
|
154
|
+
date.getUTCHours() !== hour ||
|
|
155
|
+
date.getUTCMinutes() !== minute ||
|
|
156
|
+
date.getUTCSeconds() !== second
|
|
157
|
+
) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return date;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Signature parsing & verification
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export function parseSignatureHeader(signatureHeader: string): {
|
|
168
|
+
keyId: string;
|
|
169
|
+
algorithm: string;
|
|
170
|
+
headers: string[];
|
|
171
|
+
signature: string;
|
|
172
|
+
} | null {
|
|
173
|
+
const params: Record<string, string> = {};
|
|
174
|
+
const regex = /(\w+)="([^"]+)"/g;
|
|
175
|
+
let match;
|
|
176
|
+
while ((match = regex.exec(signatureHeader)) !== null) {
|
|
177
|
+
params[match[1]] = match[2];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!params.keyId || !params.signature || !params.headers) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
keyId: params.keyId,
|
|
186
|
+
algorithm: (params.algorithm || "rsa-sha256").toLowerCase(),
|
|
187
|
+
headers: params.headers.trim().toLowerCase().split(/\s+/),
|
|
188
|
+
signature: params.signature,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Algorithm tokens we accept for an RSA actor key. The actual primitive is fixed
|
|
193
|
+
// by the imported SPKI key + SHA-256 (RSASSA-PKCS1-v1_5), NOT by this string, so
|
|
194
|
+
// `hs2019` (the cavage-draft-12 / RFC-9421-era token many implementations emit
|
|
195
|
+
// while still signing with an RSA-2048 key over SHA-256) is equivalent here.
|
|
196
|
+
// Rejecting it would drop cryptographically-valid inbound activity from those
|
|
197
|
+
// peers. Genuinely different key families (e.g. ed25519) still fall through to a
|
|
198
|
+
// verify failure since the RSA import/verify won't match.
|
|
199
|
+
const ACCEPTED_RSA_SHA256_ALGS = new Set(["rsa-sha256", "hs2019"]);
|
|
200
|
+
|
|
201
|
+
export async function fetchActorPublicKey(
|
|
202
|
+
keyId: string,
|
|
203
|
+
db: Database,
|
|
204
|
+
): Promise<string | null> {
|
|
205
|
+
if (!isSafeRemoteUrl(keyId)) {
|
|
206
|
+
log.warn("Blocked unsafe keyId URL", {
|
|
207
|
+
event: "ap.signature.unsafe_key_id",
|
|
208
|
+
keyId,
|
|
209
|
+
});
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const actorUrl = keyId.includes("#") ? keyId.split("#")[0] : keyId;
|
|
214
|
+
|
|
215
|
+
const cached = await db.query.actorCache.findFirst({
|
|
216
|
+
where: eq(actorCache.apId, actorUrl),
|
|
217
|
+
columns: {
|
|
218
|
+
publicKeyPem: true,
|
|
219
|
+
publicKeyId: true,
|
|
220
|
+
lastFetchedAt: true,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (cached?.publicKeyPem && isCachedActorFresh(cached.lastFetchedAt)) {
|
|
225
|
+
return cached.publicKeyPem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Coalesce concurrent fetches for the same actor URL. Without this a
|
|
229
|
+
// burst of inbound activities from the same peer would all miss the
|
|
230
|
+
// cache simultaneously and each open its own HTTP fetch.
|
|
231
|
+
const existingFetch = inFlightActorFetches.get(actorUrl);
|
|
232
|
+
if (existingFetch) {
|
|
233
|
+
return await existingFetch;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const fetchPromise = (async (): Promise<string | null> => {
|
|
237
|
+
try {
|
|
238
|
+
// Sign the key-fetch GET as the instance actor so a remote in
|
|
239
|
+
// authorized-fetch / secure mode (which 401s unsigned actor GETs) serves
|
|
240
|
+
// its actor document — otherwise we could never fetch the key needed to
|
|
241
|
+
// verify that remote's INBOUND activities, silently dropping everything
|
|
242
|
+
// from secure-mode instances. Falls back to unsigned if the instance
|
|
243
|
+
// actor row does not exist yet (harmless for non-secure remotes).
|
|
244
|
+
const signer = await getInstanceFetchSignerByDb(db);
|
|
245
|
+
const res = await fetchWithTimeout(actorUrl, {
|
|
246
|
+
headers: {
|
|
247
|
+
Accept: "application/activity+json, application/ld+json",
|
|
248
|
+
...(signer
|
|
249
|
+
? await signRequest(
|
|
250
|
+
signer.privateKeyPem,
|
|
251
|
+
signer.keyId,
|
|
252
|
+
"GET",
|
|
253
|
+
actorUrl,
|
|
254
|
+
)
|
|
255
|
+
: {}),
|
|
256
|
+
},
|
|
257
|
+
timeout: 15000,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
log.warn("Failed to fetch actor for signature key", {
|
|
262
|
+
event: "ap.signature.actor_fetch_failed",
|
|
263
|
+
keyId,
|
|
264
|
+
actorUrl,
|
|
265
|
+
status: res.status,
|
|
266
|
+
});
|
|
267
|
+
// Fall back to the stale cached key (if any) so a transient
|
|
268
|
+
// upstream failure does not bring federation to a halt.
|
|
269
|
+
return cached?.publicKeyPem ?? null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const rawActor: unknown = await res.json();
|
|
273
|
+
const actorData = tryParseRemoteActor(rawActor);
|
|
274
|
+
if (!actorData) {
|
|
275
|
+
log.warn("Invalid actor document for signature key", {
|
|
276
|
+
event: "ap.signature.actor_invalid",
|
|
277
|
+
keyId,
|
|
278
|
+
actorUrl,
|
|
279
|
+
});
|
|
280
|
+
// Fail CLOSED, not back to the stale key: an invalid document is not a
|
|
281
|
+
// transient network blip — it means the actor doc genuinely changed (or
|
|
282
|
+
// an on-path attacker is serving garbage to keep a rotated/compromised
|
|
283
|
+
// key alive). Falling back to the cached key would let a key the owner
|
|
284
|
+
// already rotated away from keep verifying.
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
if (!actorData.publicKey?.publicKeyPem) {
|
|
288
|
+
log.warn("Actor has no public key", {
|
|
289
|
+
event: "ap.actor.no_public_key",
|
|
290
|
+
keyId,
|
|
291
|
+
actorUrl,
|
|
292
|
+
actor: actorData.id,
|
|
293
|
+
});
|
|
294
|
+
// Fail closed: a well-formed actor doc that now has NO key means the key
|
|
295
|
+
// was removed — don't keep trusting the stale cached one.
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const result: ActorFetchResult = {
|
|
300
|
+
publicKeyPem: actorData.publicKey.publicKeyPem,
|
|
301
|
+
publicKeyId: actorData.publicKey.id ?? null,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Key rotation detection: log whenever a fresh fetch produces a
|
|
305
|
+
// different `publicKey.id` (or a different PEM under the same id)
|
|
306
|
+
// than the cached row. This lets operators audit unexpected key
|
|
307
|
+
// changes that might indicate an actor compromise.
|
|
308
|
+
if (cached?.publicKeyPem) {
|
|
309
|
+
const previousKeyId = cached.publicKeyId ?? null;
|
|
310
|
+
const keyIdChanged = result.publicKeyId !== previousKeyId;
|
|
311
|
+
const pemChanged = cached.publicKeyPem !== result.publicKeyPem;
|
|
312
|
+
if (keyIdChanged || pemChanged) {
|
|
313
|
+
log.warn("Detected actor public-key rotation", {
|
|
314
|
+
event: "ap.actor.key_rotation",
|
|
315
|
+
actorUrl,
|
|
316
|
+
previousKeyId,
|
|
317
|
+
newKeyId: result.publicKeyId,
|
|
318
|
+
pemChanged,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (actorData.id !== actorUrl) {
|
|
324
|
+
log.warn("Actor ID mismatch during signature key fetch", {
|
|
325
|
+
event: "ap.signature.actor_id_mismatch",
|
|
326
|
+
keyId,
|
|
327
|
+
actorUrl,
|
|
328
|
+
receivedId: actorData.id,
|
|
329
|
+
});
|
|
330
|
+
// Fail closed: the document served under this actor URL claims a
|
|
331
|
+
// DIFFERENT id — a redirect/substitution, not a transient error. Never
|
|
332
|
+
// fall back to the stale key on a mismatch.
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (
|
|
337
|
+
actorData.id &&
|
|
338
|
+
actorData.inbox &&
|
|
339
|
+
isSafeRemoteUrl(actorData.id) &&
|
|
340
|
+
isSafeRemoteUrl(actorData.inbox)
|
|
341
|
+
) {
|
|
342
|
+
// Reuse the already-fetched signature actor document and write it
|
|
343
|
+
// through the ONE canonical superset cache shape, so this opportunistic
|
|
344
|
+
// upsert populates `outbox` / `followersUrl` / `sharedInbox` identically
|
|
345
|
+
// to every other entry path.
|
|
346
|
+
const cacheFields = buildActorCacheFields(actorData);
|
|
347
|
+
// Single atomic upsert. A check-then-insert/update is racy across
|
|
348
|
+
// Worker isolates: two isolates racing the same cold actor can both
|
|
349
|
+
// miss the existence check and then both INSERT, and the loser hits a
|
|
350
|
+
// primary-key violation that would null out a successfully-fetched
|
|
351
|
+
// key and spuriously reject a validly-signed activity.
|
|
352
|
+
// `onConflictDoUpdate` collapses that to one race-safe statement
|
|
353
|
+
// (same pattern as fetchAndCacheRemoteActor in queue-batching.ts).
|
|
354
|
+
await db
|
|
355
|
+
.insert(actorCache)
|
|
356
|
+
.values({ apId: actorData.id, ...cacheFields })
|
|
357
|
+
.onConflictDoUpdate({ target: actorCache.apId, set: cacheFields });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return result.publicKeyPem;
|
|
361
|
+
} catch (e) {
|
|
362
|
+
log.error("Error fetching actor for signature key", {
|
|
363
|
+
event: "ap.signature.actor_fetch_error",
|
|
364
|
+
keyId,
|
|
365
|
+
actorUrl,
|
|
366
|
+
error: e,
|
|
367
|
+
});
|
|
368
|
+
return cached?.publicKeyPem ?? null;
|
|
369
|
+
} finally {
|
|
370
|
+
inFlightActorFetches.delete(actorUrl);
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
|
|
374
|
+
inFlightActorFetches.set(actorUrl, fetchPromise);
|
|
375
|
+
return await fetchPromise;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Extract the actor URL from a keyId (strips the fragment, e.g. "#main-key").
|
|
380
|
+
*/
|
|
381
|
+
function signingActorFromKeyId(keyId: string | undefined): string | undefined {
|
|
382
|
+
if (!keyId) return undefined;
|
|
383
|
+
return keyId.includes("#") ? keyId.split("#")[0] : keyId;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Verify the HTTP Signature on a GET request (no body digest required).
|
|
388
|
+
* Returns the resolved signing actor URL on success. Used to gate
|
|
389
|
+
* non-public object reads.
|
|
390
|
+
*/
|
|
391
|
+
export async function verifyGetHttpSignature(
|
|
392
|
+
request: Request,
|
|
393
|
+
db: Database,
|
|
394
|
+
): Promise<{ valid: boolean; signingActor?: string; error?: string }> {
|
|
395
|
+
const signatureHeader = request.headers.get("Signature");
|
|
396
|
+
if (!signatureHeader) {
|
|
397
|
+
return { valid: false, error: "Missing Signature header" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
401
|
+
if (!parsed) {
|
|
402
|
+
return { valid: false, error: "Invalid Signature header format" };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!parsed.headers.includes("date")) {
|
|
406
|
+
return { valid: false, error: "date header must be included in signature" };
|
|
407
|
+
}
|
|
408
|
+
if (!parsed.headers.includes("(request-target)")) {
|
|
409
|
+
return { valid: false, error: "(request-target) must be signed" };
|
|
410
|
+
}
|
|
411
|
+
// Require `host` among the signed headers and verify it matches the request
|
|
412
|
+
// target, mirroring the POST path (verifyHttpSignature). (request-target)
|
|
413
|
+
// signs only method + path, NOT the host, so without binding `host` a
|
|
414
|
+
// signature captured for path P on one host would also verify on a DIFFERENT
|
|
415
|
+
// host serving the same path P — a cross-target read-gate replay surface. The
|
|
416
|
+
// signed-host match is enforced after the URL is parsed below.
|
|
417
|
+
if (!parsed.headers.includes("host")) {
|
|
418
|
+
return { valid: false, error: "host header must be included in signature" };
|
|
419
|
+
}
|
|
420
|
+
if (!ACCEPTED_RSA_SHA256_ALGS.has(parsed.algorithm)) {
|
|
421
|
+
return {
|
|
422
|
+
valid: false,
|
|
423
|
+
error: `Unsupported algorithm: ${parsed.algorithm}`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const dateHeader = request.headers.get("date");
|
|
428
|
+
if (!dateHeader) {
|
|
429
|
+
return { valid: false, error: "Missing Date header required by signature" };
|
|
430
|
+
}
|
|
431
|
+
const requestDate = parseImfFixdate(dateHeader);
|
|
432
|
+
if (!requestDate) {
|
|
433
|
+
return { valid: false, error: "unable_to_parse_date" };
|
|
434
|
+
}
|
|
435
|
+
if (!withinSignatureWindow(requestDate)) {
|
|
436
|
+
return {
|
|
437
|
+
valid: false,
|
|
438
|
+
error: "Request timestamp outside acceptable window",
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const url = new URL(request.url);
|
|
443
|
+
// The signed Host value must match the host this request was actually
|
|
444
|
+
// delivered to, otherwise a validly-signed GET for one origin could be
|
|
445
|
+
// replayed against another target sharing the same actor key.
|
|
446
|
+
const signedHost = request.headers.get("host");
|
|
447
|
+
if (!signedHost) {
|
|
448
|
+
return { valid: false, error: "Missing Host header required by signature" };
|
|
449
|
+
}
|
|
450
|
+
if (signedHost.toLowerCase() !== url.host.toLowerCase()) {
|
|
451
|
+
return { valid: false, error: "Host header does not match request target" };
|
|
452
|
+
}
|
|
453
|
+
const requestTarget = `${url.pathname}${url.search}`;
|
|
454
|
+
const signatureParts: string[] = [];
|
|
455
|
+
for (const headerName of parsed.headers) {
|
|
456
|
+
if (headerName === "(request-target)") {
|
|
457
|
+
signatureParts.push(
|
|
458
|
+
`(request-target): ${request.method.toLowerCase()} ${requestTarget}`,
|
|
459
|
+
);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const headerValue = request.headers.get(headerName);
|
|
463
|
+
if (!headerValue) {
|
|
464
|
+
return { valid: false, error: `Missing required header: ${headerName}` };
|
|
465
|
+
}
|
|
466
|
+
signatureParts.push(`${headerName}: ${headerValue}`);
|
|
467
|
+
}
|
|
468
|
+
const signatureString = signatureParts.join("\n");
|
|
469
|
+
|
|
470
|
+
const publicKeyPem = await fetchActorPublicKey(parsed.keyId, db);
|
|
471
|
+
if (!publicKeyPem) {
|
|
472
|
+
return { valid: false, error: "Could not fetch public key" };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const pemContents = publicKeyPem
|
|
477
|
+
.replace(/-----[^-]+-----/g, "")
|
|
478
|
+
.replace(/\s/g, "");
|
|
479
|
+
const binaryKey = base64ToBytes(pemContents);
|
|
480
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
481
|
+
"spki",
|
|
482
|
+
binaryKey.buffer as ArrayBuffer,
|
|
483
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
484
|
+
false,
|
|
485
|
+
["verify"],
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
const signatureBytes = base64ToBytes(parsed.signature);
|
|
489
|
+
const valid = await crypto.subtle.verify(
|
|
490
|
+
"RSASSA-PKCS1-v1_5",
|
|
491
|
+
cryptoKey,
|
|
492
|
+
signatureBytes.buffer as ArrayBuffer,
|
|
493
|
+
new TextEncoder().encode(signatureString),
|
|
494
|
+
);
|
|
495
|
+
if (!valid) {
|
|
496
|
+
return { valid: false, error: "Signature verification failed" };
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
valid: true,
|
|
500
|
+
signingActor: signingActorFromKeyId(parsed.keyId),
|
|
501
|
+
};
|
|
502
|
+
} catch (e) {
|
|
503
|
+
log.error("GET signature verification error", {
|
|
504
|
+
event: "ap.signature.get_verification_error",
|
|
505
|
+
keyId: parsed.keyId,
|
|
506
|
+
error: e,
|
|
507
|
+
});
|
|
508
|
+
return { valid: false, error: "Signature verification error" };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function verifyHttpSignature(
|
|
513
|
+
request: Request,
|
|
514
|
+
db: Database,
|
|
515
|
+
body: string,
|
|
516
|
+
): Promise<{ valid: boolean; keyId?: string; error?: string }> {
|
|
517
|
+
const signatureHeader = request.headers.get("Signature");
|
|
518
|
+
if (!signatureHeader) {
|
|
519
|
+
return { valid: false, error: "Missing Signature header" };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
523
|
+
if (!parsed) {
|
|
524
|
+
return { valid: false, error: "Invalid Signature header format" };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!parsed.headers.includes("date")) {
|
|
528
|
+
return { valid: false, error: "date header must be included in signature" };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Validate Date header timestamp (prevents replay attacks). RFC 7231
|
|
532
|
+
// requires IMF-fixdate (e.g. "Sun, 06 Nov 1994 08:49:37 GMT"). We parse
|
|
533
|
+
// strictly to reject ambiguous/locale-dependent inputs that `new Date()`
|
|
534
|
+
// would otherwise accept (rfc850 / asctime / arbitrary strings).
|
|
535
|
+
const dateHeader = request.headers.get("date");
|
|
536
|
+
if (!dateHeader) {
|
|
537
|
+
return { valid: false, error: "Missing Date header required by signature" };
|
|
538
|
+
}
|
|
539
|
+
const requestDate = parseImfFixdate(dateHeader);
|
|
540
|
+
if (!requestDate) {
|
|
541
|
+
return { valid: false, error: "unable_to_parse_date" };
|
|
542
|
+
}
|
|
543
|
+
if (!withinSignatureWindow(requestDate)) {
|
|
544
|
+
return {
|
|
545
|
+
valid: false,
|
|
546
|
+
error: "Request timestamp outside acceptable window",
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!ACCEPTED_RSA_SHA256_ALGS.has(parsed.algorithm)) {
|
|
551
|
+
return {
|
|
552
|
+
valid: false,
|
|
553
|
+
error: `Unsupported algorithm: ${parsed.algorithm}`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!parsed.headers.includes("(request-target)")) {
|
|
558
|
+
return { valid: false, error: "(request-target) must be signed" };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!parsed.headers.includes("digest")) {
|
|
562
|
+
return {
|
|
563
|
+
valid: false,
|
|
564
|
+
error:
|
|
565
|
+
"digest header must be included in signature to ensure body integrity",
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Require `host` among the signed headers (matching Mastodon's required
|
|
570
|
+
// header set). Without binding `host`, a signature captured for one target
|
|
571
|
+
// could be replayed against a different host — a cross-target replay
|
|
572
|
+
// surface. We also verify below that the signed Host value matches the
|
|
573
|
+
// request URL host so an attacker cannot forward the request elsewhere.
|
|
574
|
+
if (!parsed.headers.includes("host")) {
|
|
575
|
+
return {
|
|
576
|
+
valid: false,
|
|
577
|
+
error: "host header must be included in signature",
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Build the signature string from headers
|
|
582
|
+
const url = new URL(request.url);
|
|
583
|
+
const requestTarget = `${url.pathname}${url.search}`;
|
|
584
|
+
|
|
585
|
+
// The signed Host value must match the host this request was actually
|
|
586
|
+
// delivered to, otherwise a validly-signed request for one origin could be
|
|
587
|
+
// replayed against another target that shares the same actor key.
|
|
588
|
+
const signedHost = request.headers.get("host");
|
|
589
|
+
if (!signedHost) {
|
|
590
|
+
return { valid: false, error: "Missing Host header required by signature" };
|
|
591
|
+
}
|
|
592
|
+
if (signedHost.toLowerCase() !== url.host.toLowerCase()) {
|
|
593
|
+
return { valid: false, error: "Host header does not match request target" };
|
|
594
|
+
}
|
|
595
|
+
const signatureParts: string[] = [];
|
|
596
|
+
|
|
597
|
+
for (const headerName of parsed.headers) {
|
|
598
|
+
if (headerName === "(request-target)") {
|
|
599
|
+
signatureParts.push(
|
|
600
|
+
`(request-target): ${request.method.toLowerCase()} ${requestTarget}`,
|
|
601
|
+
);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const headerValue = request.headers.get(headerName);
|
|
605
|
+
if (!headerValue) {
|
|
606
|
+
return { valid: false, error: `Missing required header: ${headerName}` };
|
|
607
|
+
}
|
|
608
|
+
signatureParts.push(`${headerName}: ${headerValue}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const signatureString = signatureParts.join("\n");
|
|
612
|
+
|
|
613
|
+
// Verify body digest
|
|
614
|
+
const digestHeader = request.headers.get("digest");
|
|
615
|
+
if (!digestHeader) {
|
|
616
|
+
return {
|
|
617
|
+
valid: false,
|
|
618
|
+
error: "Digest header missing but required by signature",
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const bodyHash = await crypto.subtle.digest(
|
|
622
|
+
"SHA-256",
|
|
623
|
+
new TextEncoder().encode(body),
|
|
624
|
+
);
|
|
625
|
+
const expectedDigest = bufferToBase64(bodyHash);
|
|
626
|
+
if (!hasSha256Digest(digestHeader, expectedDigest)) {
|
|
627
|
+
return { valid: false, error: "Digest mismatch" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Fetch public key and verify
|
|
631
|
+
const publicKeyPem = await fetchActorPublicKey(parsed.keyId, db);
|
|
632
|
+
if (!publicKeyPem) {
|
|
633
|
+
return { valid: false, error: "Could not fetch public key" };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
const pemContents = publicKeyPem
|
|
638
|
+
.replace(/-----[^-]+-----/g, "")
|
|
639
|
+
.replace(/\s/g, "");
|
|
640
|
+
const binaryKey = base64ToBytes(pemContents);
|
|
641
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
642
|
+
"spki",
|
|
643
|
+
binaryKey.buffer as ArrayBuffer,
|
|
644
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
645
|
+
false,
|
|
646
|
+
["verify"],
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const signatureBytes = base64ToBytes(parsed.signature);
|
|
650
|
+
const valid = await crypto.subtle.verify(
|
|
651
|
+
"RSASSA-PKCS1-v1_5",
|
|
652
|
+
cryptoKey,
|
|
653
|
+
signatureBytes.buffer as ArrayBuffer,
|
|
654
|
+
new TextEncoder().encode(signatureString),
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
if (!valid) {
|
|
658
|
+
return { valid: false, error: "Signature verification failed" };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return { valid: true, keyId: parsed.keyId };
|
|
662
|
+
} catch (e) {
|
|
663
|
+
log.error("Signature verification error", {
|
|
664
|
+
event: "ap.signature.verification_error",
|
|
665
|
+
keyId: parsed.keyId,
|
|
666
|
+
error: e,
|
|
667
|
+
});
|
|
668
|
+
return { valid: false, error: "Signature verification error" };
|
|
669
|
+
}
|
|
670
|
+
}
|