Merge pull request 'Support ActivityPub identity verification' (#27) from support-activitypub into main

Reviewed-on: https://codeberg.org/keyoxide/doipjs/pulls/27
This commit is contained in:
Yarmo Mackenbach 2022-10-24 21:44:58 +02:00
commit 2d6b02eded
10 changed files with 279 additions and 51 deletions

View file

@ -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 || {

View file

@ -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

View file

@ -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

View file

@ -59,6 +59,10 @@ const opts = {
},
twitter: {
bearerToken: null
},
activitypub: {
acct: null,
privateKey: null
}
}
}

View file

@ -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)

102
src/fetcher/activitypub.js Normal file
View file

@ -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
})
}

View file

@ -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')

View file

@ -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

View file

@ -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
}

View file

@ -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)
},
}