Files
NodeBB/src/activitypub/index.js

289 lines
7.9 KiB
JavaScript
Raw Normal View History

'use strict';
const nconf = require('nconf');
const winston = require('winston');
const { createHash, createSign, createVerify, getHashes } = require('crypto');
const request = require('../request');
const db = require('../database');
2024-02-26 16:12:40 -05:00
const meta = require('../meta');
const user = require('../user');
const utils = require('../utils');
const ttl = require('../cache/ttl');
2024-01-12 11:27:55 -05:00
const requestCache = ttl({ ttl: 1000 * 60 * 5 }); // 5 minutes
const ActivityPub = module.exports;
ActivityPub._constants = Object.freeze({
uid: -2,
publicAddress: 'https://www.w3.org/ns/activitystreams#Public',
acceptableTypes: [
'application/activity+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
],
});
2024-02-21 10:26:26 -05:00
ActivityPub._cache = requestCache;
ActivityPub.helpers = require('./helpers');
ActivityPub.inbox = require('./inbox');
ActivityPub.mocks = require('./mocks');
ActivityPub.notes = require('./notes');
ActivityPub.actors = require('./actors');
ActivityPub.resolveId = async (uid, id) => {
try {
const query = new URL(id);
({ id } = await ActivityPub.get('uid', uid, id));
const response = new URL(id);
if (query.host !== response.host) {
winston.warn(`[activitypub/resolveId] id resolution domain mismatch: ${query.href} != ${response.href}`);
return null;
}
return id;
} catch (e) {
return null;
}
};
ActivityPub.resolveInboxes = async (ids) => {
const inboxes = new Set();
2024-02-26 16:12:40 -05:00
if (!meta.config.activitypubAllowLoopback) {
ids = ids.filter((id) => {
const { hostname } = new URL(id);
return hostname !== nconf.get('url_parsed').hostname;
});
}
await ActivityPub.actors.assert(ids);
await Promise.all(ids.map(async (id) => {
const { inbox, sharedInbox } = await user.getUserFields(id, ['inbox', 'sharedInbox']);
if (sharedInbox || inbox) {
inboxes.add(sharedInbox || inbox);
}
}));
return Array.from(inboxes);
};
ActivityPub.getPublicKey = async (type, id) => {
let publicKey;
try {
({ publicKey } = await db.getObject(`${type}:${id}:keys`));
} catch (e) {
({ publicKey } = await ActivityPub.helpers.generateKeys(type, id));
}
return publicKey;
};
ActivityPub.getPrivateKey = async (type, id) => {
// Sanity checking
if (!['cid', 'uid'].includes(type) || !utils.isNumber(id) || parseInt(id, 10) < 0) {
throw new Error('[[error:invalid-data]]');
}
id = parseInt(id, 10);
let privateKey;
try {
({ privateKey } = await db.getObject(`${type}:${id}:keys`));
} catch (e) {
({ privateKey } = await ActivityPub.helpers.generateKeys(type, id));
}
let keyId;
if (type === 'uid') {
keyId = `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}#key`;
} else {
keyId = `${nconf.get('url')}/category/${id}#key`;
}
return { key: privateKey, keyId };
};
ActivityPub.fetchPublicKey = async (uri) => {
// Used for retrieving the public key from the passed-in keyId uri
const body = await ActivityPub.get('uid', 0, uri);
if (!body.hasOwnProperty('publicKey')) {
throw new Error('[[error:activitypub.pubKey-not-found]]');
}
return body.publicKey;
};
ActivityPub.sign = async ({ key, keyId }, url, payload) => {
// Returns string for use in 'Signature' header
const { host, pathname } = new URL(url);
const date = new Date().toUTCString();
let digest = null;
let headers = '(request-target) host date';
let signed_string = `(request-target): ${payload ? 'post' : 'get'} ${pathname}\nhost: ${host}\ndate: ${date}`;
// Calculate payload hash if payload present
if (payload) {
const payloadHash = createHash('sha256');
payloadHash.update(JSON.stringify(payload));
digest = `SHA-256=${payloadHash.digest('base64')}`;
headers += ' digest';
signed_string += `\ndigest: ${digest}`;
}
// Sign string using private key
let signature = createSign('sha256');
signature.update(signed_string);
signature.end();
signature = signature.sign(key, 'base64');
// Construct signature header
return {
date,
digest,
signature: `keyId="${keyId}",headers="${headers}",signature="${signature}",algorithm="hs2019"`,
};
};
ActivityPub.verify = async (req) => {
winston.verbose('[activitypub/verify] Starting signature verification...');
if (!req.headers.hasOwnProperty('signature')) {
winston.verbose('[activitypub/verify] Failed, no signature header.');
return false;
}
// Break the signature apart
let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
const split = cur.split('="');
const key = split.shift();
const value = split.join('="');
memo[key] = value.slice(0, -1);
return memo;
}, {});
const acceptableHashes = getHashes();
if (algorithm === 'hs2019' || !acceptableHashes.includes(algorithm)) {
algorithm = 'sha256';
}
// Re-construct signature string
const signed_string = headers.split(' ').reduce((memo, cur) => {
switch (cur) {
case '(request-target)': {
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`);
break;
}
case '(created)': {
memo.push(`(created): ${created}`);
break;
}
case '(expires)': {
memo.push(`(expires): ${expires}`);
break;
}
default: {
memo.push(`${cur}: ${req.headers[cur]}`);
break;
}
}
return memo;
}, []).join('\n');
// Verify the signature string via public key
try {
// Retrieve public key from remote instance
winston.verbose(`[activitypub/verify] Retrieving pubkey for ${keyId}`);
const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId);
const verify = createVerify('sha256');
verify.update(signed_string);
verify.end();
winston.verbose('[activitypub/verify] Attempting signed string verification');
const verified = verify.verify(publicKeyPem, signature, 'base64');
return verified;
} catch (e) {
winston.verbose('[activitypub/verify] Failed, key retrieval or verification failure.');
return false;
}
};
ActivityPub.get = async (type, id, uri) => {
const cacheKey = [id, uri].join(';');
2024-01-12 11:27:55 -05:00
if (requestCache.has(cacheKey)) {
return requestCache.get(cacheKey);
}
const keyData = await ActivityPub.getPrivateKey(type, id);
const headers = id >= 0 ? await ActivityPub.sign(keyData, uri) : {};
winston.verbose(`[activitypub/get] ${uri}`);
try {
const { response, body } = await request.get(uri, {
headers: {
...headers,
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
timeout: 5000,
});
if (!String(response.statusCode).startsWith('2')) {
winston.error(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`);
if (body.hasOwnProperty('error')) {
winston.error(`[activitypub/get] Error received: ${body.error}`);
}
throw new Error(`[[error:activitypub.get-failed]]`);
}
requestCache.set(cacheKey, body);
return body;
} catch (e) {
// Handle things like non-json body, etc.
throw new Error(`[[error:activitypub.get-failed]]`);
}
};
ActivityPub.send = async (type, id, targets, payload) => {
if (!Array.isArray(targets)) {
targets = [targets];
}
const inboxes = await ActivityPub.resolveInboxes(targets);
const actor = ActivityPub.helpers.resolveActor(type, id);
payload = {
2023-12-13 13:15:03 -05:00
'@context': 'https://www.w3.org/ns/activitystreams',
actor,
...payload,
};
await Promise.all(inboxes.map(async (uri) => {
const keyData = await ActivityPub.getPrivateKey(type, id);
const headers = await ActivityPub.sign(keyData, uri, payload);
winston.verbose(`[activitypub/send] ${uri}`);
try {
const { response, body } = await request.post(uri, {
headers: {
...headers,
'content-type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
body: payload,
});
if (String(response.statusCode).startsWith('2')) {
winston.verbose(`[activitypub/send] Successfully sent ${payload.type} to ${uri}`);
} else {
2024-02-09 11:37:22 -05:00
winston.warn(`[activitypub/send] Could not send ${payload.type} to ${uri}; error: ${String(body)}`);
}
} catch (e) {
winston.warn(`[activitypub/send] Could not send ${payload.type} to ${uri}; error: ${e.message}`);
}
}));
};