From 577157c5057f3f15fa41bbf4be052e1c0f44f809 Mon Sep 17 00:00:00 2001 From: Armin Date: Tue, 26 Feb 2019 17:11:41 +0100 Subject: [PATCH] Some fixes + refactoring + logic to receive sharedInboxEndpoints and to add new one --- package.json | 1 + src/activitypub/ActivityPub.js | 27 ++++- src/activitypub/NitroDatasource.js | 44 ++++++-- src/activitypub/routes/inbox.js | 16 +-- src/activitypub/routes/user.js | 4 +- src/activitypub/security/index.js | 160 ++++++++--------------------- src/activitypub/utils/index.js | 69 ++++++++++++- src/jwt/generateToken.js | 2 +- src/schema.graphql | 6 ++ yarn.lock | 4 +- 10 files changed, 192 insertions(+), 141 deletions(-) diff --git a/package.json b/package.json index 799c45085..1aa02fc96 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "sanitize-html": "~1.20.0", "slug": "~1.0.0", "trunc-html": "~1.1.2", + "uuid": "^3.3.2", "wait-on": "~3.2.0" }, "devDependencies": { diff --git a/src/activitypub/ActivityPub.js b/src/activitypub/ActivityPub.js index 57b1a5353..b4d9677c2 100644 --- a/src/activitypub/ActivityPub.js +++ b/src/activitypub/ActivityPub.js @@ -1,4 +1,10 @@ -import { sendAcceptActivity, sendRejectActivity, extractNameFromId, extractDomainFromUrl } from './utils' +import { + sendAcceptActivity, + sendRejectActivity, + extractNameFromId, + extractDomainFromUrl, + signAndSend +} from './utils' import request from 'request' import as from 'activitystrea.ms' import NitroDatasource from './NitroDatasource' @@ -69,6 +75,8 @@ export default class ActivityPub { }, async (err, response, toActorObject) => { if (err) return reject(err) debug(`name = ${toActorName}@${this.domain}`) + // save shared inbox + await this.dataSource.addSharedInboxEndpoint(toActorObject.endpoints.sharedInbox) let followersCollectionPage = await this.dataSource.getFollowersCollectionPage(activity.object) @@ -123,9 +131,9 @@ export default class ActivityPub { case 'Note': const articleObject = activity.object if (articleObject.inReplyTo) { - return this.dataSource.createComment(articleObject) + return this.dataSource.createComment(activity) } else { - return this.dataSource.createPost(articleObject) + return this.dataSource.createPost(activity) } default: } @@ -134,4 +142,17 @@ export default class ActivityPub { handleDeleteActivity (activity) { debug('inside delete') } + + async sendActivity (activity) { + if (Array.isArray(activity.to) && activity.to.includes('https://www.w3.org/ns/activitystreams#Public')) { + delete activity.send + const fromName = extractNameFromId(activity.actor) + const sharedInboxEndpoints = await this.dataSource.getSharedInboxEndpoints() + await Promise.all( + sharedInboxEndpoints.map((el) => { + return signAndSend(activity, fromName, new URL(el).host, el) + }) + ) + } + } } diff --git a/src/activitypub/NitroDatasource.js b/src/activitypub/NitroDatasource.js index 957026a2c..b723fa72c 100644 --- a/src/activitypub/NitroDatasource.js +++ b/src/activitypub/NitroDatasource.js @@ -278,15 +278,17 @@ export default class NitroDatasource { ) } - async createPost (postObject) { + async createPost (activity) { // TODO how to handle the to field? Now the post is just created, doesn't matter who is the recipient // createPost + const postObject = activity.object const title = postObject.summary ? postObject.summary : postObject.content.split(' ').slice(0, 5).join(' ') - const id = extractIdFromActivityId(postObject.id) + const postId = extractIdFromActivityId(postObject.id) + const activityId = extractIdFromActivityId(activity.id) let result = await this.client.mutate({ mutation: gql` mutation { - CreatePost(content: "${postObject.content}", title: "${title}", id: "${id}") { + CreatePost(content: "${postObject.content}", title: "${title}", id: "${postId}", activityId: "${activityId}") { id } } @@ -300,7 +302,7 @@ export default class NitroDatasource { result = await this.client.mutate({ mutation: gql` mutation { - AddPostAuthor(from: {id: "${userId}"}, to: {id: "${id}"}) + AddPostAuthor(from: {id: "${userId}"}, to: {id: "${postId}"}) } ` }) @@ -308,11 +310,41 @@ export default class NitroDatasource { throwErrorIfGraphQLErrorOccurred(result) } - async createComment (postObject) { + async getSharedInboxEndpoints () { + const result = await this.client.query({ + query: gql` + query { + SharedInboxEndpoint { + uri + } + } + ` + }) + throwErrorIfGraphQLErrorOccurred(result) + return result.data.SharedInboxEnpoint + } + async addSharedInboxEndpoint (uri) { + try { + const result = await this.client.mutate({ + mutation: gql` + mutation { + CreateSharedInboxEndpoint(uri: "${uri}") + } + ` + }) + throwErrorIfGraphQLErrorOccurred(result) + return true + } catch (e) { + return false + } + } + + async createComment (activity) { + const postObject = activity.object let result = await this.client.mutate({ mutation: gql` mutation { - CreateComment(content: "${postObject.content}") { + CreateComment(content: "${postObject.content}", activityId: "${extractIdFromActivityId(activity.id)}") { id } } diff --git a/src/activitypub/routes/inbox.js b/src/activitypub/routes/inbox.js index 0f33ebaec..e8af10c2c 100644 --- a/src/activitypub/routes/inbox.js +++ b/src/activitypub/routes/inbox.js @@ -1,5 +1,5 @@ import express from 'express' -import { verify } from '../security' +import { verifySignature } from '../security' const debug = require('debug')('ea:inbox') const router = express.Router() @@ -10,21 +10,25 @@ router.post('/', async function (req, res, next) { debug(`Content-Type = ${req.get('Content-Type')}`) debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`Request headers = ${JSON.stringify(req.headers, null, 2)}`) - debug(`verify = ${await verify(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) + debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) switch (req.body.type) { case 'Create': - await await req.app.get('activityPub').handleCreateActivity(req.body).catch(next) + if (req.body.send) { + await req.app.get('ap').sendActivity(req.body).catch(next) + break + } + await req.app.get('ap').handleCreateActivity(req.body).catch(next) break case 'Undo': - await await req.app.get('activityPub').handleUndoActivity(req.body).catch(next) + await req.app.get('ap').handleUndoActivity(req.body).catch(next) break case 'Follow': debug('handleFollow') - await req.app.get('activityPub').handleFollowActivity(req.body) + await req.app.get('ap').handleFollowActivity(req.body) debug('handledFollow') break case 'Delete': - await await req.app.get('activityPub').handleDeleteActivity(req.body).catch(next) + await req.app.get('ap').handleDeleteActivity(req.body).catch(next) break /* eslint-disable */ case 'Update': diff --git a/src/activitypub/routes/user.js b/src/activitypub/routes/user.js index 8240ba393..36bb7c2db 100644 --- a/src/activitypub/routes/user.js +++ b/src/activitypub/routes/user.js @@ -1,7 +1,7 @@ import { sendCollection } from '../utils' import express from 'express' import { serveUser } from '../utils/serveUser' -import { verify } from '../security' +import { verifySignature } from '../security' const router = express.Router() const debug = require('debug')('ea:user') @@ -47,7 +47,7 @@ router.get('/:name/outbox', (req, res) => { router.post('/:name/inbox', async function (req, res, next) { debug(`body = ${JSON.stringify(req.body, null, 2)}`) debug(`actorId = ${req.body.actor}`) - debug(`verify = ${await verify(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) + debug(`verify = ${await verifySignature(`${req.protocol}://${req.hostname}:${req.port}${req.originalUrl}`, req.headers)}`) // const result = await saveActorId(req.body.actor) switch (req.body.type) { case 'Create': diff --git a/src/activitypub/security/index.js b/src/activitypub/security/index.js index 93b6cc08a..583535bcc 100644 --- a/src/activitypub/security/index.js +++ b/src/activitypub/security/index.js @@ -1,8 +1,6 @@ import dotenv from 'dotenv' import { resolve } from 'path' import crypto from 'crypto' -import { activityPub } from '../ActivityPub' -import gql from 'graphql-tag' import request from 'request' const debug = require('debug')('ea:security') @@ -24,71 +22,19 @@ export function generateRsaKeyPair () { }) } -export function signAndSend (activity, fromName, targetDomain, url) { - // fix for development: replace with http - url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url - debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`) - return new Promise(async (resolve, reject) => { - debug('inside signAndSend') - // get the private key - const result = await activityPub.dataSource.client.query({ - query: gql` - query { - User(slug: "${fromName}") { - privateKey - } - } - ` - }) - - if (result.error) { - reject(result.error) - } else { - const parsedActivity = JSON.parse(activity) - if (Array.isArray(parsedActivity['@context'])) { - parsedActivity['@context'].push('https://w3id.org/security/v1') - } else { - const context = [parsedActivity['@context']] - context.push('https://w3id.org/security/v1') - parsedActivity['@context'] = context - } - - // deduplicate context strings - parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] - const privateKey = result.data.User[0].privateKey - const date = new Date().toUTCString() - - debug(`url = ${url}`) - request({ - url: url, - headers: { - 'Host': targetDomain, - 'Date': date, - 'Signature': createSignature(privateKey, `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`, url, - { - 'Host': targetDomain, - 'Date': date, - 'Content-Type': 'application/activity+json' - }), - 'Content-Type': 'application/activity+json' - }, - method: 'POST', - body: JSON.stringify(parsedActivity) - }, (error, response) => { - if (error) { - debug(`Error = ${JSON.stringify(error, null, 2)}`) - reject(error) - } else { - debug('Response Headers:', JSON.stringify(response.headers, null, 2)) - debug('Response Body:', JSON.stringify(response.body, null, 2)) - resolve() - } - }) - } - }) +// signing +export function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { return throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const signer = crypto.createSign(algorithm) + const signingString = constructSigningString(url, headers) + signer.update(signingString) + const signatureB64 = signer.sign({ key: privKey, passphrase: process.env.PRIVATE_KEY_PASSPHRASE }, 'base64') + const headersString = Object.keys(headers).reduce((result, key) => { return result + ' ' + key.toLowerCase() }, '') + return `keyId="${keyId}",algorithm="${algorithm}",headers="(request-target)${headersString}",signature="${signatureB64}"` } -export function verify (url, headers) { +// verifying +export function verifySignature (url, headers) { return new Promise((resolve, reject) => { const signatureHeader = headers['signature'] ? headers['signature'] : headers['Signature'] if (!signatureHeader) { @@ -130,20 +76,36 @@ export function verify (url, headers) { }) } -// specify the public key owner object -/* const testPublicKeyOwner = { - "@context": jsig.SECURITY_CONTEXT_URL, - '@id': 'https://example.com/i/alice', - publicKey: [testPublicKey] -} */ +// private: signing +function constructSigningString (url, headers) { + const urlObj = new URL(url) + let signingString = `(request-target): post ${urlObj.pathname}${urlObj.search !== '' ? urlObj.search : ''}` + return Object.keys(headers).reduce((result, key) => { + return result + `\n${key.toLowerCase()}: ${headers[key]}` + }, signingString) +} -/* const publicKey = { - '@context': jsig.SECURITY_CONTEXT_URL, - type: 'RsaVerificationKey2018', - id: `https://${ActivityPub.domain}/users/${fromName}#main-key`, - controller: `https://${ActivityPub.domain}/users/${fromName}`, - publicKeyPem -} */ +// private: verifying +function httpVerify (pubKey, signature, signingString, algorithm) { + if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } + const verifier = crypto.createVerify(algorithm) + verifier.update(signingString) + return verifier.verify(pubKey, signature, 'base64') +} + +// private: verifying +// This function can be used to extract the signature,headers,algorithm etc. out of the Signature Header. +// Just pass what you want as key +function extractKeyValueFromSignatureHeader (signatureHeader, key) { + const keyString = signatureHeader.split(',').filter((el) => { + return !!el.startsWith(key) + })[0] + + let firstEqualIndex = keyString.search('=') + // When headers are requested add 17 to the index to remove "(request-target) " from the string + if (key === 'headers') { firstEqualIndex += 17 } + return keyString.substring(firstEqualIndex + 2, keyString.length - 1) +} // Obtained from invoking crypto.getHashes() export const SUPPORTED_HASH_ALGORITHMS = [ @@ -183,45 +145,3 @@ export const SUPPORTED_HASH_ALGORITHMS = [ 'ssl3-md5', 'ssl3-sha1', 'whirlpool'] - -// signing -function createSignature (privKey, keyId, url, headers = {}, algorithm = 'rsa-sha256') { - if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { return throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } - const signer = crypto.createSign(algorithm) - const signingString = constructSigningString(url, headers) - signer.update(signingString) - const signatureB64 = signer.sign({ key: privKey, passphrase: process.env.PRIVATE_KEY_PASSPHRASE }, 'base64') - const headersString = Object.keys(headers).reduce((result, key) => { return result + ' ' + key.toLowerCase() }, '') - return `keyId="${keyId}",algorithm="${algorithm}",headers="(request-target)${headersString}",signature="${signatureB64}"` -} - -// signing -function constructSigningString (url, headers) { - const urlObj = new URL(url) - let signingString = `(request-target): post ${urlObj.pathname}${urlObj.search !== '' ? urlObj.search : ''}` - return Object.keys(headers).reduce((result, key) => { - return result + `\n${key.toLowerCase()}: ${headers[key]}` - }, signingString) -} - -// verifying -function httpVerify (pubKey, signature, signingString, algorithm) { - if (!SUPPORTED_HASH_ALGORITHMS.includes(algorithm)) { throw Error(`SIGNING: Unsupported hashing algorithm = ${algorithm}`) } - const verifier = crypto.createVerify(algorithm) - verifier.update(signingString) - return verifier.verify(pubKey, signature, 'base64') -} - -// verifying -// This function can be used to extract the signature,headers,algorithm etc. out of the Signature Header. -// Just pass what you want as key -function extractKeyValueFromSignatureHeader (signatureHeader, key) { - const keyString = signatureHeader.split(',').filter((el) => { - return !!el.startsWith(key) - })[0] - - let firstEqualIndex = keyString.search('=') - // When headers are requested add 17 to the index to remove "(request-target) " from the string - if (key === 'headers') { firstEqualIndex += 17 } - return keyString.substring(firstEqualIndex + 2, keyString.length - 1) -} diff --git a/src/activitypub/utils/index.js b/src/activitypub/utils/index.js index d1d4be845..9df537e79 100644 --- a/src/activitypub/utils/index.js +++ b/src/activitypub/utils/index.js @@ -1,7 +1,9 @@ import crypto from 'crypto' -import { signAndSend } from '../security' import as from 'activitystrea.ms' import { activityPub } from '../ActivityPub' +import gql from 'graphql-tag' +import { createSignature } from '../security' +import request from 'request' const debug = require('debug')('ea:utils') export function extractNameFromId (uri) { @@ -213,3 +215,68 @@ export function throwErrorIfGraphQLErrorOccurred (result) { throw new Error(`${result.error.message ? result.error.message : result.error.errors[0].message}`) } } + +export function signAndSend (activity, fromName, targetDomain, url) { + // fix for development: replace with http + url = url.indexOf('localhost') > -1 ? url.replace('https', 'http') : url + debug(`passhprase = ${process.env.PRIVATE_KEY_PASSPHRASE}`) + return new Promise(async (resolve, reject) => { + debug('inside signAndSend') + // get the private key + const result = await activityPub.dataSource.client.query({ + query: gql` + query { + User(slug: "${fromName}") { + privateKey + } + } + ` + }) + + if (result.error) { + reject(result.error) + } else { + // add security context + const parsedActivity = JSON.parse(activity) + if (Array.isArray(parsedActivity['@context'])) { + parsedActivity['@context'].push('https://w3id.org/security/v1') + } else { + const context = [parsedActivity['@context']] + context.push('https://w3id.org/security/v1') + parsedActivity['@context'] = context + } + + // deduplicate context strings + parsedActivity['@context'] = [...new Set(parsedActivity['@context'])] + const privateKey = result.data.User[0].privateKey + const date = new Date().toUTCString() + + debug(`url = ${url}`) + request({ + url: url, + headers: { + 'Host': targetDomain, + 'Date': date, + 'Signature': createSignature(privateKey, `http://${activityPub.domain}/activitypub/users/${fromName}#main-key`, url, + { + 'Host': targetDomain, + 'Date': date, + 'Content-Type': 'application/activity+json' + }), + 'Content-Type': 'application/activity+json' + }, + method: 'POST', + body: JSON.stringify(parsedActivity) + }, (error, response) => { + if (error) { + debug(`Error = ${JSON.stringify(error, null, 2)}`) + reject(error) + } else { + debug('Response Headers:', JSON.stringify(response.headers, null, 2)) + debug('Response Body:', JSON.stringify(response.body, null, 2)) + resolve() + } + }) + } + }) +} diff --git a/src/jwt/generateToken.js b/src/jwt/generateToken.js index 7cbc70330..fb61bb4ac 100644 --- a/src/jwt/generateToken.js +++ b/src/jwt/generateToken.js @@ -10,7 +10,7 @@ export default function generateJwt (user) { audience: process.env.CLIENT_URI, subject: user.id.toString() }) - // jwt.verify(token, process.env.JWT_SECRET, (err, data) => { + // jwt.verifySignature(token, process.env.JWT_SECRET, (err, data) => { // console.log('token verification:', err, data) // }) return token diff --git a/src/schema.graphql b/src/schema.graphql index 3626dd3c2..eacbab55f 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -137,6 +137,7 @@ type User { type Post { id: ID! + activityId: String author: User @relation(name: "WROTE", direction: "IN") title: String! slug: String @@ -167,6 +168,7 @@ type Post { type Comment { id: ID! + activityId: String author: User @relation(name: "WROTE", direction: "IN") content: String! contentExcerpt: String @@ -241,3 +243,7 @@ type Tag { deleted: Boolean disabled: Boolean } +type SharedInboxEndpoint { + id: ID! + uri: String +} diff --git a/yarn.lock b/yarn.lock index fc6697b2a..78cabe011 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1576,9 +1576,9 @@ bitcore-lib@^0.13.7: inherits "=2.0.1" lodash "=3.10.1" -"bitcore-message@github:comakery/bitcore-message#dist": +"bitcore-message@github:CoMakery/bitcore-message#dist": version "1.0.2" - resolved "https://codeload.github.com/comakery/bitcore-message/tar.gz/8799cc327029c3d34fc725f05b2cf981363f6ebf" + resolved "https://codeload.github.com/CoMakery/bitcore-message/tar.gz/8799cc327029c3d34fc725f05b2cf981363f6ebf" dependencies: bitcore-lib "^0.13.7"