fedipod 0.8.0 → 0.9.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/lib/deliver.mjs +71 -8
- package/lib/gateway-core.mjs +4 -2
- package/lib/httpsig.mjs +27 -12
- package/lib/intake.mjs +19 -6
- package/lib/keys.mjs +18 -4
- package/lib/mastoapi.mjs +2 -2
- package/lib/proof.mjs +79 -0
- package/lib/publisher.mjs +6 -3
- package/lib/tagfeed.mjs +2 -2
- package/lib/update.mjs +7 -2
- package/lib/wire.mjs +41 -2
- package/package.json +2 -1
- package/run-agent.mjs +7 -2
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,
|
|
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
|
|
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 ${
|
|
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,
|
|
117
|
-
this.log(`delivered ${
|
|
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,
|
|
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
|
package/lib/gateway-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
51
|
+
let result = null;
|
|
46
52
|
try {
|
|
47
|
-
|
|
53
|
+
result = await verifyRequestDetailed(request, {
|
|
48
54
|
documentLoader,
|
|
49
55
|
...(keyCache ? { keyCache } : {}),
|
|
50
56
|
timeWindow: timeWindow ?? { hours: 1 },
|
|
51
57
|
});
|
|
52
58
|
} catch {
|
|
53
|
-
|
|
59
|
+
result = null; // the library itself failed — no more informative than a bad signature
|
|
54
60
|
}
|
|
55
|
-
if (
|
|
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:
|
|
64
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
1396
|
+
const { attachmentsOf, titledContent } = await import('./wire.mjs');
|
|
1384
1397
|
const attachments = attachmentsOf(note);
|
|
1385
|
-
const content =
|
|
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,
|
|
1598
|
-
const 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)
|
|
3
|
-
//
|
|
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
|
-
|
|
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/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 ?
|
|
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.
|
package/lib/proof.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// proof.mjs — FEP-8b32 object integrity proofs (Data Integrity, eddsa-jcs-2022).
|
|
2
|
+
//
|
|
3
|
+
// An HTTP signature covers one delivery and says nothing about who wrote what
|
|
4
|
+
// was inside it. A proof travels with the activity, so a server that receives
|
|
5
|
+
// one of ours second-hand — carried by a group, or forwarded by a follower's
|
|
6
|
+
// server — can tell it is ours without asking us.
|
|
7
|
+
//
|
|
8
|
+
// The RSA `#main-key` and its HTTP signatures are untouched; this is a second
|
|
9
|
+
// key alongside, which is what the Data Integrity suites require.
|
|
10
|
+
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import serialize from 'json-canon';
|
|
13
|
+
|
|
14
|
+
// multicodec: an Ed25519 public key is its 32 raw bytes behind 0xed 0x01.
|
|
15
|
+
const ED25519_PREFIX = Buffer.from([0xed, 0x01]);
|
|
16
|
+
const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
17
|
+
|
|
18
|
+
export function base58btc(bytes) {
|
|
19
|
+
const b = Buffer.from(bytes);
|
|
20
|
+
let n = 0n;
|
|
21
|
+
for (const byte of b) n = (n << 8n) + BigInt(byte);
|
|
22
|
+
let out = '';
|
|
23
|
+
while (n > 0n) { out = B58[Number(n % 58n)] + out; n /= 58n; }
|
|
24
|
+
for (const byte of b) { if (byte !== 0) break; out = B58[0] + out; }
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The `publicKeyMultibase` form of an Ed25519 public key in PEM. */
|
|
29
|
+
export function multibaseEd25519(publicPem) {
|
|
30
|
+
const raw = Buffer.from(crypto.createPublicKey(publicPem).export({ format: 'jwk' }).x, 'base64url');
|
|
31
|
+
return 'z' + base58btc(Buffer.concat([ED25519_PREFIX, raw]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The signing key, as WebCrypto wants it. */
|
|
35
|
+
export function edPrivateKey(privatePem) {
|
|
36
|
+
const jwk = crypto.createPrivateKey(privatePem).export({ format: 'jwk' });
|
|
37
|
+
return crypto.subtle.importKey('jwk', { ...jwk, key_ops: ['sign'] }, { name: 'Ed25519' }, true, ['sign']);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sha256 = (s) => crypto.createHash('sha256').update(s).digest();
|
|
41
|
+
|
|
42
|
+
// The proof's own terms have to be declared in the document that carries it,
|
|
43
|
+
// or a receiver that processes JSON-LD expands the proof away before it ever
|
|
44
|
+
// looks at it. Declared BEFORE signing, because the proof covers the document
|
|
45
|
+
// exactly as sent, `@context` included.
|
|
46
|
+
export const DI_CTX = 'https://w3id.org/security/data-integrity/v1';
|
|
47
|
+
|
|
48
|
+
function withProofContext(doc) {
|
|
49
|
+
const ctx = doc['@context'];
|
|
50
|
+
const list = Array.isArray(ctx) ? ctx : ctx ? [ctx] : [];
|
|
51
|
+
if (list.includes(DI_CTX)) return doc;
|
|
52
|
+
return { ...doc, '@context': [...list, DI_CTX] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return the activity with a `proof` attached. The proof covers the activity
|
|
57
|
+
* exactly as it goes on the wire, minus the proof itself — so it must be the
|
|
58
|
+
* last thing added, and nothing may edit the activity afterwards.
|
|
59
|
+
*
|
|
60
|
+
* Whole seconds on `created`: a verifier re-serializes the timestamp it was
|
|
61
|
+
* given, and a fractional one has more than one spelling.
|
|
62
|
+
*/
|
|
63
|
+
export async function attachProof(activity, { privateKey, verificationMethod, created = new Date() } = {}) {
|
|
64
|
+
if (!privateKey || !verificationMethod || !activity || typeof activity !== 'object') return activity;
|
|
65
|
+
const { proof: _existing, ...bare } = activity;
|
|
66
|
+
const doc = withProofContext(bare);
|
|
67
|
+
const config = {
|
|
68
|
+
'@context': doc['@context'],
|
|
69
|
+
type: 'DataIntegrityProof',
|
|
70
|
+
cryptosuite: 'eddsa-jcs-2022',
|
|
71
|
+
verificationMethod,
|
|
72
|
+
proofPurpose: 'assertionMethod',
|
|
73
|
+
created: new Date(created).toISOString().replace(/\.\d+Z$/, 'Z'),
|
|
74
|
+
};
|
|
75
|
+
const digest = Buffer.concat([sha256(serialize(config)), sha256(serialize(doc))]);
|
|
76
|
+
const sig = await crypto.subtle.sign('Ed25519', privateKey, digest);
|
|
77
|
+
const { '@context': _ctx, ...emitted } = config;
|
|
78
|
+
return { ...doc, proof: { ...emitted, proofValue: 'z' + base58btc(Buffer.from(sig)) } };
|
|
79
|
+
}
|
package/lib/publisher.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
22
22
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
|
|
23
23
|
|
|
24
24
|
export class Publisher {
|
|
25
|
-
constructor({ config, remote, local, store, deliverer, publicKeyPem, log = console.log,
|
|
25
|
+
constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
26
26
|
probeFetch = null, resolveMention = null, privateOnPod = true,
|
|
27
27
|
}) {
|
|
28
28
|
this.config = config;
|
|
@@ -31,6 +31,7 @@ export class Publisher {
|
|
|
31
31
|
this.store = store;
|
|
32
32
|
this.deliverer = deliverer;
|
|
33
33
|
this.publicKeyPem = publicKeyPem;
|
|
34
|
+
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
34
35
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
35
36
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
36
37
|
const publicBase = config.gateway?.frontActor
|
|
@@ -76,7 +77,8 @@ export class Publisher {
|
|
|
76
77
|
const actorDoc = wire.actorDoc({
|
|
77
78
|
urls, handle: this.config.handle, name: this.config.name, publicKeyPem: this.publicKeyPem,
|
|
78
79
|
movedTo: this.config.movedTo || null, kind: this.config.kind,
|
|
79
|
-
approveJoins:
|
|
80
|
+
approveJoins: wire.followsNeedApproval(this.config),
|
|
81
|
+
assertionKey: this.assertionKey,
|
|
80
82
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
81
83
|
image: this.config.image || null, fields: this.config.fields || [],
|
|
82
84
|
webId: this.remote.webId || null,
|
|
@@ -350,7 +352,8 @@ export class Publisher {
|
|
|
350
352
|
await this.remote.putJson(urls.actor, wire.actorDoc({
|
|
351
353
|
urls, handle: this.config.handle, name: this.config.name,
|
|
352
354
|
publicKeyPem: this.publicKeyPem, movedTo: target, kind: this.config.kind,
|
|
353
|
-
approveJoins:
|
|
355
|
+
approveJoins: wire.followsNeedApproval(this.config),
|
|
356
|
+
assertionKey: this.assertionKey,
|
|
354
357
|
summary: this.config.summary || null, icon: this.config.icon || null,
|
|
355
358
|
image: this.config.image || null, fields: this.config.fields || [],
|
|
356
359
|
webId: this.remote.webId || null,
|
package/lib/tagfeed.mjs
CHANGED
|
@@ -135,10 +135,10 @@ export class TagFeed {
|
|
|
135
135
|
if (!this.store.getActors()[author]) {
|
|
136
136
|
await this.intake.fetchAP(author).catch(() => {}); // warm name+avatar
|
|
137
137
|
}
|
|
138
|
-
const { attachmentsOf,
|
|
138
|
+
const { attachmentsOf, titledContent } = await import('./wire.mjs');
|
|
139
139
|
const attachments = attachmentsOf(note);
|
|
140
140
|
this.store.addStatus({
|
|
141
|
-
noteId, actor: author, content:
|
|
141
|
+
noteId, actor: author, content: titledContent(note),
|
|
142
142
|
published: note.published, inReplyTo: note.inReplyTo, kind: 'tag', tag,
|
|
143
143
|
...(attachments.length ? { attachments } : {}),
|
|
144
144
|
});
|
package/lib/update.mjs
CHANGED
|
@@ -42,16 +42,21 @@ export async function checkLatest({ current = localVersion(), fetchImpl = fetch
|
|
|
42
42
|
} catch { return null; }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// The npm half, injectable — a test asking what runUpdate DOES must never be
|
|
46
|
+
// able to install software on the machine running it.
|
|
47
|
+
const npmInstallLatest = () =>
|
|
48
|
+
execFileSync('npm', ['install', '-g', 'fedipod@latest'], { stdio: 'pipe' });
|
|
49
|
+
|
|
45
50
|
// Fast-forward only: a checkout with local changes is somebody's work, and an
|
|
46
51
|
// update must refuse rather than eat it.
|
|
47
|
-
export function runUpdate({ root = repoRoot(), log = () => {} } = {}) {
|
|
52
|
+
export function runUpdate({ root = repoRoot(), log = () => {}, install = npmInstallLatest } = {}) {
|
|
48
53
|
// The ordinary install comes from npm, where updating is npm's job and the
|
|
49
54
|
// files under us are not ours to move. A checkout is somebody working on it,
|
|
50
55
|
// and gets the fast-forward below.
|
|
51
56
|
if (!fs.existsSync(path.join(root, '.git'))) {
|
|
52
57
|
try {
|
|
53
58
|
log('updating from npm…');
|
|
54
|
-
|
|
59
|
+
install();
|
|
55
60
|
return { ok: true, note: 'updated from npm — restart the agent to serve it' };
|
|
56
61
|
} catch (e) {
|
|
57
62
|
return { ok: false,
|
package/lib/wire.mjs
CHANGED
|
@@ -87,7 +87,19 @@ export function jrd({ handle, host, actor }) {
|
|
|
87
87
|
|
|
88
88
|
// `kind: 'group'` publishes a Group rather than a Person, which is what makes
|
|
89
89
|
// Mastodon and Lemmy treat the actor as a community you join.
|
|
90
|
-
|
|
90
|
+
// Whether a follow will be held for the operator instead of accepted on
|
|
91
|
+
// arrival. A person's follows wait unless `autoAcceptFollows` says otherwise,
|
|
92
|
+
// because a delivery on its own proves nothing about who sent it; a group is
|
|
93
|
+
// open unless its operator asked to see joins. The actor document has to say
|
|
94
|
+
// the same thing, or a remote server shows "Following" for a follow that is
|
|
95
|
+
// in fact still waiting, and nothing ever tells it otherwise.
|
|
96
|
+
export function followsNeedApproval(config = {}) {
|
|
97
|
+
return !!config.approveJoins || (config.kind !== 'group' && !config.autoAcceptFollows);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
101
|
+
|
|
102
|
+
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
91
103
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
92
104
|
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
93
105
|
blocked = null, inbox = null }) {
|
|
@@ -103,6 +115,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kin
|
|
|
103
115
|
// inline exactly as Mastodon declares it.
|
|
104
116
|
context.push({ toot: 'http://joinmastodon.org/ns#', featured: { '@id': 'toot:featured', '@type': '@id' } });
|
|
105
117
|
if (approveJoins) context.push({ manuallyApprovesFollowers: 'as:manuallyApprovesFollowers' });
|
|
118
|
+
// FEP-8b32. The Multikey is a SECOND key beside publicKey/#main-key, not a
|
|
119
|
+
// replacement: HTTP signatures stay RSA, and only the proof is Ed25519.
|
|
120
|
+
// `assertionMethod` comes from the DID context and the key's own terms from
|
|
121
|
+
// the Multikey one; the proof's terms travel with the proof, not here.
|
|
122
|
+
if (assertionKey) context.push('https://www.w3.org/ns/did/v1',
|
|
123
|
+
'https://w3id.org/security/multikey/v1');
|
|
106
124
|
// FEP-4ccd and FEP-c648 terms, declared exactly as those FEPs declare them,
|
|
107
125
|
// and only when the collections are actually published.
|
|
108
126
|
if (pendingFollowers || pendingFollowing) {
|
|
@@ -130,6 +148,10 @@ export function actorDoc({ urls, handle, name, publicKeyPem, movedTo = null, kin
|
|
|
130
148
|
id: urls.actor,
|
|
131
149
|
type: kind === 'group' ? 'Group' : 'Person',
|
|
132
150
|
...(approveJoins ? { manuallyApprovesFollowers: true } : {}),
|
|
151
|
+
...(assertionKey ? { assertionMethod: [{
|
|
152
|
+
id: assertionKeyId(urls), type: 'Multikey', controller: urls.actor,
|
|
153
|
+
publicKeyMultibase: assertionKey,
|
|
154
|
+
}] } : {}),
|
|
133
155
|
...(movedTo ? { movedTo } : {}),
|
|
134
156
|
...(webId || aliases.length ? { alsoKnownAs: [...(webId ? [webId] : []), ...aliases] } : {}),
|
|
135
157
|
preferredUsername: handle,
|
|
@@ -526,11 +548,28 @@ export function sanitizeHtml(html) {
|
|
|
526
548
|
});
|
|
527
549
|
}
|
|
528
550
|
|
|
551
|
+
// A Lemmy post, a blog article, a PeerTube video and a Bookwyrm review each
|
|
552
|
+
// carry their headline in `name` and their body in `content`. Reading content
|
|
553
|
+
// alone loses the headline, and a link post is often nothing else — so keep it
|
|
554
|
+
// as the first line, unless the body already opens with it.
|
|
555
|
+
export function titledContent(note) {
|
|
556
|
+
const html = sanitizeHtml(note?.content);
|
|
557
|
+
const types = Array.isArray(note?.type) ? note.type : [note?.type];
|
|
558
|
+
const title = typeof note?.name === 'string' ? note.name.trim() : '';
|
|
559
|
+
if (!title || types.includes('Note')) return html;
|
|
560
|
+
const opening = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
561
|
+
if (opening.startsWith(title.toLowerCase())) return html;
|
|
562
|
+
const esc = String(title).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
|
|
563
|
+
return `<p><strong>${esc}</strong></p>${html}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
529
566
|
// Normalize a wire Note's attachment list to { url, mediaType, description }.
|
|
530
567
|
export function attachmentsOf(note) {
|
|
531
568
|
const list = Array.isArray(note?.attachment) ? note.attachment : note?.attachment ? [note.attachment] : [];
|
|
569
|
+
// A bare Link carries `href` and no `url` — Lemmy's link posts arrive that
|
|
570
|
+
// way, and reading only `url` dropped the link the post was about.
|
|
532
571
|
return list.map(a => ({
|
|
533
|
-
url: typeof a?.url === 'string' ? a.url : a?.url?.href,
|
|
572
|
+
url: typeof a?.url === 'string' ? a.url : a?.url?.href || (typeof a?.href === 'string' ? a.href : undefined),
|
|
534
573
|
mediaType: a?.mediaType || '',
|
|
535
574
|
...(a?.name ? { description: a.name } : {}),
|
|
536
575
|
})).filter(a => a.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"@netlify/blobs": "^10.7.13",
|
|
44
44
|
"@solid/access-token-verifier": "^2.1.1",
|
|
45
45
|
"jose": "^6.2.8",
|
|
46
|
+
"json-canon": "^1.0.1",
|
|
46
47
|
"node-forge": "^1.4.0",
|
|
47
48
|
"rdflib": "^2.4.0",
|
|
48
49
|
"sanitize-html": "^2.17.6",
|
package/run-agent.mjs
CHANGED
|
@@ -47,7 +47,7 @@ import { Lease } from './lib/lease.mjs';
|
|
|
47
47
|
import { startAdmin } from './lib/admin.mjs';
|
|
48
48
|
import { exposureProblem, hostLabel } from './lib/guard.mjs';
|
|
49
49
|
import { pendingSteps } from './lib/migrate.mjs';
|
|
50
|
-
import { apUrls } from './lib/wire.mjs';
|
|
50
|
+
import { apUrls, assertionKeyId } from './lib/wire.mjs';
|
|
51
51
|
import { followActor, unfollowActor, resolveHandle } from './lib/social.mjs';
|
|
52
52
|
|
|
53
53
|
export class Agent {
|
|
@@ -324,11 +324,14 @@ export class Agent {
|
|
|
324
324
|
clearInterval(this.schedTimer);
|
|
325
325
|
this.deliverer = new Deliverer({
|
|
326
326
|
store: this.store, rsaPrivate: keys.rsaPrivate, keyId: this.urls.actor + '#main-key',
|
|
327
|
+
actorId: this.urls.actor, edPrivate: keys.edPrivate,
|
|
328
|
+
proofKeyId: assertionKeyId(this.urls),
|
|
327
329
|
log: this.log, passive: this.viewer,
|
|
328
330
|
});
|
|
329
331
|
this.publisher = new Publisher({
|
|
330
332
|
config, remote: this.remote, local: this.local, store: this.store,
|
|
331
|
-
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
|
|
333
|
+
deliverer: this.deliverer, publicKeyPem: keys.rsaPublicPem,
|
|
334
|
+
assertionKey: keys.edPublicMultibase, log: this.log,
|
|
332
335
|
resolveMention: (h) => resolveHandle(this, h),
|
|
333
336
|
// Whether the fediverse tree is on the pod at all, so the ACL check does
|
|
334
337
|
// not probe for something the default layout keeps on local disk.
|
|
@@ -389,7 +392,9 @@ export class Agent {
|
|
|
389
392
|
log: this.log,
|
|
390
393
|
});
|
|
391
394
|
this.publisher.publicKeyPem = keys.rsaPublicPem;
|
|
395
|
+
this.publisher.assertionKey = keys.edPublicMultibase;
|
|
392
396
|
this.deliverer.rsaPrivate = keys.rsaPrivate;
|
|
397
|
+
this.deliverer.edPrivate = keys.edPrivate;
|
|
393
398
|
await this.publisher.publishProfile();
|
|
394
399
|
return { changed: before !== keys.rsaPublicPem, publicKeyPem: keys.rsaPublicPem };
|
|
395
400
|
}
|