mirror of
				https://github.com/NodeBB/NodeBB.git
				synced 2025-10-31 02:55:58 +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