mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-30 18:46:01 +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