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'; | 'use strict'; | ||||||
|  |  | ||||||
| const request = require('request-promise-native'); | 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 ttl = require('../cache/ttl'); | ||||||
|  |  | ||||||
| const webfingerCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours | const webfingerCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours | ||||||
| @@ -36,6 +39,29 @@ Helpers.query = async (id) => { | |||||||
| 		({ href: actorUri } = actorUri); | 		({ href: actorUri } = actorUri); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	webfingerCache.set(id, { username, hostname, actorUri }); | 	const { publicKey } = response.body; | ||||||
| 	return { username, hostname, actorUri }; |  | ||||||
|  | 	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'; | 'use strict'; | ||||||
|  |  | ||||||
| const { generateKeyPairSync } = require('crypto'); |  | ||||||
|  |  | ||||||
| const winston = require('winston'); |  | ||||||
| const request = require('request-promise-native'); | 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 db = require('../database'); | ||||||
|  | const user = require('../user'); | ||||||
|  |  | ||||||
| const ActivityPub = module.exports; | const ActivityPub = module.exports; | ||||||
|  |  | ||||||
| @@ -36,29 +37,135 @@ ActivityPub.getPublicKey = async (uid) => { | |||||||
| 	try { | 	try { | ||||||
| 		({ publicKey } = await db.getObject(`uid:${uid}:keys`)); | 		({ publicKey } = await db.getObject(`uid:${uid}:keys`)); | ||||||
| 	} catch (e) { | 	} catch (e) { | ||||||
| 		({ publicKey } = await generateKeys(uid)); | 		({ publicKey } = await ActivityPub.helpers.generateKeys(uid)); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return publicKey; | 	return publicKey; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| async function generateKeys(uid) { | ActivityPub.getPrivateKey = async (uid) => { | ||||||
| 	winston.verbose(`[activitypub] Generating RSA key-pair for uid ${uid}`); | 	let privateKey; | ||||||
| 	const { |  | ||||||
| 		publicKey, | 	try { | ||||||
| 		privateKey, | 		({ privateKey } = await db.getObject(`uid:${uid}:keys`)); | ||||||
| 	} = generateKeyPairSync('rsa', { | 	} catch (e) { | ||||||
| 		modulusLength: 2048, | 		({ privateKey } = await ActivityPub.helpers.generateKeys(uid)); | ||||||
| 		publicKeyEncoding: { | 	} | ||||||
| 			type: 'spki', |  | ||||||
| 			format: 'pem', | 	return privateKey; | ||||||
| 		}, | }; | ||||||
| 		privateKeyEncoding: { |  | ||||||
| 			type: 'pkcs8', | ActivityPub.fetchPublicKey = async (uri) => { | ||||||
| 			format: 'pem', | 	// 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; | ||||||
| 	return { publicKey, privateKey }; | }; | ||||||
| } |  | ||||||
|  | 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) { | 	if (!actor) { | ||||||
| 		return next(); | 		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 = { | 	const payload = { | ||||||
| 		uid, | 		uid, | ||||||
| 		username: `${preferredUsername}@${hostname}`, | 		username: `${preferredUsername}@${hostname}`, | ||||||
|   | |||||||
| @@ -33,8 +33,8 @@ Controller.getActor = async (req, res) => { | |||||||
| 		image: cover ? `${nconf.get('url')}${cover}` : null, | 		image: cover ? `${nconf.get('url')}${cover}` : null, | ||||||
|  |  | ||||||
| 		publicKey: { | 		publicKey: { | ||||||
| 			id: `${nconf.get('url')}/user/${userslug}`, | 			id: `${nconf.get('url')}/user/${userslug}#key`, | ||||||
| 			owner: `${nconf.get('url')}/user/${userslug}#key`, | 			owner: `${nconf.get('url')}/user/${userslug}`, | ||||||
| 			publicKeyPem: publicKey, | 			publicKeyPem: publicKey, | ||||||
| 		}, | 		}, | ||||||
| 	}); | 	}); | ||||||
| @@ -97,6 +97,7 @@ Controller.getInbox = async (req, res) => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| Controller.postInbox = async (req, res) => { | Controller.postInbox = async (req, res) => { | ||||||
| 	// stub — other activity-pub services will push stuff here. | 	console.log(req.body); | ||||||
| 	res.sendStatus(405); |  | ||||||
|  | 	res.sendStatus(201); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ const privileges = require('../privileges'); | |||||||
| const cacheCreate = require('../cache/lru'); | const cacheCreate = require('../cache/lru'); | ||||||
| const helpers = require('./helpers'); | const helpers = require('./helpers'); | ||||||
| const api = require('../api'); | const api = require('../api'); | ||||||
|  | const activitypub = require('../activitypub'); | ||||||
|  |  | ||||||
| const controllers = { | const controllers = { | ||||||
| 	api: require('../controllers/api'), | 	api: require('../controllers/api'), | ||||||
| @@ -329,3 +330,13 @@ middleware.proceedOnActivityPub = (req, res, next) => { | |||||||
|  |  | ||||||
| 	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.post('/user/:userslug/outbox', middlewares, controllers.activitypub.postOutbox); | ||||||
|  |  | ||||||
| 	app.get('/user/:userslug/inbox', middlewares, controllers.activitypub.getInbox); | 	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