diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js index d001cfb2ed..2be0f4fa5d 100644 --- a/src/activitypub/helpers.js +++ b/src/activitypub/helpers.js @@ -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 }; }; diff --git a/src/activitypub/index.js b/src/activitypub/index.js index b9b0a60279..20d7b38967 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -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); diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index d97261ee1e..7932ad78fa 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -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}`, diff --git a/src/controllers/activitypub.js b/src/controllers/activitypub.js index 9e4527c7ac..460d227208 100644 --- a/src/controllers/activitypub.js +++ b/src/controllers/activitypub.js @@ -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); }; diff --git a/src/middleware/index.js b/src/middleware/index.js index 2d09dfcdf1..1c733c970d 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -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(); +}); diff --git a/src/routes/activitypub.js b/src/routes/activitypub.js index 02fe9a2bf0..121d085d9b 100644 --- a/src/routes/activitypub.js +++ b/src/routes/activitypub.js @@ -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); };