mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-29 18:16:17 +01:00
feat: http signatures support, .sign() and .verify() AP helper methods
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const request = require('request-promise-native');
|
||||
const { generateKeyPairSync, sign } = require('crypto');
|
||||
const winston = require('winston');
|
||||
|
||||
const db = require('../database');
|
||||
const ttl = require('../cache/ttl');
|
||||
|
||||
const webfingerCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours
|
||||
@@ -36,6 +39,29 @@ Helpers.query = async (id) => {
|
||||
({ href: actorUri } = actorUri);
|
||||
}
|
||||
|
||||
webfingerCache.set(id, { username, hostname, actorUri });
|
||||
return { username, hostname, actorUri };
|
||||
const { publicKey } = response.body;
|
||||
|
||||
webfingerCache.set(id, { username, hostname, actorUri, publicKey });
|
||||
return { username, hostname, actorUri, publicKey };
|
||||
};
|
||||
|
||||
Helpers.generateKeys = async (uid) => {
|
||||
winston.verbose(`[activitypub] Generating RSA key-pair for uid ${uid}`);
|
||||
const {
|
||||
publicKey,
|
||||
privateKey,
|
||||
} = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
|
||||
await db.setObject(`uid:${uid}:keys`, { publicKey, privateKey });
|
||||
return { publicKey, privateKey };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const { generateKeyPairSync } = require('crypto');
|
||||
|
||||
const winston = require('winston');
|
||||
const request = require('request-promise-native');
|
||||
const url = require('url');
|
||||
const nconf = require('nconf');
|
||||
const { createHash, createSign, createVerify } = require('crypto');
|
||||
|
||||
const db = require('../database');
|
||||
const user = require('../user');
|
||||
|
||||
const ActivityPub = module.exports;
|
||||
|
||||
@@ -36,29 +37,135 @@ ActivityPub.getPublicKey = async (uid) => {
|
||||
try {
|
||||
({ publicKey } = await db.getObject(`uid:${uid}:keys`));
|
||||
} catch (e) {
|
||||
({ publicKey } = await generateKeys(uid));
|
||||
({ publicKey } = await ActivityPub.helpers.generateKeys(uid));
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
async function generateKeys(uid) {
|
||||
winston.verbose(`[activitypub] Generating RSA key-pair for uid ${uid}`);
|
||||
const {
|
||||
publicKey,
|
||||
privateKey,
|
||||
} = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
ActivityPub.getPrivateKey = async (uid) => {
|
||||
let privateKey;
|
||||
|
||||
try {
|
||||
({ privateKey } = await db.getObject(`uid:${uid}:keys`));
|
||||
} catch (e) {
|
||||
({ privateKey } = await ActivityPub.helpers.generateKeys(uid));
|
||||
}
|
||||
|
||||
return privateKey;
|
||||
};
|
||||
|
||||
ActivityPub.fetchPublicKey = async (uri) => {
|
||||
// Used for retrieving the public key from the passed-in keyId uri
|
||||
const { publicKey } = await request({
|
||||
uri,
|
||||
headers: {
|
||||
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
json: true,
|
||||
});
|
||||
|
||||
await db.setObject(`uid:${uid}:keys`, { publicKey, privateKey });
|
||||
return { publicKey, privateKey };
|
||||
}
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
ActivityPub.sign = async (uid, url, payload) => {
|
||||
// Returns string for use in 'Signature' header
|
||||
const { host, pathname } = new URL(url);
|
||||
const date = new Date().toUTCString();
|
||||
const key = await ActivityPub.getPrivateKey(uid);
|
||||
const userslug = await user.getUserField(uid, 'userslug');
|
||||
const keyId = `${nconf.get('url')}/user/${userslug}#key`;
|
||||
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 = payloadHash.digest('hex');
|
||||
headers += ' digest';
|
||||
signed_string += `\ndigest: ${digest}`;
|
||||
}
|
||||
|
||||
// Sign string using private key
|
||||
const signatureHash = createHash('sha256');
|
||||
signatureHash.update(signed_string);
|
||||
const signatureDigest = signatureHash.digest('hex');
|
||||
let signature = createSign('sha256');
|
||||
signature.update(signatureDigest);
|
||||
signature.end();
|
||||
signature = signature.sign(key, 'hex');
|
||||
signature = btoa(signature);
|
||||
|
||||
// Construct signature header
|
||||
return {
|
||||
date,
|
||||
digest,
|
||||
signature: `keyId="${keyId}",headers="${headers}",signature="${signature}"`,
|
||||
};
|
||||
};
|
||||
|
||||
ActivityPub.verify = async (req) => {
|
||||
// Break the signature apart
|
||||
const { keyId, headers, signature } = 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;
|
||||
}, {});
|
||||
|
||||
// Retrieve public key from remote instance
|
||||
const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId);
|
||||
|
||||
// Re-construct signature string
|
||||
const signed_string = headers.split(' ').reduce((memo, cur) => {
|
||||
if (cur === '(request-target)') {
|
||||
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.path}`);
|
||||
} else if (req.headers.hasOwnProperty(cur)) {
|
||||
memo.push(`${cur}: ${req.headers[cur]}`);
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, []).join('\n');
|
||||
|
||||
// Verify the signature string via public key
|
||||
try {
|
||||
const signatureHash = createHash('sha256');
|
||||
signatureHash.update(signed_string);
|
||||
const signatureDigest = signatureHash.digest('hex');
|
||||
const verify = createVerify('sha256');
|
||||
verify.update(signatureDigest);
|
||||
verify.end();
|
||||
const verified = verify.verify(publicKeyPem, atob(signature), 'hex');
|
||||
return verified;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This is just some code to test signing and verification. This should really be in the test suite.
|
||||
*/
|
||||
// setTimeout(async () => {
|
||||
// const payload = {
|
||||
// foo: 'bar',
|
||||
// };
|
||||
// const signature = await ActivityPub.sign(1, 'http://127.0.0.1:4567/user/julian/inbox', payload);
|
||||
|
||||
// const res = await request({
|
||||
// uri: 'http://127.0.0.1:4567/user/julian/inbox',
|
||||
// method: 'post',
|
||||
// headers: {
|
||||
// Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
// ...signature,
|
||||
// },
|
||||
// json: true,
|
||||
// body: payload,
|
||||
// simple: false,
|
||||
// });
|
||||
|
||||
// console.log(res);
|
||||
// }, 1000);
|
||||
|
||||
@@ -70,9 +70,8 @@ profileController.getFederated = async function (req, res, next) {
|
||||
if (!actor) {
|
||||
return next();
|
||||
}
|
||||
// console.log(actor);
|
||||
const { preferredUsername, published, icon, image, name, summary, hostname } = actor;
|
||||
|
||||
const { preferredUsername, published, icon, image, name, summary, hostname } = actor;
|
||||
const payload = {
|
||||
uid,
|
||||
username: `${preferredUsername}@${hostname}`,
|
||||
|
||||
@@ -33,8 +33,8 @@ Controller.getActor = async (req, res) => {
|
||||
image: cover ? `${nconf.get('url')}${cover}` : null,
|
||||
|
||||
publicKey: {
|
||||
id: `${nconf.get('url')}/user/${userslug}`,
|
||||
owner: `${nconf.get('url')}/user/${userslug}#key`,
|
||||
id: `${nconf.get('url')}/user/${userslug}#key`,
|
||||
owner: `${nconf.get('url')}/user/${userslug}`,
|
||||
publicKeyPem: publicKey,
|
||||
},
|
||||
});
|
||||
@@ -97,6 +97,7 @@ Controller.getInbox = async (req, res) => {
|
||||
};
|
||||
|
||||
Controller.postInbox = async (req, res) => {
|
||||
// stub — other activity-pub services will push stuff here.
|
||||
res.sendStatus(405);
|
||||
console.log(req.body);
|
||||
|
||||
res.sendStatus(201);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ const privileges = require('../privileges');
|
||||
const cacheCreate = require('../cache/lru');
|
||||
const helpers = require('./helpers');
|
||||
const api = require('../api');
|
||||
const activitypub = require('../activitypub');
|
||||
|
||||
const controllers = {
|
||||
api: require('../controllers/api'),
|
||||
@@ -329,3 +330,13 @@ middleware.proceedOnActivityPub = (req, res, next) => {
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
middleware.validateActivity = helpers.try(async (req, res, next) => {
|
||||
// Checks the validity of the incoming payload against the sender and rejects on failure
|
||||
const verified = await activitypub.verify(req);
|
||||
if (!verified) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -12,5 +12,5 @@ module.exports = function (app, middleware, controllers) {
|
||||
app.post('/user/:userslug/outbox', middlewares, controllers.activitypub.postOutbox);
|
||||
|
||||
app.get('/user/:userslug/inbox', middlewares, controllers.activitypub.getInbox);
|
||||
app.post('/user/:userslug/inbox', middlewares, controllers.activitypub.postInbox);
|
||||
app.post('/user/:userslug/inbox', [...middlewares, middleware.validateActivity], controllers.activitypub.postInbox);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user