From e28315e87f8e44783eb661e1cb5cdd3dd8244f47 Mon Sep 17 00:00:00 2001 From: Yarmo Mackenbach Date: Fri, 30 Sep 2022 23:39:10 +0200 Subject: [PATCH] Add activitypub service provider --- src/claimDefinitions/activitypub.js | 85 +++++++++++++++++++ src/claimDefinitions/index.js | 25 +----- src/enums.js | 12 +-- src/fetcher/activitypub.js | 121 ++++++++++++++++++++++++++++ src/fetcher/index.js | 1 + 5 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 src/claimDefinitions/activitypub.js create mode 100644 src/fetcher/activitypub.js diff --git a/src/claimDefinitions/activitypub.js b/src/claimDefinitions/activitypub.js new file mode 100644 index 0000000..8fc7d0d --- /dev/null +++ b/src/claimDefinitions/activitypub.js @@ -0,0 +1,85 @@ +/* +Copyright 2021 Yarmo Mackenbach + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const E = require('../enums') + +const reURI = /^acct:(.*)@(.*)\/?/ + +const processURI = (uri) => { + const match = uri.match(reURI) + + return { + serviceprovider: { + type: 'web', + name: 'activitypub' + }, + match: { + regularExpression: reURI, + isAmbiguous: false + }, + profile: { + display: `${match[1]}@${match[2]}`, + uri: uri, + qr: null + }, + proof: { + uri: uri, + request: { + fetcher: E.Fetcher.ACTIVITYPUB, + access: E.ProofAccess.GENERIC, + format: E.ProofFormat.JSON, + data: { + username: match[1], + domain: match[2] + } + } + }, + claim: [ + { + format: E.ClaimFormat.FINGERPRINT, + relation: E.ClaimRelation.CONTAINS, + path: ['summary'] + }, + { + format: E.ClaimFormat.FINGERPRINT, + relation: E.ClaimRelation.CONTAINS, + path: ['attachment', 'value'] + } + ] + } +} + +const tests = [ + { + uri: 'acct:alice@domain.org', + shouldMatch: true + }, + { + uri: 'acct:alice', + shouldMatch: false + }, + { + uri: 'https://domain.org/@alice/', + shouldMatch: false + }, + { + uri: 'https://domain.org/alice', + shouldMatch: false + } +] + +exports.reURI = reURI +exports.processURI = processURI +exports.tests = tests diff --git a/src/claimDefinitions/index.js b/src/claimDefinitions/index.js index dcc7b81..5046ca8 100644 --- a/src/claimDefinitions/index.js +++ b/src/claimDefinitions/index.js @@ -13,28 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -const list = [ - 'dns', - 'irc', - 'xmpp', - 'matrix', - 'telegram', - 'twitter', - 'reddit', - 'liberapay', - 'lichess', - 'hackernews', - 'lobsters', - 'devto', - 'gitea', - 'gitlab', - 'github', - 'mastodon', - 'pleroma', - 'discourse', - 'owncast', - 'stackexchange' -] const data = { dns: require('./dns'), @@ -52,6 +30,7 @@ const data = { gitea: require('./gitea'), gitlab: require('./gitlab'), github: require('./github'), + activitypub: require('./activitypub'), mastodon: require('./mastodon'), pleroma: require('./pleroma'), discourse: require('./discourse'), @@ -59,5 +38,5 @@ const data = { stackexchange: require('./stackexchange') } -exports.list = list +exports.list = Object.keys(data) exports.data = data diff --git a/src/enums.js b/src/enums.js index c2813fe..e15486a 100644 --- a/src/enums.js +++ b/src/enums.js @@ -39,20 +39,22 @@ Object.freeze(ProxyPolicy) * @enum {string} */ const Fetcher = { - /** Basic HTTP requests */ - HTTP: 'http', + /** HTTP requests to ActivityPub */ + ACTIVITYPUB: 'activitypub', /** DNS module from Node.js */ DNS: 'dns', + /** Basic HTTP requests */ + HTTP: 'http', /** IRC module from Node.js */ IRC: 'irc', - /** XMPP module from Node.js */ - XMPP: 'xmpp', /** HTTP request to Matrix API */ MATRIX: 'matrix', /** HTTP request to Telegram API */ TELEGRAM: 'telegram', /** HTTP request to Twitter API */ - TWITTER: 'twitter' + TWITTER: 'twitter', + /** XMPP module from Node.js */ + XMPP: 'xmpp' } Object.freeze(Fetcher) diff --git a/src/fetcher/activitypub.js b/src/fetcher/activitypub.js new file mode 100644 index 0000000..8325e3a --- /dev/null +++ b/src/fetcher/activitypub.js @@ -0,0 +1,121 @@ +/* +Copyright 2021 Yarmo Mackenbach + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const axios = require('axios') +const jsEnv = require('browser-or-node') + +/** + * @module fetcher/activitypub + */ + +/** + * The request's timeout value in milliseconds + * @constant {number} timeout + */ +module.exports.timeout = 5000 + +/** + * Execute a fetch request + * @function + * @async + * @param {object} data - Data used in the request + * @param {string} data.username - The username of the account to verify + * @param {string} data.domain - The domain of the ActivityPub instance + * @param {object} opts - Options used to enable the request + * @param {string} opts.claims.activitypub.acct - The identifier of the verifier account + * @param {string} opts.claims.activitypub.privateKey - The private key to sign the request + * @returns {object} + */ +module.exports.fn = async (data, opts) => { + let crypto + if (jsEnv.isNode) { + crypto = require('crypto') + } + + let timeoutHandle + const timeoutPromise = new Promise((resolve, reject) => { + timeoutHandle = setTimeout( + () => reject(new Error('Request was timed out')), + data.fetcherTimeout ? data.fetcherTimeout : module.exports.timeout + ) + }) + + const fetchPromise = new Promise((resolve, reject) => { + (async () => { + if (!opts.claims.activitypub.acct.match(/acct:(.*)@(.*)/)) { + reject(new Error('ActivityPub fetcher was not set up properly')) + } + + const urlWebfinger = `https://${data.domain}/.well-known/webfinger?resource=acct:${data.username}@${data.domain}` + const webfinger = await axios.get(urlWebfinger, + { + headers: { Accept: 'application/json' } + }) + .then(res => { + return res.data + }) + .catch(error => { + reject(error) + }) + + let urlActivitypub = null + webfinger.links.forEach(element => { + if (element.type === 'application/activity+json') { + urlActivitypub = element.href + } + }) + + // Prepare the signature + const matchAcct = opts.claims.activitypub.acct.match(/acct:(.*)@(.*)/) + const now = new Date() + const { host, pathname, search } = new URL(urlActivitypub) + const signedString = `(request-target): get ${pathname}${search}\nhost: ${host}\ndate: ${now.toUTCString()}` + + const headers = { + host, + date: now.toUTCString(), + accept: 'application/activity+json' + } + + if (jsEnv.isNode) { + // Generate the signature + const sign = crypto.createSign('SHA256') + sign.write(signedString) + sign.end() + const signatureSig = sign.sign(opts.claims.activitypub.privateKey, 'base64') + headers.signature = `keyId="https://${matchAcct[2]}/${matchAcct[1]}#main-key",headers="(request-target) host date",signature="${signatureSig}",algorithm="rsa-sha256"` + } + + axios.get(urlActivitypub, + { + headers + }) + .then(res => { + return res.data + }) + .then(res => { + resolve(res) + }) + .catch(error => { + reject(error) + }) + })() + }) + + return Promise.race([fetchPromise, timeoutPromise]).then((result) => { + clearTimeout(timeoutHandle) + return result + }) +} diff --git a/src/fetcher/index.js b/src/fetcher/index.js index df32047..ffc19e8 100644 --- a/src/fetcher/index.js +++ b/src/fetcher/index.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +exports.activitypub = require('./activitypub') exports.dns = require('./dns') exports.http = require('./http') exports.irc = require('./irc')