@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.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. 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
+ }