Skip to content

Commit

Permalink
feat: http signatures support, .sign() and .verify() AP helper methods
Browse files Browse the repository at this point in the history
  • Loading branch information
julianlam committed Jun 19, 2023
1 parent 683a639 commit 6e7c7d3
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 30 deletions.
30 changes: 28 additions & 2 deletions src/activitypub/helpers.js
@@ -1,7 +1,10 @@
'use strict';

const request = require('request-promise-native');
const { generateKeyPairSync, sign } = require('crypto');

Check failure on line 4 in src/activitypub/helpers.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

'sign' is assigned a value but never used
const winston = require('winston');

const db = require('../database');
const ttl = require('../cache/ttl');

const webfingerCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours
Expand Down Expand Up @@ -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 };
};
149 changes: 128 additions & 21 deletions 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');

Check failure on line 4 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

'url' is assigned a value but never used
const nconf = require('nconf');
const { createHash, createSign, createVerify } = require('crypto');

const db = require('../database');
const user = require('../user');

const ActivityPub = module.exports;

Expand Down Expand Up @@ -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 = {

Check failure on line 153 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// foo: 'bar',

Check failure on line 154 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// };

Check failure on line 155 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// const signature = await ActivityPub.sign(1, 'http://127.0.0.1:4567/user/julian/inbox', payload);

Check failure on line 156 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character

// const res = await request({

Check failure on line 158 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// uri: 'http://127.0.0.1:4567/user/julian/inbox',

Check failure on line 159 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// method: 'post',

Check failure on line 160 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// headers: {

Check failure on line 161 in src/activitypub/index.js

View workflow job for this annotation

GitHub Actions / Lint and test (ubuntu-latest, 16, mongo-dev)

Unexpected tab character
// Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
// ...signature,
// },
// json: true,
// body: payload,
// simple: false,
// });

// console.log(res);
// }, 1000);
3 changes: 1 addition & 2 deletions src/controllers/accounts/profile.js
Expand Up @@ -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}`,
Expand Down
9 changes: 5 additions & 4 deletions src/controllers/activitypub.js
Expand Up @@ -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,
},
});
Expand Down Expand Up @@ -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);
};
11 changes: 11 additions & 0 deletions src/middleware/index.js
Expand Up @@ -17,6 +17,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'),
Expand Down Expand Up @@ -316,3 +317,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();
});
2 changes: 1 addition & 1 deletion src/routes/activitypub.js
Expand Up @@ -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);
};

0 comments on commit 6e7c7d3

Please sign in to comment.