diff --git a/src/claim.js b/src/claim.js index f6d9bfa..b3db90b 100644 --- a/src/claim.js +++ b/src/claim.js @@ -225,7 +225,7 @@ class Claim { // For each match for (let index = 0; index < this._matches.length; index++) { - const claimData = this._matches[index] + let claimData = this._matches[index] let verificationResult = null let proofData = null @@ -248,6 +248,11 @@ class Claim { fetcher: proofData.fetcher, viaProxy: proofData.viaProxy } + + // Post process the data + if (claimData.functions && claimData.functions.postprocess) { + ({ claimData, proofData } = claimData.functions.postprocess(claimData, proofData)) + } } else { // Consider the proof completed but with a negative result verificationResult = verificationResult || { diff --git a/src/claimDefinitions/activitypub.js b/src/claimDefinitions/activitypub.js new file mode 100644 index 0000000..afdf00f --- /dev/null +++ b/src/claimDefinitions/activitypub.js @@ -0,0 +1,96 @@ +/* +Copyright 2022 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 = /^https:\/\/(.*)\/?/ + +const processURI = (uri) => { + return { + serviceprovider: { + type: 'web', + name: 'activitypub' + }, + match: { + regularExpression: reURI, + isAmbiguous: false + }, + profile: { + display: uri, + uri: uri, + qr: null + }, + proof: { + uri: uri, + request: { + fetcher: E.Fetcher.ACTIVITYPUB, + access: E.ProofAccess.GENERIC, + format: E.ProofFormat.JSON, + data: { + url: uri + } + } + }, + claim: [ + { + format: E.ClaimFormat.FINGERPRINT, + relation: E.ClaimRelation.CONTAINS, + path: ['summary'] + }, + { + format: E.ClaimFormat.FINGERPRINT, + relation: E.ClaimRelation.CONTAINS, + path: ['attachment', 'value'] + } + ], + functions: { + postprocess: (claimData, proofData) => { + claimData.profile.display = `${proofData.result.preferredUsername}@${new URL(proofData.result.url).hostname}` + return { claimData, proofData } + } + } + } +} + +const tests = [ + { + uri: 'https://domain.org', + shouldMatch: true + }, + { + uri: 'https://domain.org/@/alice/', + shouldMatch: true + }, + { + uri: 'https://domain.org/@alice', + shouldMatch: true + }, + { + uri: 'https://domain.org/u/alice/', + shouldMatch: true + }, + { + uri: 'https://domain.org/users/alice/', + shouldMatch: true + }, + { + uri: 'http://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/defaults.js b/src/defaults.js index 8c80cb1..2fd03ae 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -59,6 +59,10 @@ const opts = { }, twitter: { bearerToken: null + }, + activitypub: { + acct: null, + privateKey: null } } } 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..655a0f0 --- /dev/null +++ b/src/fetcher/activitypub.js @@ -0,0 +1,102 @@ +/* +Copyright 2022 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 validator = require('validator') +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.url - The URL of the account to verify + * @param {object} opts - Options used to enable the request + * @param {string} opts.claims.activitypub.url - The URL 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 () => { + let isConfigured = false + try { + validator.isURL(opts.claims.activitypub.url) + isConfigured = true + } catch (_) {} + + const now = new Date() + const { host, pathname, search } = new URL(data.url) + + const headers = { + host, + date: now.toUTCString(), + accept: 'application/activity+json' + } + + if (isConfigured && jsEnv.isNode) { + // Generate the signature + const signedString = `(request-target): get ${pathname}${search}\nhost: ${host}\ndate: ${now.toUTCString()}` + const sign = crypto.createSign('SHA256') + sign.write(signedString) + sign.end() + const signatureSig = sign.sign(opts.claims.activitypub.privateKey.replace(/\\n/g, '\n'), 'base64') + headers.signature = `keyId="${opts.claims.activitypub.url}#main-key",headers="(request-target) host date",signature="${signatureSig}",algorithm="rsa-sha256"` + } + + axios.get(data.url, + { + 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') diff --git a/src/proxy/api/v2/index.js b/src/proxy/api/v2/index.js index 18f7a17..715d27c 100644 --- a/src/proxy/api/v2/index.js +++ b/src/proxy/api/v2/index.js @@ -246,4 +246,25 @@ router.get( } ) +// ActivityPub route +router.get( + '/get/activitypub', + query('url').isURL(), + async (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.activitypub + .fn(req.query, opts) + .then((data) => { + return res.status(200).send(data) + }) + .catch((err) => { + return res.status(400).json({ errors: err.message ? err.message : err }) + }) + } +) + module.exports = router diff --git a/src/verifications.js b/src/verifications.js index c5ce662..41f9637 100644 --- a/src/verifications.js +++ b/src/verifications.js @@ -200,33 +200,50 @@ const run = async (proofData, claimData, fingerprint) => { errors: [] } + const claimMethods = Array.isArray(claimData.claim) + ? claimData.claim + : [claimData.claim] + switch (claimData.proof.request.format) { case E.ProofFormat.JSON: - try { - res.result = await runJSON( - proofData, - claimData.claim.path, - fingerprint, - claimData.claim.format, - claimData.claim.relation - ) - res.completed = true - } catch (error) { - res.errors.push(error.message ? error.message : error) + for (let index = 0; index < claimMethods.length; index++) { + const claimMethod = claimMethods[index] + try { + res.result = res.result || await runJSON( + proofData, + claimMethod.path, + fingerprint, + claimMethod.format, + claimMethod.relation + ) + } catch (error) { + res.errors.push(error.message ? error.message : error) + } } + res.completed = true break case E.ProofFormat.TEXT: - try { - res.result = await containsProof(proofData, - fingerprint, - claimData.claim.format) - res.completed = true - } catch (error) { - res.errors.push('err_unknown_text_verification') + for (let index = 0; index < claimMethods.length; index++) { + const claimMethod = claimMethods[index] + try { + res.result = res.result || await containsProof( + proofData, + fingerprint, + claimMethod.format + ) + } catch (error) { + res.errors.push('err_unknown_text_verification') + } } + res.completed = true break } + // Reset the errors if one of the claim methods was successful + if (res.result) { + res.errors = [] + } + return res } diff --git a/test/claimDefinitions.test.js b/test/claimDefinitions.test.js index 3f494ae..66b78b8 100644 --- a/test/claimDefinitions.test.js +++ b/test/claimDefinitions.test.js @@ -49,10 +49,11 @@ const pattern = { data: _.isObject, }, }, - claim: { - format: _.isInteger, - relation: _.isInteger, - path: _.isArray, + claim: (x) => { + return _.isObject(x) || _.isArray(x) + }, + functions: (x) => { + return _.isObject(x) || _.isUndefined(x) }, }