From 50b3254c7d099370129ca590543260aba7a38e88 Mon Sep 17 00:00:00 2001 From: Yarmo Mackenbach Date: Mon, 14 Nov 2022 18:53:18 +0100 Subject: [PATCH] Upgrade API endpoints, add proxy API --- api/v1/index.js | 23 +++ api/v2/index.js | 39 ++++ api/v2/keyoxide_profile.js | 390 +++++++++++++++++++++++++++++++++++++ api/v2/proxy_get.js | 289 +++++++++++++++++++++++++++ routes/api.js | 4 + 5 files changed, 745 insertions(+) create mode 100644 api/v1/index.js create mode 100644 api/v2/index.js create mode 100644 api/v2/keyoxide_profile.js create mode 100644 api/v2/proxy_get.js diff --git a/api/v1/index.js b/api/v1/index.js new file mode 100644 index 0000000..c61d247 --- /dev/null +++ b/api/v1/index.js @@ -0,0 +1,23 @@ +/* +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. +*/ +import express from 'express' +const router = express.Router() + +router.get('*', (req, res) => { + return res.status(501).send('Proxy v1 API endpoint is no longer supported, please migrate to proxy v2 API endpoint') +}) + +export default router diff --git a/api/v2/index.js b/api/v2/index.js new file mode 100644 index 0000000..3a22084 --- /dev/null +++ b/api/v2/index.js @@ -0,0 +1,39 @@ +/* +Copyright (C) 2022 Yarmo Mackenbach + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) +any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could display +a "Source" link that leads users to an archive of the code. There are many +ways you could offer source, and different solutions will be better for different +programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. For +more information on this, and how to apply and follow the GNU AGPL, see . +*/ +import express from 'express' +import keyoxideProfileApiRouter from './keyoxide_profile.js' +import proxyGetApiRouter from './proxy_get.js' + +const router = express.Router() + +router.use('/profile', keyoxideProfileApiRouter) +router.use('/get', proxyGetApiRouter) + +export default router diff --git a/api/v2/keyoxide_profile.js b/api/v2/keyoxide_profile.js new file mode 100644 index 0000000..1cd3454 --- /dev/null +++ b/api/v2/keyoxide_profile.js @@ -0,0 +1,390 @@ +/* +Copyright (C) 2022 Yarmo Mackenbach + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) +any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could display +a "Source" link that leads users to an archive of the code. There are many +ways you could offer source, and different solutions will be better for different +programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. For +more information on this, and how to apply and follow the GNU AGPL, see . +*/ +import express from 'express' +import { check, validationResult } from 'express-validator' +import Ajv from 'ajv' +import { generateWKDProfile, generateHKPProfile } from '../../server/index.js' +import 'dotenv/config.js' + +const router = express.Router() +const ajv = new Ajv({coerceTypes: true}) + +const apiProfileSchema = { + type: "object", + properties: { + keyData: { + type: "object", + properties: { + fingerprint: { + type: "string" + }, + openpgp4fpr: { + type: "string" + }, + users: { + type: "array", + items: { + type: "object", + properties: { + userData: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + email: { type: "string" }, + comment: { type: "string" }, + isPrimary: { type: "boolean" }, + isRevoked: { type: "boolean" }, + } + }, + claims: { + type: "array", + items: { + type: "object", + properties: { + claimVersion: { type: "integer" }, + uri: { type: "string" }, + fingerprint: { type: "string" }, + status: { type: "string" }, + matches: { + type: "array", + items: { + type: "object", + properties: { + serviceProvider: { + type: "object", + properties: { + type: { type: "string" }, + name: { type: "string" }, + } + }, + match: { + type: "object", + properties: { + regularExpression: { type: "object" }, + isAmbiguous: { type: "boolean" }, + } + }, + profile: { + type: "object", + properties: { + display: { type: "string" }, + uri: { type: "string" }, + qr: { type: "string" }, + } + }, + proof: { + type: "object", + properties: { + uri: { type: "string" }, + request: { + type: "object", + properties: { + fetcher: { type: "string" }, + access: { type: "string" }, + format: { type: "string" }, + data: { type: "object" }, + } + }, + } + }, + claim: { + type: "object", + properties: { + format: { type: "string" }, + relation: { type: "string" }, + path: { + type: "array", + items: { + type: "string" + } + }, + } + }, + } + } + }, + verification: { + type: "object" + }, + summary: { + type: "object", + properties: { + profileName: { type: "string" }, + profileURL: { type: "string" }, + serviceProviderName: { type: "string" }, + isVerificationDone: { type: "boolean" }, + isVerified: { type: "boolean" }, + } + } + } + } + }, + } + } + }, + primaryUserIndex: { + type: "integer" + }, + key: { + type: "object", + properties: { + data: { type: "object" }, + fetchMethod: { type: "string" }, + uri: { type: "string" }, + } + }, + }, + }, + keyoxide: { + type: "object", + properties: { + url: { type: "string" }, + } + }, + extra: { + type: "object", + properties: { + avatarURL: { type: "string" }, + } + }, + errors: { + type: "array" + }, + }, + required: ["keyData", "keyoxide", "extra", "errors"], + additionalProperties: false +} + +const apiProfileValidate = ajv.compile(apiProfileSchema) + +const doVerification = async (data) => { + let promises = [] + let results = [] + let verificationOptions = { + proxy: { + hostname: process.env.PROXY_HOSTNAME, + policy: (process.env.PROXY_HOSTNAME != "") ? 'adaptive' : 'never' + } + } + + for (let iUser = 0; iUser < data.keyData.users.length; iUser++) { + const user = data.keyData.users[iUser] + + for (let iClaim = 0; iClaim < user.claims.length; iClaim++) { + const claim = user.claims[iClaim] + + promises.push( + new Promise(async (resolve, reject) => { + await claim.verify(verificationOptions) + results.push([iUser, iClaim, claim]) + resolve() + }) + ) + } + } + await Promise.all(promises) + + results.forEach(result => { + data.keyData.users[result[0]].claims[result[1]] = result[2] + }) + + return data +} + +const sanitize = (data) => { + let results = [] + + const dataClone = JSON.parse(JSON.stringify(data)) + + for (let iUser = 0; iUser < dataClone.keyData.users.length; iUser++) { + const user = dataClone.keyData.users[iUser] + + for (let iClaim = 0; iClaim < user.claims.length; iClaim++) { + const claim = user.claims[iClaim] + + // TODO Fix upstream + for (let iMatch = 0; iMatch < claim.matches.length; iMatch++) { + const match = claim.matches[iMatch]; + if (Array.isArray(match.claim)) { + match.claim = match.claim[0] + } + } + // TODO Fix upstream + if (!claim.verification) { + claim.verification = {} + } + // TODO Fix upstream + claim.matches.forEach(match => { + match.proof.request.access = ['generic', 'nocors', 'granted', 'server'][match.proof.request.access] + match.claim.format = ['uri', 'fingerprint', 'message'][match.claim.format] + match.claim.relation = ['contains', 'equals', 'oneof'][match.claim.relation] + }) + + data.keyData.users[iUser].claims[iClaim] = claim + } + } + + const valid = apiProfileValidate(data) + if (!valid) { + throw new Error(`Profile data sanitization error`) + } + + return data +} + +const addSummaryToClaims = (data) => { + // To be removed when data is added by DOIP library + for (let userIndex = 0; userIndex < data.keyData.users.length; userIndex++) { + const user = data.keyData.users[userIndex] + + for (let claimIndex = 0; claimIndex < user.claims.length; claimIndex++) { + const claim = user.claims[claimIndex] + + const isVerificationDone = claim.status === "verified" + const isVerified = isVerificationDone ? claim.verification.result : false + const isAmbiguous = isVerified + ? false + : claim.matches.length > 1 || claim.matches[0].match.isAmbiguous + + data.keyData.users[userIndex].claims[claimIndex].summary = { + profileName: !isAmbiguous ? claim.matches[0].profile.display : claim.uri, + profileURL: !isAmbiguous ? claim.matches[0].profile.uri : "", + serviceProviderName: !isAmbiguous ? claim.matches[0].serviceprovider.name : "", + isVerificationDone: isVerificationDone, + isVerified: isVerified, + } + } + } + + return data +} + +router.get('/fetch', + check('query').exists(), + check('protocol').optional().toLowerCase().isIn(["hkp", "wkd"]), + check('doVerification').default(false).isBoolean().toBoolean(), + check('returnPublicKey').default(false).isBoolean().toBoolean(), + async (req, res) => { + const valRes = validationResult(req); + if (!valRes.isEmpty()) { + res.status(400).send(valRes) + return + } + + // Generate profile + let data + switch (req.query.protocol) { + case 'wkd': + data = await generateWKDProfile(req.query.query) + break; + case 'hkp': + data = await generateHKPProfile(req.query.query) + break; + default: + if (req.query.query.includes('@')) { + data = await generateWKDProfile(req.query.query) + } else { + data = await generateHKPProfile(req.query.query) + } + break; + } + + if (data.errors.length > 0) { + delete data.key + res.status(500).send(data) + } + + // Return public key + if (req.query.returnPublicKey) { + data.keyData.key.data = data.key.publicKey + } + delete data.key + + // Do verification + if (req.query.doVerification) { + data = await doVerification(data) + } + + try { + // Sanitize JSON + data = sanitize(data) + } catch (error) { + data.keyData = {} + data.extra = {} + data.errors = [error.message] + } + + // Add missing data + data = addSummaryToClaims(data) + + let statusCode = 200 + if (data.errors.length > 0) { + statusCode = 500 + } + + res.status(statusCode).send(data) + } +) + +router.get('/verify', + check('data').exists().isJSON(), + async (req, res) => { + const valRes = validationResult(req) + if (!valRes.isEmpty()) { + res.status(400).send(valRes) + return + } + + // Do verification + data = await doVerification(req.query.data) + + try { + // Sanitize JSON + data = sanitize(data); + } catch (error) { + data.keyData = {} + data.extra = {} + data.errors = [error.message] + } + + // Add missing data + data = addSummaryToClaims(data) + + let statusCode = 200 + if (data.errors.length > 0) { + statusCode = 500 + } + + res.status(statusCode).send(data) + } +) + +export default router diff --git a/api/v2/proxy_get.js b/api/v2/proxy_get.js new file mode 100644 index 0000000..df28273 --- /dev/null +++ b/api/v2/proxy_get.js @@ -0,0 +1,289 @@ +/* +Copyright (C) 2022 Yarmo Mackenbach + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) +any later version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could display +a "Source" link that leads users to an archive of the code. There are many +ways you could offer source, and different solutions will be better for different +programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. For +more information on this, and how to apply and follow the GNU AGPL, see . +*/ +import express from 'express' +import { query, validationResult } from 'express-validator' +import { fetcher, enums as E } from 'doipjs' +import 'dotenv/config.js' + +const router = express.Router() + +const opts = { + claims: { + activitypub: { + url: process.env.ACTIVITYPUB_URL || null, + privateKey: process.env.ACTIVITYPUB_PRIVATE_KEY || null + }, + irc: { + nick: process.env.IRC_NICK || null + }, + matrix: { + instance: process.env.MATRIX_INSTANCE || null, + accessToken: process.env.MATRIX_ACCESS_TOKEN || null + }, + telegram: { + token: process.env.TELEGRAM_TOKEN || null + }, + twitter: { + bearerToken: process.env.TWITTER_BEARER_TOKEN || null + }, + xmpp: { + service: process.env.XMPP_SERVICE || null, + username: process.env.XMPP_USERNAME || null, + password: process.env.XMPP_PASSWORD || null + } + } +} + +// Root route +router.get('/', async (req, res) => { + return res.status(400).json({ errors: 'Invalid endpoint' }) +}) + +// HTTP route +router.get( + '/http', + query('url').isURL(), + query('format').isIn([E.ProofFormat.JSON, E.ProofFormat.TEXT]), + (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.http + .fn(req.query, opts) + .then((result) => { + switch (req.query.format) { + case E.ProofFormat.JSON: + return res.status(200).json(result) + + case E.ProofFormat.TEXT: + return res.status(200).send(result) + } + }) + .catch((err) => { + return res.status(400).json({ errors: err.message ? err.message : err }) + }) + } +) + +// DNS route +router.get('/dns', query('domain').isFQDN(), (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.dns + .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 }) + }) +}) + +// XMPP route +router.get( + '/xmpp', + query('id').isEmail(), + query('field').isIn([ + 'fn', + 'number', + 'userid', + 'url', + 'bday', + 'nickname', + 'note', + 'desc' + ]), + async (req, res) => { + if ( + !opts.claims.xmpp.service || + !opts.claims.xmpp.username || + !opts.claims.xmpp.password + ) { + return res.status(501).json({ errors: 'XMPP not enabled on server' }) + } + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.xmpp + .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 }) + }) + } +) + +// Twitter route +router.get('/twitter', query('tweetId').isInt(), async (req, res) => { + if (!opts.claims.twitter.bearerToken) { + return res.status(501).json({ errors: 'Twitter not enabled on server' }) + } + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.twitter + .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 }) + }) +}) + +// Matrix route +router.get( + '/matrix', + query('roomId').isString(), + query('eventId').isString(), + async (req, res) => { + if (!opts.claims.matrix.instance || !opts.claims.matrix.accessToken) { + return res.status(501).json({ errors: 'Matrix not enabled on server' }) + } + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.matrix + .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 }) + }) + } +) + +// Telegram route +router.get( + '/telegram', + query('user').isString(), + query('chat').isString(), + async (req, res) => { + if (!opts.claims.telegram.token) { + return res.status(501).json({ errors: 'Telegram not enabled on server' }) + } + + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.telegram + .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 }) + }) + } +) + +// IRC route +router.get('/irc', query('nick').isString(), async (req, res) => { + if (!opts.claims.irc.nick) { + return res.status(501).json({ errors: 'IRC not enabled on server' }) + } + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.irc + .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 }) + }) +}) + +// Gitlab route +router.get( + '/gitlab', + query('domain').isFQDN(), + query('username').isString(), + async (req, res) => { + const errors = validationResult(req) + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }) + } + + fetcher.http + .fn({ + url: `https://${req.query.domain}/api/v4/projects/${req.query.username}%2Fgitlab_proof`, + format: 'json' + }, opts) + .then((data) => { + return res.status(200).send(data) + }) + .catch((err) => { + return res.status(400).json({ errors: err.message ? err.message : err }) + }) + } +) + +// ActivityPub route +router.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 }) + }) + } +) + +export default router diff --git a/routes/api.js b/routes/api.js index 1aa2e42..2a6aa3c 100644 --- a/routes/api.js +++ b/routes/api.js @@ -29,9 +29,13 @@ more information on this, and how to apply and follow the GNU AGPL, see