fedipod-server 0.12.1 → 0.13.1
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/README.md +7 -0
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +7 -0
- package/dist/handler.js +33 -1
- package/dist/handler.jsonld +4 -0
- package/lib/client/c2s.mjs +6 -4
- package/lib/core/as2.mjs +69 -18
- package/lib/core/graphview.mjs +289 -0
- package/lib/core/intake/index.mjs +4 -4
- package/lib/core/intake/verify.mjs +4 -3
- package/lib/core/publisher/restore.mjs +7 -4
- package/lib/gateway/front-core.mjs +14 -6
- package/lib/server/embed.mjs +59 -1
- package/package.json +1 -1
- package/web/app/agent.mjs +7 -6
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +18 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +261 -44
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/signup.mjs +2 -3
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +341 -58
|
@@ -44,7 +44,12 @@ async function verifyPodToken(request, pathname, verifier) {
|
|
|
44
44
|
const url = request.url;
|
|
45
45
|
const { webid } = await v(authz, dpop ? { header: dpop, method: request.method, url } : undefined);
|
|
46
46
|
return webid || null;
|
|
47
|
-
} catch {
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// Said aloud: a token the front will not take is otherwise a bare 401 to
|
|
49
|
+
// the caller and nothing at all here.
|
|
50
|
+
console.log(`front: pod token refused on ${pathname}: ${e?.message || e}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
// Is this WebID served by the claimed pod? A pod owner's WebID lives on the pod
|
|
@@ -149,7 +154,7 @@ async function relayOne(item, rec, fetchImpl) {
|
|
|
149
154
|
}
|
|
150
155
|
try {
|
|
151
156
|
const res = await safeFetch(url, { method, headers, body, signal: AbortSignal.timeout(RELAY_TIMEOUT_MS) }, fetchImpl);
|
|
152
|
-
const out = { url, status: res.status };
|
|
157
|
+
const out = { url, method, status: res.status };
|
|
153
158
|
// The far server asking to be left alone has to reach the agent that will
|
|
154
159
|
// do the asking again. Without this the browser build could not honour a
|
|
155
160
|
// Retry-After at all — every delivery it makes goes through here — and fell
|
|
@@ -161,7 +166,7 @@ async function relayOne(item, rec, fetchImpl) {
|
|
|
161
166
|
out.body = await readCapped(res, RELAY_MAX_BODY);
|
|
162
167
|
}
|
|
163
168
|
return out;
|
|
164
|
-
} catch (e) { return { url, status: 0, error: e.message }; }
|
|
169
|
+
} catch (e) { return { url, method, status: 0, error: e.message }; }
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
const j = (status, obj, ct = 'application/json') =>
|
|
@@ -470,15 +475,18 @@ async function route(request, ctx) {
|
|
|
470
475
|
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
471
476
|
const handle = String(body.handle || '').toLowerCase();
|
|
472
477
|
const rec = await ctx.lookup(handle);
|
|
473
|
-
if (!rec) return j(404, { error: 'no such account' });
|
|
478
|
+
if (!rec) { console.log(`relay: no account named ${handle}`); return j(404, { error: 'no such account' }); }
|
|
474
479
|
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
475
|
-
if (!webid) return j(401, { error: 'a Solid-OIDC token proving the pod is required' });
|
|
480
|
+
if (!webid) { console.log(`relay @${handle}: no usable pod token`); return j(401, { error: 'a Solid-OIDC token proving the pod is required' }); }
|
|
476
481
|
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
477
|
-
if (!owner) return j(403, { error: "the token proves a different pod than this account's" });
|
|
482
|
+
if (!owner) { console.log(`relay @${handle}: token is for ${webid}, not this account's pod`); return j(403, { error: "the token proves a different pod than this account's" }); }
|
|
478
483
|
const items = Array.isArray(body.requests) ? body.requests : [];
|
|
479
484
|
if (!items.length) return j(400, { error: 'requests must be a non-empty list' });
|
|
480
485
|
if (items.length > RELAY_MAX_REQUESTS) return j(400, { error: `at most ${RELAY_MAX_REQUESTS} requests per call` });
|
|
481
486
|
const results = await Promise.all(items.map((it) => relayOne(it, rec, ctx.fetchImpl || fetch)));
|
|
487
|
+
// One line per relayed request, so a lookup that fails on the far side is
|
|
488
|
+
// visible here and not only as an empty result in someone's browser.
|
|
489
|
+
for (const r of results) console.log(`relay @${handle}: ${r.method || ''} ${r.url} → ${r.status}${r.error ? ` (${r.error})` : ''}`);
|
|
482
490
|
return j(200, { results });
|
|
483
491
|
}
|
|
484
492
|
|
package/lib/server/embed.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
|
|
|
19
19
|
import { Agent } from '../../run-agent.mjs';
|
|
20
20
|
import { RemotePod } from '../device/remote.mjs';
|
|
21
21
|
import { apUrls, DEFAULT_ROOT } from '../core/wire.mjs';
|
|
22
|
+
import { handleDelivery } from '../gateway/gateway-core.mjs';
|
|
22
23
|
import { writeJsonAtomic } from '../device/home.mjs';
|
|
23
24
|
import { buildAdminSurface } from '../device/admin/index.mjs';
|
|
24
25
|
import { FixedAuthorities } from '../shared/guard.mjs';
|
|
@@ -201,6 +202,62 @@ async function connectionsIntoPod(agent, home, log) {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The pod's own inbox is a verifying door here.
|
|
207
|
+
*
|
|
208
|
+
* The server that stores the inbox is the server the delivery arrives at, so
|
|
209
|
+
* the signature is checked while the headers still exist and the receipt is
|
|
210
|
+
* written beside the activity — the same door code a standalone gateway runs,
|
|
211
|
+
* with nothing renamed and nothing advertised differently. The identity's
|
|
212
|
+
* config names its own inbox as the door and carries the receipt secret, which
|
|
213
|
+
* is what makes the drain read receipts at all; `trust` because a verified
|
|
214
|
+
* sender is one the identity may act for.
|
|
215
|
+
*
|
|
216
|
+
* An identity attached to an outside door keeps that door: only the secret
|
|
217
|
+
* is ensured, so receipts from either door verify against the one value.
|
|
218
|
+
*/
|
|
219
|
+
export async function ensureInboxDoor(agent, urls, log = () => {}) {
|
|
220
|
+
const cfg = agent.store.getConfig();
|
|
221
|
+
if (!cfg) return;
|
|
222
|
+
const g = { ...(cfg.gateway || {}) };
|
|
223
|
+
const fresh = !g.url || !g.mode || g.mode === 'off';
|
|
224
|
+
if (fresh) Object.assign(g, { url: urls.inbox, mode: 'trust' });
|
|
225
|
+
if (!g.hmacSecret) g.hmacSecret = crypto.randomBytes(32).toString('base64');
|
|
226
|
+
if (fresh || !cfg.gateway?.hmacSecret) {
|
|
227
|
+
agent.store.setConfig({ ...cfg, gateway: g });
|
|
228
|
+
await agent.store.flush();
|
|
229
|
+
log(fresh ? 'the pod inbox verifies deliveries at the door' : 'receipt secret added for the inbox door');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* One delivery to a running identity's inbox, verified at the door.
|
|
235
|
+
*
|
|
236
|
+
* `request` is the WHATWG form of the POST. `podPut` writes through the
|
|
237
|
+
* server's store. Returns { status, reason } for the caller to answer with.
|
|
238
|
+
* The policy is read from the identity's live state rather than its
|
|
239
|
+
* published policy document, because both are in this process.
|
|
240
|
+
*/
|
|
241
|
+
export async function deliverToInbox(agent, request, { podPut, gatewayWebId = null, fetchImpl = fetch } = {}) {
|
|
242
|
+
const cfg = agent.store.getConfig() || {};
|
|
243
|
+
const contacts = agent.store.getContacts();
|
|
244
|
+
const bl = agent.store.getBlocklist();
|
|
245
|
+
const u = agent.urls;
|
|
246
|
+
const toPod = (x) => (u.toPod ? u.toPod(x) : x);
|
|
247
|
+
const ident = {
|
|
248
|
+
inboxUrl: toPod(u.inbox),
|
|
249
|
+
actorUrl: u.actor,
|
|
250
|
+
followersUrl: u.followers,
|
|
251
|
+
notesPrefix: u.notes,
|
|
252
|
+
following: contacts.following.filter((f) => f.accepted && !f.bsky).map((f) => f.actor),
|
|
253
|
+
blocklist: { domains: bl.domains || [], actors: bl.actors || [] },
|
|
254
|
+
kind: cfg.kind || 'person',
|
|
255
|
+
gatewayWebId,
|
|
256
|
+
hmacSecret: cfg.gateway?.hmacSecret || null,
|
|
257
|
+
};
|
|
258
|
+
return handleDelivery(request, ident, { podPut, fetchImpl });
|
|
259
|
+
}
|
|
260
|
+
|
|
204
261
|
/**
|
|
205
262
|
* Bring one identity up inside the server.
|
|
206
263
|
*
|
|
@@ -277,6 +334,7 @@ export async function startEmbeddedAgent({
|
|
|
277
334
|
// Before connect(), which is what looks the key and the connections up.
|
|
278
335
|
await keyIntoPod(agent, home, log);
|
|
279
336
|
await connectionsIntoPod(agent, home, log);
|
|
337
|
+
await ensureInboxDoor(agent, urls, log);
|
|
280
338
|
|
|
281
339
|
await agent.connect();
|
|
282
340
|
|
|
@@ -342,6 +400,6 @@ export async function startEmbeddedAgent({
|
|
|
342
400
|
// returned rather than rebuilt by the caller so the root name lives here.
|
|
343
401
|
return {
|
|
344
402
|
agent, handle, home, surface, host: authorities.host,
|
|
345
|
-
podHome: urls.home, actorUrl: urls.actor, stop,
|
|
403
|
+
podHome: urls.home, actorUrl: urls.actor, inboxUrl: urls.inbox, stop,
|
|
346
404
|
};
|
|
347
405
|
}
|
package/package.json
CHANGED
package/web/app/agent.mjs
CHANGED
|
@@ -17,10 +17,9 @@ import { MastoApi } from '../../lib/client/masto/index.mjs';
|
|
|
17
17
|
import { TagFeed } from '../../lib/connections/tagfeed.mjs';
|
|
18
18
|
import { makeDpopSession } from './pod-auth.mjs';
|
|
19
19
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
20
|
-
import { importSigningKey, loadKeysFromPod,
|
|
20
|
+
import { importSigningKey, loadKeysFromPod, cacheOpenedKeys } from './keys-browser.mjs';
|
|
21
21
|
import { generateKeys, wrapKeys } from './keystore.mjs';
|
|
22
|
-
import {
|
|
23
|
-
import { RelayDeliverer } from './deliver-relay.mjs';
|
|
22
|
+
import { RelayDeliverer, doorKeyOf } from './deliver-relay.mjs';
|
|
24
23
|
import { AdminFacade } from './admin-facade.mjs';
|
|
25
24
|
import { BrowserAtproto } from './atproto-browser.mjs';
|
|
26
25
|
import { BskyFeed } from '../../lib/connections/bskyfeed.mjs';
|
|
@@ -204,7 +203,10 @@ export class BrowserAgent {
|
|
|
204
203
|
passive: true,
|
|
205
204
|
store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
|
|
206
205
|
actorId: this.urls.actor, log: this.log,
|
|
207
|
-
relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`,
|
|
206
|
+
relayUrl: `${frontOrigin.replace(/\/$/, '')}/api/relay`,
|
|
207
|
+
// The relay finds the account by the front's own key for it, which for a
|
|
208
|
+
// mail-door account is the full address, not the bare handle.
|
|
209
|
+
handle: doorKeyOf(config.gateway?.url) || config.handle, sessionFetch: session.fetch,
|
|
208
210
|
});
|
|
209
211
|
|
|
210
212
|
this.publisher = new Publisher({
|
|
@@ -364,8 +366,7 @@ export class BrowserAgent {
|
|
|
364
366
|
const rec = await generateKeys();
|
|
365
367
|
rec.mintedFor = this.urls.actor; // one key, one actor (lib/keys.mjs)
|
|
366
368
|
await podState.writeWrappedKeys(this.remote, this.urls, await wrapKeys(rec, password));
|
|
367
|
-
await
|
|
368
|
-
const keys = await importSigningKey(rec);
|
|
369
|
+
const keys = await cacheOpenedKeys(this.urls.actor, rec);
|
|
369
370
|
this.publisher.publicKeyPem = keys.rsaPublicPem;
|
|
370
371
|
this.deliverer.rsaPrivate = keys.rsaPrivate;
|
|
371
372
|
await this.publisher.publishProfile();
|
package/web/app/boot.mjs
CHANGED
|
@@ -15,8 +15,7 @@ import * as podState from '../../lib/pod/state.mjs';
|
|
|
15
15
|
import { BrowserRemotePod } from './pod-remote.mjs';
|
|
16
16
|
import { beginLogin, completeLogin, getSession, signOut } from './oidc-session.mjs';
|
|
17
17
|
import { unwrapKeys, isKeyEnvelope } from './keystore.mjs';
|
|
18
|
-
import {
|
|
19
|
-
import { keyCacheKey } from './keys-browser.mjs';
|
|
18
|
+
import { cacheOpenedKeys } from './keys-browser.mjs';
|
|
20
19
|
|
|
21
20
|
const REDIRECT = `${location.origin}/`; // the app root doubles as the OIDC callback
|
|
22
21
|
|
|
@@ -83,7 +82,7 @@ window.fedipodUnlock = async (password) => {
|
|
|
83
82
|
if (!isKeyEnvelope(doc)) throw new Error('this account\'s key is not locked — nothing to unlock');
|
|
84
83
|
const rec = await unwrapKeys(doc, password); // throws 'wrong password'
|
|
85
84
|
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
86
|
-
await
|
|
85
|
+
await cacheOpenedKeys(actorUrl, rec);
|
|
87
86
|
await bootWorker();
|
|
88
87
|
};
|
|
89
88
|
|
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
import { Deliverer } from '../../lib/core/deliver.mjs';
|
|
11
11
|
import { sign } from './shims/fedify-sig.mjs';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The name the front keys this account's row by, read off its door inbox:
|
|
15
|
+
* `<front>/u/<key>/ap/inbox/`. A mail-door account is keyed by its full
|
|
16
|
+
* address (`you@your.pod`), not the bare handle, because "you" alone is not
|
|
17
|
+
* unique across pods — and the relay looks the account up by that key.
|
|
18
|
+
* Null when the URL is not a door of that shape.
|
|
19
|
+
*/
|
|
20
|
+
export function doorKeyOf(doorInboxUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const seg = new URL(doorInboxUrl).pathname.split('/');
|
|
23
|
+
return seg[1] === 'u' && seg[2] ? decodeURIComponent(seg[2]) : null;
|
|
24
|
+
} catch { return null; }
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
export class RelayDeliverer extends Deliverer {
|
|
14
28
|
constructor(opts) {
|
|
15
29
|
super(opts);
|
|
@@ -28,10 +42,13 @@ export class RelayDeliverer extends Deliverer {
|
|
|
28
42
|
async signedFetch(url, init = {}) {
|
|
29
43
|
const body = typeof init.body === 'string' ? init.body : (init.body ? new TextDecoder().decode(init.body) : '');
|
|
30
44
|
const s = await sign({ url, method: init.method || 'GET', headers: init.headers || {}, body }, this.rsaPrivate, this.keyId);
|
|
45
|
+
// Every signed header goes to the relay, `accept` included: the signature
|
|
46
|
+
// covers it, so a relay request missing it carries an invalid signature —
|
|
47
|
+
// and a read without it gets the HTML page instead of the document.
|
|
31
48
|
const relayReq = {
|
|
32
49
|
url: s.url, method: s.method, body,
|
|
33
50
|
headers: {
|
|
34
|
-
date: s.headers.date, digest: s.headers.digest,
|
|
51
|
+
date: s.headers.date, digest: s.headers.digest, accept: s.headers.accept,
|
|
35
52
|
'content-type': s.headers['content-type'], signature: s.headers.signature,
|
|
36
53
|
},
|
|
37
54
|
};
|
package/web/app/dist/boot.js
CHANGED
|
@@ -33231,6 +33231,26 @@ async function kvPut(key, val) {
|
|
|
33231
33231
|
}
|
|
33232
33232
|
|
|
33233
33233
|
// web/app/keys-browser.mjs
|
|
33234
|
+
var pemToDer = (pem) => Uint8Array.from(
|
|
33235
|
+
atob(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")),
|
|
33236
|
+
(c) => c.charCodeAt(0)
|
|
33237
|
+
);
|
|
33238
|
+
async function importSigningKey(keysRecord) {
|
|
33239
|
+
const rsaPrivate = await crypto.subtle.importKey(
|
|
33240
|
+
"pkcs8",
|
|
33241
|
+
pemToDer(keysRecord.rsa.privatePem),
|
|
33242
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
33243
|
+
false,
|
|
33244
|
+
["sign"]
|
|
33245
|
+
);
|
|
33246
|
+
return { rsaPrivate, rsaPublicPem: keysRecord.rsa.publicPem, edPrivate: null, edPublicMultibase: null };
|
|
33247
|
+
}
|
|
33248
|
+
async function cacheOpenedKeys(actorUrl, keysRecord) {
|
|
33249
|
+
const keys = await importSigningKey(keysRecord);
|
|
33250
|
+
await kvPut(keyCacheKey(actorUrl), { rsaPrivate: keys.rsaPrivate, rsaPublicPem: keys.rsaPublicPem }).catch(() => {
|
|
33251
|
+
});
|
|
33252
|
+
return keys;
|
|
33253
|
+
}
|
|
33234
33254
|
var keyCacheKey = (actorUrl) => `signing-keys:${actorUrl}`;
|
|
33235
33255
|
|
|
33236
33256
|
// web/app/signup.mjs
|
|
@@ -33321,8 +33341,7 @@ async function signUp(answers, { onStep = () => {
|
|
|
33321
33341
|
} catch (e) {
|
|
33322
33342
|
throw new Error(`could not store the signing key on the pod (${e.message}). The credential is for ${credential.webId} \u2014 that WebID must own ${pod} and its ${AP_ROOT} must be writable by it.`);
|
|
33323
33343
|
}
|
|
33324
|
-
await
|
|
33325
|
-
});
|
|
33344
|
+
await cacheOpenedKeys(actorUrl, keys);
|
|
33326
33345
|
prog.keys = keys;
|
|
33327
33346
|
prog.keysStored = true;
|
|
33328
33347
|
keysStep.ok();
|
|
@@ -33668,7 +33687,7 @@ window.fedipodUnlock = async (password) => {
|
|
|
33668
33687
|
if (!isKeyEnvelope(doc)) throw new Error("this account's key is not locked \u2014 nothing to unlock");
|
|
33669
33688
|
const rec = await unwrapKeys(doc, password);
|
|
33670
33689
|
const actorUrl = `${cfg.remotePod}${cfg.root || AP_ROOT}ap/actor`;
|
|
33671
|
-
await
|
|
33690
|
+
await cacheOpenedKeys(actorUrl, rec);
|
|
33672
33691
|
await bootWorker();
|
|
33673
33692
|
};
|
|
33674
33693
|
window.fedipodSignup = async ({ onStep, ...answers }) => {
|