fedipod 0.8.0 → 0.10.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/bin/fedipod.mjs CHANGED
@@ -1492,12 +1492,44 @@ if (cmd === 'up') {
1492
1492
  console.log('UI password set — /oauth/authorize now shows a login form (restart a running agent to pick it up)');
1493
1493
  } else if (cmd === 'gateway' || cmd === 'front') {
1494
1494
  // Attach this identity to a gateway ('front' kept as an alias). Shapes:
1495
- // fedipod gateway <.../ap/inbox/> --secret S --inbox-only the usual one:
1495
+ // fedipod gateway --attach <origin> [--name N] [--fronted] ask the gateway
1496
+ // itself: the agent proves the pod with its own credential and stores
1497
+ // the door + secret the gateway answers with.
1498
+ // fedipod gateway <.../ap/inbox/> --secret S --inbox-only paste-in form:
1496
1499
  // identity stays on the pod; only the advertised inbox moves to the
1497
1500
  // gateway's door. Leaving is one republish with the pod inbox.
1498
1501
  // fedipod gateway <.../ap/actor> --secret S fronted identity
1499
1502
  // fedipod gateway --detach back to the pod inbox
1500
1503
  requireIdentity();
1504
+ if (has('attach')) {
1505
+ const front = flag('attach');
1506
+ if (!/^https?:\/\/\S+$/.test(String(front || ''))) {
1507
+ console.error('usage: fedipod gateway --attach <https://gateway-origin> [--name yourname] [--fronted]');
1508
+ process.exit(2);
1509
+ }
1510
+ try {
1511
+ const res = await localFetch(HOME, PORT, `/gateway`, {
1512
+ method: 'POST', headers: { 'content-type': 'application/json' },
1513
+ body: JSON.stringify({ action: 'attach', front,
1514
+ ...(flag('name') ? { handle: flag('name') } : {}),
1515
+ ...(has('fronted') ? { fronted: true } : {}) }),
1516
+ });
1517
+ const body = await res.json();
1518
+ if (res.status >= 400) { console.error(body.error || `HTTP ${res.status}`); process.exit(1); }
1519
+ if (body.frontActor) {
1520
+ console.log(`attached — this identity now publishes as ${body.address || body.frontActor}`);
1521
+ console.log('Restart the agent (or `fedipod up`) to republish under the front.');
1522
+ } else {
1523
+ console.log(`attached — your mail now arrives through ${body.url}, filtered; your name has not moved.`);
1524
+ console.log('Starting in shadow: the door filters, and the agent measures how much');
1525
+ console.log('verifies before it believes any receipt. Move to trust when you are ready.');
1526
+ }
1527
+ } catch (e) {
1528
+ console.error(`agent not reachable on :${PORT} (${e.message}) — start it, then attach`);
1529
+ process.exit(1);
1530
+ }
1531
+ process.exit(0);
1532
+ }
1501
1533
  if (has('detach')) {
1502
1534
  try {
1503
1535
  const res = await localFetch(HOME, PORT, `/gateway`, {
package/cli.md CHANGED
@@ -38,9 +38,10 @@ several identities, say which you mean. `profiles` does not list identities
38
38
  under a custom `AP_HOME`.
39
39
 
40
40
  Commands that manage a live identity — `alias`, `admit`, `import`, `archive`,
41
- `bsky`, `describe`, `status`, `rebuild`, `gateway --detach` and the group
42
- commands — talk to the running agent and say so when it is not there. `keys`,
43
- `state`, `home` and attaching a gateway want the agent stopped.
41
+ `bsky`, `describe`, `status`, `rebuild`, `gateway --attach`,
42
+ `gateway --detach` and the group commands — talk to the running agent and say
43
+ so when it is not there. `keys`, `state`, `home` and the paste-in gateway form
44
+ (`gateway <url> --secret …`) want the agent stopped.
44
45
 
45
46
  ## Recovery
46
47
 
@@ -189,9 +190,16 @@ are not imported.
189
190
  ## The gateway
190
191
 
191
192
  ```
193
+ fedipod gateway --attach <gateway-origin> [--name N] [--fronted]
192
194
  fedipod gateway <door-inbox-url> --secret <hmac> --inbox-only
193
195
  fedipod gateway --detach
194
196
  ```
197
+
198
+ `--attach` asks the gateway itself: the agent proves the pod with its own
199
+ credential, and the gateway answers with the door and the receipt secret.
200
+ `--name` defaults to your handle; `--fronted` takes a gateway-based name,
201
+ published at the next restart. The admin page's Gateway panel is the same
202
+ action with the name checked as you type.
195
203
  Attaching points your actor's advertised inbox at a gateway's door, so
196
204
  deliveries are verified and de-junked before they reach your pod; your name,
197
205
  key and data stay on your pod. The URL and secret come from the gateway's
package/gui.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Open `https://localhost:8030/` while any agent is running — it forwards you to the agent — then choose `manage account` and select the actor you want from the local actors dropdown.
4
4
  Picking an actor marked "(stopped)" starts its agent, then opens its page.
5
5
 
6
+ The software row names the version the agent is running. When the copy on the machine is further ahead — after an update, or after pulling a checkout — it says so and asks for a restart, because an agent goes on serving the code it started with until it is restarted.
7
+
6
8
  `Update`, shown on the software row when a newer FediPod exists, pulls the latest version and restarts every agent on the machine — agents installed as services; any started by hand need their own restart. The same row flags an older data layout after an update; the `fedipod upgrade` terminal command lists those moves and the commands that make them.
7
9
 
8
10
  * **Parking** (the status control) stops the mail and unfollows people you follow, but keeps your handle
@@ -70,12 +72,24 @@ follows the group's own moderation settings; see [Groups](groups.md).
70
72
  **new followers** control switches between waiting for your approval and
71
73
  accepting automatically.
72
74
 
75
+ ## The gateway
76
+
77
+ The **Gateway** panel attaches this account to a mail-filtering gateway (a
78
+ fedipod.net-style front), shows what its door has verified, and detaches it.
79
+ Give the gateway's address and the name you want there — the panel checks the
80
+ name is free as you type — and choose a pod-based name (`@you@your.pod`) or a
81
+ gateway-based name (`@you@the-gateway`); the gateway account is created
82
+ automatically either way. Attaching proves the pod with the agent's own
83
+ credential, so no password is typed anywhere. Detaching republishes your
84
+ actor with your pod's own inbox.
85
+
73
86
  ## Moving here from another server
74
87
 
75
88
  Open **Transfer an account here** on the action rail and add your old account
76
89
  as an alias, set
77
90
  **new followers** to *accepted automatically*, then trigger the move on the
78
- old server; your followers arrive by themselves. Removing an alias asks
91
+ old server (on Mastodon: Preferences Account *Move to a different
92
+ account*); your followers arrive by themselves. Removing an alias asks
79
93
  twice — servers still processing the move check it while they retry. The CSV
80
94
  files from the old server's export are imported with the CLI; see
81
95
  [CLI admin](cli.md).
package/lib/admin.mjs CHANGED
@@ -32,6 +32,7 @@ import { portFree, freePortFrom } from './ports.mjs';
32
32
  import { claimDirectory, yieldDirectory } from './directory.mjs';
33
33
  import { localFetch } from './localapi.mjs';
34
34
  import { ensureTrustedTls } from './certs.mjs';
35
+ import { localVersion } from './update.mjs';
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
  const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -325,7 +326,8 @@ function sendFile(res, baseDir, rel, auth) {
325
326
  // guards the operator's door (basePath) instead of the whole surface.
326
327
  export function buildAdminSurface({ agent, gate, allowed, log = console.log,
327
328
  port = null, handle = null, embedded = false, basePath = '/',
328
- publicOrigin = null, scheme = null }) {
329
+ publicOrigin = null, scheme = null,
330
+ versionOnDisk = () => localVersion(projectRoot) }) {
329
331
  const json = (res, status, obj) => sendJson(res, status, obj, allowed);
330
332
  const masto = new MastoApi({ agent, log, allowed, scheme, embedded });
331
333
  // The spec's own write API (§6), beside the facade. Its bearer fallback is
@@ -592,6 +594,10 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
592
594
  privateRoot: agent.readCredential?.()?.privateRoot || null,
593
595
  mode: agent.status?.().mode || null, port, home: tildify(agent.home) || null,
594
596
  update: agent.updateInfo || null,
597
+ // What this process is running, against what a restart would run:
598
+ // the checkout can move while an agent stays up.
599
+ version: AGENT_VERSION,
600
+ versionOnDisk: versionOnDisk(),
595
601
  pendingUpgrade: agent.pendingUpgrade || [],
596
602
  // The connected Bluesky account, non-secret half. `connected` is the
597
603
  // credential's word, so a config entry orphaned by a deleted
@@ -1423,6 +1429,16 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1423
1429
  const cfg = { ...agent.store.getConfig() };
1424
1430
  const g = { ...(cfg.gateway || {}) };
1425
1431
  const inboxUrl = agent.publisher?.urls?.inbox;
1432
+ // Availability is a read: answered before the lease takeover below,
1433
+ // which every real gateway change does want.
1434
+ if (body.action === 'check') {
1435
+ const front = String(body.front || '').replace(/\/+$/, '');
1436
+ try { new URL(front); } catch { return json(res, 400, { error: 'front must be a gateway origin URL' }); }
1437
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(String(body.handle || '').toLowerCase())}`,
1438
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1439
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1440
+ return json(res, 200, { available: !!chk.available, reason: chk.reason || null });
1441
+ }
1426
1442
  await agent.requestTakeover?.();
1427
1443
  const persist = async () => {
1428
1444
  cfg.gateway = g; agent.store.setConfig(cfg);
@@ -1478,6 +1494,56 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1478
1494
  else if (target !== 'off') await agent.publisher?.publishGatewayPolicy().catch(() => {});
1479
1495
  return json(res, 200, { ok: true, mode: g.mode });
1480
1496
  }
1497
+ // Attach through a multi-user front (fedipod.net and kin): the agent
1498
+ // proves the pod with its own credential — no browser, no password —
1499
+ // and the front answers with the door and the receipt secret.
1500
+ if (body.action === 'attach') {
1501
+ const front = String(body.front || '').replace(/\/+$/, '');
1502
+ let fu;
1503
+ try { fu = new URL(front); } catch { return json(res, 400, { error: 'front must be the gateway origin, like https://fedipod.net' }); }
1504
+ if (fu.protocol !== 'https:' && !/^(localhost|127\.0\.0\.1)$|\.localhost$/.test(fu.hostname)) {
1505
+ return json(res, 400, { error: 'front must be https' });
1506
+ }
1507
+ if (agent.embedded) {
1508
+ return json(res, 400, { error: 'this identity runs inside its pod server and has no portable credential — attach from a standalone agent' });
1509
+ }
1510
+ const handle = String(body.handle || cfg.handle || '').toLowerCase().trim();
1511
+ if (!handle) return json(res, 400, { error: 'a name at the gateway is required' });
1512
+ const fronted = body.fronted === true;
1513
+ const frontActor = `${front}/u/${handle}/ap/actor`;
1514
+ if (g.frontActor && (!fronted || g.frontActor !== frontActor)) {
1515
+ return json(res, 400, { error: `this identity already fronts through ${g.frontActor} — changing a published front renames every id; detach first if you mean it` });
1516
+ }
1517
+ // Availability first, for a clean answer before anything is created.
1518
+ const chk = await fetch(`${front}/api/handle?handle=${encodeURIComponent(handle)}`,
1519
+ { headers: { accept: 'application/json' } }).then((r) => r.json()).catch(() => null);
1520
+ if (!chk) return json(res, 502, { error: `${front} did not answer its handle check` });
1521
+ if (!chk.available) return json(res, 409, { error: chk.reason || `the name ${handle} is taken at ${front}` });
1522
+ const attach = await agent.remote.session.fetch(`${front}/api/attach`, {
1523
+ method: 'POST', headers: { 'content-type': 'application/json' },
1524
+ body: JSON.stringify({ handle, podHome: agent.urls.home, kind: cfg.kind || 'person', fronted }),
1525
+ }).catch(() => null);
1526
+ if (!attach) return json(res, 502, { error: `${front} did not answer the attach` });
1527
+ const d = await attach.json().catch(() => ({}));
1528
+ if (attach.status !== 201) {
1529
+ return json(res, attach.status >= 400 && attach.status < 500 ? attach.status : 502,
1530
+ { error: d.error || `attach failed (HTTP ${attach.status})` });
1531
+ }
1532
+ g.url = String(d.doorInbox || `${front}/u/${handle}/ap/inbox/`);
1533
+ if (d.hmacSecret) g.hmacSecret = String(d.hmacSecret);
1534
+ if (fronted) g.frontActor = String(d.frontActor || frontActor);
1535
+ if (!g.mode || g.mode === 'off') g.mode = 'shadow';
1536
+ await persist();
1537
+ // Inbox-only applies live: the actor republishes advertising the
1538
+ // door. A front carries new ids, which are wired at startup — that
1539
+ // attach persists and owes a restart.
1540
+ if (!fronted) {
1541
+ await agent.publisher?.publishProfile();
1542
+ await agent.publisher?.publishGatewayPolicy?.().catch(() => {});
1543
+ }
1544
+ return json(res, 200, { ok: true, mode: g.mode, url: g.url,
1545
+ ...(fronted ? { frontActor: g.frontActor, address: d.address || null, restart: true } : {}) });
1546
+ }
1481
1547
  if (body.action === 'forget') {
1482
1548
  const wasLocked = g.mode === 'locked';
1483
1549
  delete cfg.gateway; agent.store.setConfig(cfg);
@@ -1487,7 +1553,7 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1487
1553
  await agent.publisher?.publishProfile(); // re-advertise the pod inbox
1488
1554
  return json(res, 200, { ok: true, mode: 'off', forgotten: true });
1489
1555
  }
1490
- return json(res, 400, { error: 'action must be configure, mode or forget' });
1556
+ return json(res, 400, { error: 'action must be configure, mode, check, attach or forget' });
1491
1557
  }
1492
1558
  // Symmetrical with /block, and open for the same reason: a block made
1493
1559
  // by mistake is worth undoing before federation is even configured.
@@ -1534,14 +1600,17 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
1534
1600
  return { handler, masto, c2s, streaming };
1535
1601
  }
1536
1602
 
1537
- export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null }) {
1603
+ export function startAdmin({ port, gateToken, agent, log = console.log, handle = null, tls = null,
1604
+ // Injectable, so a test can put a checkout ahead of the running process
1605
+ // without editing the package.json of the machine running the test.
1606
+ versionOnDisk = () => localVersion(projectRoot) }) {
1538
1607
  const gate = makeGate(gateToken);
1539
1608
  // Live, so the named origin appears the moment connect() reads the handle
1540
1609
  // out of pod state — including for the OAuth redirect check in MastoApi.
1541
1610
  // The https listener's port joins the authority set: same names, second port.
1542
1611
  const allowed = new Authorities(port, handle);
1543
1612
  agent.authorities = allowed;
1544
- const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle });
1613
+ const { handler, streaming } = buildAdminSurface({ agent, gate, allowed, log, port, handle, versionOnDisk });
1545
1614
 
1546
1615
  // Loopback both ways: the canonical URL is https://localhost:<port>/, and
1547
1616
  // "localhost" resolves to ::1 on many systems before falling back to IPv4 —
package/lib/deliver.mjs CHANGED
@@ -4,11 +4,43 @@
4
4
  // dropped after MAX_ATTEMPTS (~3 days).
5
5
 
6
6
  import { signRequest } from '@fedify/fedify/sig';
7
+ import { webcrypto } from 'node:crypto';
8
+ import { attachProof } from './proof.mjs';
7
9
  import { pinnedFor, retryAfterMs, HTTP_TIMEOUT_MS } from './safefetch.mjs';
8
10
  import { USER_AGENT } from './ua.mjs';
9
11
 
10
12
  const MAX_REDIRECTS = 3;
11
13
 
14
+ // draft-cavage signs `(request-target): <method> <path><query>`. Fedify 2.3.4
15
+ // builds that line from the pathname alone, so a signature over any URL
16
+ // carrying a query is one a correct verifier cannot reconstruct — a paged
17
+ // collection at `?page=2`, a WordPress inbox at `?rest_route=…`. Re-sign just
18
+ // that line, over the header list Fedify already chose; a URL with no query
19
+ // comes back untouched, and anything unexpected is left exactly as signed.
20
+ export async function withQueryInTarget(signed, privateKey) {
21
+ const url = new URL(signed.url);
22
+ if (!url.search) return signed;
23
+ const header = signed.headers.get('signature');
24
+ if (!header) return signed;
25
+ const fields = Object.fromEntries(
26
+ [...header.matchAll(/([A-Za-z]+)="([^"]*)"/g)].map(m => [m[1], m[2]]));
27
+ const names = fields.headers?.split(/\s+/g) || [];
28
+ if (!fields.signature || !names.includes('(request-target)')) return signed;
29
+ if (names.some(n => n.startsWith('(') && n !== '(request-target)')) return signed;
30
+ const message = names.map(n => n === '(request-target)'
31
+ ? `(request-target): ${signed.method.toLowerCase()} ${url.pathname}${url.search}`
32
+ : `${n}: ${(n === 'host' ? signed.headers.get('host') || url.host : signed.headers.get(n)) ?? ''}`)
33
+ .join('\n');
34
+ const sig = await webcrypto.subtle.sign('RSASSA-PKCS1-v1_5', privateKey,
35
+ new TextEncoder().encode(message));
36
+ const headers = new Headers(signed.headers);
37
+ headers.set('signature',
38
+ header.replace(/signature="[^"]*"/, `signature="${Buffer.from(sig).toString('base64')}"`));
39
+ const init = { method: signed.method, headers, signal: signed.signal };
40
+ if (signed.method !== 'GET' && signed.method !== 'HEAD') init.body = await signed.arrayBuffer();
41
+ return new Request(signed.url, init);
42
+ }
43
+
12
44
  const MAX_ATTEMPTS = 12; // 2^12 min ≈ 68h of backoff
13
45
  const TICK_MS = 60_000;
14
46
  const MAX_QUEUE = 2000; // beyond it, overflow dead-letters
@@ -25,10 +57,14 @@ export class Deliverer {
25
57
  // passive: signing-only (viewer-mode agents) — no queue drain timer, so a
26
58
  // read-only agent never mutates shared delivery state. startQueue() flips
27
59
  // it live when a viewer is promoted to active.
28
- constructor({ store, keyId, rsaPrivate, log = console.log, passive = false }) {
60
+ constructor({ store, keyId, rsaPrivate, actorId = null, edPrivate = null, proofKeyId = null,
61
+ log = console.log, passive = false }) {
29
62
  this.store = store;
30
63
  this.keyId = keyId;
31
64
  this.rsaPrivate = rsaPrivate;
65
+ this.actorId = actorId;
66
+ this.edPrivate = edPrivate;
67
+ this.proofKeyId = proofKeyId;
32
68
  this.log = log;
33
69
  if (!passive) this.startQueue();
34
70
  }
@@ -64,7 +100,8 @@ export class Deliverer {
64
100
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
65
101
  const dispatcher = await pinnedFor(current);
66
102
  const req = new Request(current, withUa);
67
- const signed = await signRequest(req, this.rsaPrivate, new URL(this.keyId));
103
+ const signed = await withQueryInTarget(
104
+ await signRequest(req, this.rsaPrivate, new URL(this.keyId)), this.rsaPrivate);
68
105
  const res = await fetch(signed, { ...(dispatcher ? { dispatcher } : {}), redirect: 'manual' });
69
106
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
70
107
  current = new URL(res.headers.get('location'), current).href;
@@ -101,20 +138,43 @@ export class Deliverer {
101
138
  }
102
139
 
103
140
  // Deliver, queueing on failure.
141
+ // FEP-8b32: a proof on everything WE author, so a server that meets one of
142
+ // our activities second-hand can verify it without asking us.
143
+ //
144
+ // Only ours. An activity we forward on someone else's behalf keeps whatever
145
+ // proof its author gave it — signing that one as an assertion of ours would
146
+ // be claiming to have written it.
147
+ async proofed(activity) {
148
+ if (!this.edPrivate || !this.proofKeyId || !activity || typeof activity !== 'object') return activity;
149
+ if (activity.proof) return activity;
150
+ const actor = typeof activity.actor === 'string' ? activity.actor : activity.actor?.id;
151
+ if (!this.actorId || actor !== this.actorId) return activity;
152
+ try {
153
+ return await attachProof(activity,
154
+ { privateKey: this.edPrivate, verificationMethod: this.proofKeyId });
155
+ } catch (e) {
156
+ this.log(`proof: ${e.message}`); // an unproved activity still federates
157
+ return activity;
158
+ }
159
+ }
160
+
104
161
  async deliver(inbox, activity) {
162
+ // Proved before anything else, so the copy that goes on the queue is the
163
+ // copy that was signed — a retry days later must not post a bare activity.
164
+ const signed = await this.proofed(activity);
105
165
  // A host we already know is refusing: queue without asking again. This is
106
166
  // the path a FRESH activity takes, so without it a fan-out to a struggling
107
167
  // server opened one socket per follower before any of this applied.
108
168
  const host = hostOf(inbox);
109
169
  const until = this._cooling?.get(host);
110
170
  if (until && until > Date.now()) {
111
- this.log(`${host} is cooling — queueing ${activity.type} rather than asking again`);
112
- this._enqueue({ inbox, activity, attempts: 1, nextAt: until });
171
+ this.log(`${host} is cooling — queueing ${signed.type} rather than asking again`);
172
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: until });
113
173
  return;
114
174
  }
115
175
  try {
116
- await this.deliverNow(inbox, activity);
117
- this.log(`delivered ${activity.type} → ${inbox}`);
176
+ await this.deliverNow(inbox, signed);
177
+ this.log(`delivered ${signed.type} → ${inbox}`);
118
178
  } catch (e) {
119
179
  this.log(`delivery failed (${e.message}) — queued`);
120
180
  const wait = e.retryAfterMs || 60_000;
@@ -122,7 +182,7 @@ export class Deliverer {
122
182
  this._cooling ||= new Map();
123
183
  this._cooling.set(host, Date.now() + wait);
124
184
  }
125
- this._enqueue({ inbox, activity, attempts: 1, nextAt: Date.now() + wait });
185
+ this._enqueue({ inbox, activity: signed, attempts: 1, nextAt: Date.now() + wait });
126
186
  }
127
187
  }
128
188
 
@@ -140,8 +200,11 @@ export class Deliverer {
140
200
  }
141
201
 
142
202
  async deliverToAll(inboxes, activity) {
203
+ // Proved once for the whole fan-out: every recipient gets the same bytes,
204
+ // and one signature is computed rather than one per follower.
205
+ const signed = await this.proofed(activity);
143
206
  // Shared inboxes deduplicate fan-out to the same server.
144
- for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, activity);
207
+ for (const inbox of [...new Set(inboxes)]) await this.deliver(inbox, signed);
145
208
  }
146
209
 
147
210
  // Serialized, for the same reason Intake.drain is: the tick is 60s and a
@@ -33,7 +33,9 @@ function httpUrl(u) {
33
33
  function isBlocked(actor, blocklist = {}) {
34
34
  if (!actor) return true;
35
35
  if ((blocklist.actors || []).includes(actor)) return true;
36
- let host; try { host = new URL(actor).host; } catch { return false; }
36
+ // hostname, not host: the agent's own isBlocked strips the port, and a door
37
+ // that keeps it lets a blocked domain back in on a non-default port.
38
+ let host; try { host = new URL(actor).hostname; } catch { return false; }
37
39
  return (blocklist.domains || []).some(d => host === d || host.endsWith('.' + d));
38
40
  }
39
41
 
@@ -88,7 +90,7 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
88
90
  // reaches the pod (today it would, drain, and die unapplied). An absent or
89
91
  // unfetchable-key signature is NOT dropped: it forwards unverified and the
90
92
  // drain's verify-by-dereference still stands behind it.
91
- if (v.verified === false && v.reason === 'bad-signature-or-key-unfetchable') {
93
+ if (v.verified === false && v.reason === 'bad-signature') {
92
94
  return { status: 202, reason: 'forged signature' };
93
95
  }
94
96
 
package/lib/httpsig.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // — so the key deref is injected as a loader that goes through safefetch.
11
11
 
12
12
  import crypto from 'node:crypto';
13
- import { verifyRequest } from '@fedify/fedify/sig';
13
+ import { verifyRequestDetailed } from '@fedify/fedify/sig';
14
14
  import { assertPublicUrl, safeFetch, readCapped } from './safefetch.mjs';
15
15
 
16
16
  // An SSRF-safe JSON-LD document loader for Fedify's key fetch. The keyId in a
@@ -40,33 +40,48 @@ export function makeSafeLoader({ getActors = null, fetchImpl = fetch } = {}) {
40
40
  // has one natively; an agent-side endpoint builds one from its req). Returns a
41
41
  // normalized result both the gateway and the drain understand — never throws
42
42
  // on a bad signature, because "unverified" is a routine outcome, not an error.
43
+ //
44
+ // Three outcomes, kept apart because the door acts on them differently. Only a
45
+ // signature we could CHECK and found wrong is a forgery. A key we could not
46
+ // fetch proves nothing: a sender in secure mode — Threads, Mastodon with
47
+ // AUTHORIZED_FETCH — answers this keyless loader 404 or 401, so its deliveries
48
+ // have to degrade to unverified rather than be read as forged.
43
49
  export async function verifyHttpSignature(request, { documentLoader, keyCache, timeWindow } = {}) {
44
50
  const hadSig = request.headers.get('signature') != null;
45
- let key = null;
51
+ let result = null;
46
52
  try {
47
- key = await verifyRequest(request, {
53
+ result = await verifyRequestDetailed(request, {
48
54
  documentLoader,
49
55
  ...(keyCache ? { keyCache } : {}),
50
56
  timeWindow: timeWindow ?? { hours: 1 },
51
57
  });
52
58
  } catch {
53
- key = null; // a loader that refused (SSRF, 401 from a secure-mode pod) unverified
59
+ result = null; // the library itself failed no more informative than a bad signature
54
60
  }
55
- if (key) {
61
+ if (result?.verified) {
62
+ const key = result.key;
56
63
  return {
57
64
  verified: true, method: 'draft-cavage',
58
65
  keyId: key.id?.href ?? null, actor: key.ownerId?.href ?? null, reason: null,
59
66
  checks: { signature: true, digest: true, dateSkew: true, keyFetched: true },
60
67
  };
61
68
  }
69
+ const kind = result?.reason?.type;
70
+ if (!hadSig || kind === 'noSignature') {
71
+ return {
72
+ verified: false, method: 'none', keyId: null, actor: null,
73
+ reason: 'no-signature', checks: { signature: false },
74
+ };
75
+ }
76
+ if (kind === 'keyFetchError') {
77
+ return {
78
+ verified: false, method: 'draft-cavage', keyId: null, actor: null,
79
+ reason: 'key-unfetchable', checks: { signature: false, keyFetched: false },
80
+ };
81
+ }
62
82
  return {
63
- verified: false, method: hadSig ? 'draft-cavage' : 'none',
64
- keyId: null, actor: null,
65
- // The distinction the receipt needs: a forgery (present but invalid) is
66
- // dropped at the edge; an absent or unfetchable-key signature degrades to
67
- // buffered-unverified, exactly as an install with no gateway behaves.
68
- reason: hadSig ? 'bad-signature-or-key-unfetchable' : 'no-signature',
69
- checks: { signature: false },
83
+ verified: false, method: 'draft-cavage', keyId: null, actor: null,
84
+ reason: 'bad-signature', checks: { signature: false },
70
85
  };
71
86
  }
72
87
 
package/lib/intake.mjs CHANGED
@@ -113,7 +113,19 @@ function pollOf(note) {
113
113
  })),
114
114
  };
115
115
  }
116
- export const isContentType = (t) => CONTENT_TYPES.has(t);
116
+ // AS2 lets `type` be one string or a list, and implementations use both —
117
+ // `["Person","Service"]` is an ordinary actor. Read either form.
118
+ const typesOf = (t) => (Array.isArray(t) ? t : [t]).filter(x => typeof x === 'string');
119
+ export const isContentType = (t) => typesOf(t).some(x => CONTENT_TYPES.has(x));
120
+ const isActorType = (t) => typesOf(t).some(x => ACTOR_TYPES.has(x));
121
+
122
+ // What we will carry to our followers on someone else's behalf (§7.1.2): the
123
+ // activities a conversation is made of, and nothing else. A type this file
124
+ // does not handle falls out of handle() with no rejection, and "no rejection"
125
+ // is what qualifies an activity for forwarding — so without this gate a
126
+ // stranger could have anything at all, of a type nothing here reads,
127
+ // re-delivered to every follower over our signature.
128
+ const FORWARDABLE = new Set(['Create', 'Update', 'Delete', 'Like', 'Announce', 'Undo']);
117
129
  const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
118
130
 
119
131
  export function sameOrigin(a, b) {
@@ -741,7 +753,7 @@ export class Intake {
741
753
  // Same origin rather than exact equality: signedFetch follows redirects
742
754
  // without reporting where it landed, so a server that redirects its own
743
755
  // canonical actor URL would otherwise stop being cached at all.
744
- if (ACTOR_TYPES.has(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
756
+ if (isActorType(doc?.type) && doc.id && sameOrigin(doc.id, url)) {
745
757
  this.store.cacheActor(doc.id, doc);
746
758
  }
747
759
  return doc;
@@ -1123,6 +1135,7 @@ export class Intake {
1123
1135
  // never forwarded.
1124
1136
  async _maybeForward(activity) {
1125
1137
  if (!activity || typeof activity !== 'object') return;
1138
+ if (!FORWARDABLE.has(activity.type)) return; // see FORWARDABLE
1126
1139
  try {
1127
1140
  const audience = []
1128
1141
  .concat(activity.to || [], activity.cc || [], activity.audience || [])
@@ -1380,9 +1393,9 @@ export class Intake {
1380
1393
  const note = await this.fetchAP(objectId);
1381
1394
  if (!note) return `object fetch failed (${objectId})`;
1382
1395
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
1383
- const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
1396
+ const { attachmentsOf, titledContent } = await import('./wire.mjs');
1384
1397
  const attachments = attachmentsOf(note);
1385
- const content = sanitizeHtml(note.content); // hostile markup never reaches pod or client
1398
+ const content = titledContent(note); // hostile markup never reaches pod or client
1386
1399
  // The delivering actor was checked on arrival; the author is only known once
1387
1400
  // the note is dereferenced. authorOf refuses an author the note's own origin
1388
1401
  // does not vouch for — see its comment; this is where a forged attribution
@@ -1594,8 +1607,8 @@ export class Intake {
1594
1607
  const note = await this.fetchAP(objectId);
1595
1608
  if (!note) throw new Error(`cannot refetch ${objectId} — will retry`);
1596
1609
  if (note.id !== objectId || !isContentType(note.type)) return `object not verifiable content (${objectId}, ${note.type})`;
1597
- const { attachmentsOf, sanitizeHtml } = await import('./wire.mjs');
1598
- const content = sanitizeHtml(note.content);
1610
+ const { attachmentsOf, titledContent } = await import('./wire.mjs');
1611
+ const content = titledContent(note);
1599
1612
  const attachments = attachmentsOf(note);
1600
1613
  const freshPoll = pollOf(note);
1601
1614
  const freshEmojis = emojisOf(note);
package/lib/keys.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // keys.mjs — actor signing keys. RSA (Mastodon's draft-cavage HTTP Signatures
2
- // require RSA-SHA256) + Ed25519 (stored for future FEP-8b32 use, not yet in
3
- // the actor doc). PEM at rest (0600), CryptoKey in memory for Fedify's
4
- // signRequest.
2
+ // require RSA-SHA256) signs deliveries; Ed25519 signs the FEP-8b32 proof that
3
+ // travels with an activity. PEM at rest (0600), CryptoKey in memory for
4
+ // Fedify's signRequest and for the proof.
5
5
  //
6
6
  // The key lives on THIS MACHINE by default (AP_HOME/keys.json): the pod host
7
7
  // then never holds it. `setup --keys pod` puts it in pod state instead,
@@ -112,9 +112,23 @@ export async function resolveKeys(store, { localDir = null, rotate = false, acto
112
112
  log(rotate ? 'minted a REPLACEMENT signing key — republish the profile' : 'minted a signing key');
113
113
  }
114
114
 
115
+ // A record written before proofs existed has no Ed25519 half. Minting one is
116
+ // safe in a way minting an RSA key is not: nothing has ever published or
117
+ // cached it, so no remote signature is invalidated.
118
+ if (!rec.ed25519) {
119
+ rec = { ...rec, ed25519: generate().ed25519 };
120
+ if (localDir) writeLocal(localDir, rec); else store.write('keys.json', rec);
121
+ log('minted the Ed25519 half of the signing key');
122
+ }
123
+
115
124
  const der = crypto.createPrivateKey(rec.rsa.privatePem).export({ type: 'pkcs8', format: 'der' });
116
125
  const rsaPrivate = await crypto.subtle.importKey('pkcs8', der, RSA_ALG, true, ['sign']);
117
- return { rsaPrivate, rsaPublicPem: rec.rsa.publicPem };
126
+ const { edPrivateKey, multibaseEd25519 } = await import('./proof.mjs');
127
+ return {
128
+ rsaPrivate, rsaPublicPem: rec.rsa.publicPem,
129
+ edPrivate: await edPrivateKey(rec.ed25519.privatePem),
130
+ edPublicMultibase: multibaseEd25519(rec.ed25519.publicPem),
131
+ };
118
132
  }
119
133
 
120
134
  // Move a local key into pod state — the arrangement that lets several
package/lib/lease.mjs CHANGED
@@ -29,6 +29,7 @@ export class Lease {
29
29
  this.stopped = false;
30
30
  this.timer = null;
31
31
  this.heldUntil = 0; // wall-clock end of the lease we last wrote
32
+ this.denied = null; // why the last acquire() said no: 'unreadable' | 'held'
32
33
  }
33
34
 
34
35
  // null means the lease document is NOT THERE — nobody holds it. UNREADABLE
@@ -74,16 +75,21 @@ export class Lease {
74
75
  const cur = await this.readFresh();
75
76
  // Cannot read it: stay a viewer. A viewer that should have been active is
76
77
  // an inconvenience; two agents both draining is the destructive one.
77
- if (cur === UNREADABLE) { this.log('lease unreadable — staying a viewer'); return false; }
78
- if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) return false;
78
+ if (cur === UNREADABLE) {
79
+ this.denied = 'unreadable';
80
+ this.log('lease unreadable — staying a viewer'); return false;
81
+ }
82
+ if (cur && cur.holder !== this.id && Date.now() < cur.expiresAt) { this.denied = 'held'; return false; }
79
83
  const doc = { holder: this.id, expiresAt: Date.now() + TTL_MS };
80
- if (!await this.write(doc)) return false;
84
+ if (!await this.write(doc)) { this.denied = 'held'; return false; }
81
85
  const confirm = await this.readFresh();
82
- if (confirm === UNREADABLE || confirm?.holder !== this.id) return false;
86
+ if (confirm === UNREADABLE) { this.denied = 'unreadable'; return false; }
87
+ if (confirm?.holder !== this.id) { this.denied = 'held'; return false; }
83
88
  // From the document we WROTE, and only once the confirm agrees. Recomputing
84
89
  // it here would claim a lease that outlives what the pod's copy grants, by
85
90
  // however long the PUT and the confirming GET took.
86
91
  this.heldUntil = doc.expiresAt;
92
+ this.denied = null;
87
93
  return true;
88
94
  }
89
95
 
package/lib/mastoapi.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  import crypto from 'node:crypto';
14
14
  import * as social from './social.mjs';
15
- import { sanitizeHtml } from './wire.mjs';
15
+ import { sanitizeHtml, followsNeedApproval } from './wire.mjs';
16
16
  import { authorOf } from './intake.mjs';
17
17
  import { profileUrl, postUrl } from './bskyfeed.mjs';
18
18
  import { Push } from './webpush.mjs';
@@ -222,7 +222,7 @@ export class MastoApi {
222
222
  // people — so read it from config, or the editor opens empty and saving
223
223
  // wipes what was there.
224
224
  display_name: (self ? this.store.getConfig()?.name : cached.name) || cached.name || user,
225
- locked: self ? !!this.store.getConfig()?.approveJoins : false,
225
+ locked: self ? followsNeedApproval(this.store.getConfig() || {}) : false,
226
226
  // Read from config for self, like the fields above it: our own actor is
227
227
  // not in the actor cache — the cache is for other people — so a group
228
228
  // asking about itself would be told it was a person.