Merge branch 'dev' into configurable-scheme initial merge conflict

resolution

Tests not updated yet
This commit is contained in:
Preston Maness 2023-07-14 17:03:30 -05:00
commit 0b9a00c69e
22 changed files with 1587 additions and 1928 deletions

View file

@ -9,7 +9,7 @@
"bent": "^7.3.12",
"body-parser": "^1.19.0",
"dialog-polyfill": "^0.5.6",
"doipjs": "^0.18.3",
"doipjs": "^1.0.0",
"dotenv": "^16.0.3",
"express": "^4.17.1",
"express-validator": "^6.13.0",
@ -28,14 +28,14 @@
"devDependencies": {
"@vercel/ncc": "^0.34.0",
"chai": "^4.3.6",
"copy-webpack-plugin": "^10.2.4",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.6.0",
"esmock": "^2.3.1",
"license-check-and-add": "^4.0.5",
"mini-css-extract-plugin": "^2.5.3",
"mocha": "^10.1.0",
"nodemon": "^2.0.20",
"rome": "^11.0.0",
"rome": "^12.1",
"standard": "^17.0.0",
"style-loader": "^3.3.1",
"webpack": "^5.75.0",
@ -46,11 +46,13 @@
"start": "node --experimental-fetch ./",
"dev": "LOG_LEVEL=debug yarn run watch & yarn run build:static:dev",
"test": "yarn run standard:check && yarn run rome:check && mocha --loader=esmock",
"test": "yarn run lint && mocha --loader=esmock",
"watch": "./node_modules/.bin/nodemon --config nodemon.json ./",
"build": "yarn run build:server & yarn run build:static",
"build:server": "ncc build ./src/index.js -o dist",
"build:static": "webpack --config webpack.config.js --env static=true --env mode=production",
"build:static:dev": "webpack --config webpack.config.js --env static=true --env mode=development",
"lint": "yarn run standard:check && yarn run rome:check",
"standard:check": "./node_modules/.bin/standard ./src",
"standard:fix": "./node_modules/.bin/standard --fix ./src",
"rome:check": "./node_modules/.bin/rome check ./src",

View file

@ -1,400 +0,0 @@
/*
Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
import express from 'express'
import { check, validationResult } from 'express-validator'
import Ajv from 'ajv'
import { generateWKDProfile, generateHKPProfile, generateAutoProfile } from '../../server/index.js'
import * as dotenv from 'dotenv'
dotenv.config()
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) => {
const promises = []
const results = []
const verificationOptions = {
proxy: {
hostname: process.env.PROXY_HOSTNAME,
policy: (process.env.PROXY_HOSTNAME !== '') ? 'adaptive' : 'never',
scheme: (process.env.PROXY_SCHEME !== '') ? process.env.PROXY_SCHEME : (process.env.SCHEME !== '') ? process.env.SCHEME : 'https'
}
}
// Return early if no users in key
if (!data.keyData.users) {
return data
}
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((resolve, reject) => {
(async () => {
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) => {
const dataClone = JSON.parse(JSON.stringify(data))
if (dataClone.keyData.users) {
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) => {
// Return early if no users in key
if (!data.keyData.users) {
return 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,
isVerified
}
}
}
return data
}
router.get('/profile/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:
data = await generateAutoProfile(req.query.query)
break
}
if (data.errors.length > 0) {
data.key = undefined
res.status(500).send(data)
}
// Return public key
if (req.query.returnPublicKey) {
data.keyData.key.data = data.key.publicKey
}
data.key = undefined
// 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('/profile/verify',
check('data').exists().isJSON(),
async (req, res) => {
const valRes = validationResult(req)
if (!valRes.isEmpty()) {
res.status(400).send(valRes)
return
}
// Do verification
let 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

View file

@ -1,23 +0,0 @@
/*
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

View file

@ -1,371 +0,0 @@
/*
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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
import express from 'express'
import { check, validationResult } from 'express-validator'
import Ajv from 'ajv'
import { generateWKDProfile, generateHKPProfile, generateAutoProfile } from '../../server/index.js'
import * as dotenv from 'dotenv'
dotenv.config()
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: 'array',
items: {
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) => {
const promises = []
const results = []
const verificationOptions = {
proxy: {
hostname: process.env.PROXY_HOSTNAME,
policy: (process.env.PROXY_HOSTNAME !== '') ? 'adaptive' : 'never',
scheme: (process.env.PROXY_SCHEME !== '') ? process.env.PROXY_SCHEME : (process.env.SCHEME !== '') ? process.env.SCHEME : 'https'
}
}
// Return early if no users in key
if (!data.keyData.users) {
return data
}
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((resolve, reject) => {
(async () => {
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) => {
const valid = apiProfileValidate(data)
if (!valid) {
throw new Error('Profile data sanitization error')
}
return data
}
const addSummaryToClaims = (data) => {
// Return early if no users in key
if (!data.keyData.users) {
return 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,
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:
data = await generateAutoProfile(req.query.query)
break
}
if (data.errors.length > 0) {
data.key = undefined
res.status(500).send(data)
}
// Return public key
if (req.query.returnPublicKey) {
data.keyData.key.data = data.key.publicKey
}
data.key = undefined
// 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
let 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

View file

@ -1,5 +1,5 @@
/*
Copyright (C) 2022 Yarmo Mackenbach
Copyright (C) 2023 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

View file

@ -0,0 +1,185 @@
/*
Copyright (C) 2023 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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
import express from 'express'
import { check, validationResult } from 'express-validator'
import Ajv from 'ajv/dist/2020.js'
import * as dotenv from 'dotenv'
import { Claim } from 'doipjs'
import { generateAspeProfile, generateWKDProfile, generateHKPProfile, generateAutoProfile } from '../../server/index.js'
import { claimSchema, personaSchema, profileSchema, serviceProviderSchema } from '../../schemas.js'
dotenv.config()
const router = express.Router()
const ajv = new Ajv({
schemas: [profileSchema, personaSchema, claimSchema, serviceProviderSchema]
})
const apiProfileValidate = ajv.compile(profileSchema)
const doVerification = async (profile) => {
const promises = []
const results = []
const verificationOptions = {
proxy: {
hostname: process.env.PROXY_HOSTNAME,
policy: (process.env.PROXY_HOSTNAME !== '') ? 'adaptive' : 'never'
}
}
// Return early if no users in key
if (!profile.personas) {
return profile
}
for (let iUser = 0; iUser < profile.personas.length; iUser++) {
const user = profile.personas[iUser]
for (let iClaim = 0; iClaim < user.claims.length; iClaim++) {
const claim = user.claims[iClaim]
promises.push(
new Promise((resolve, reject) => {
(async () => {
await claim.verify(verificationOptions)
results.push([iUser, iClaim, claim])
resolve()
})()
})
)
}
}
await Promise.all(promises)
results.forEach(result => {
profile.personas[result[0]].claims[result[1]] = result[2]
})
return profile
}
const validate = (profile) => {
const valid = apiProfileValidate(profile)
if (!valid) {
throw new Error(`Profile data validation error: ${apiProfileValidate.errors.map(x => x.message).join(', ')}`)
}
}
router.get('/fetch',
check('query').exists(),
check('protocol').optional().toLowerCase().isIn(['aspe', 'hkp', 'wkd']),
check('doVerification').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 'aspe':
data = await generateAspeProfile(req.query.query)
break
case 'wkd':
data = await generateWKDProfile(req.query.query)
break
case 'hkp':
data = await generateHKPProfile(req.query.query)
break
default:
data = await generateAutoProfile(req.query.query)
break
}
if ('errors' in data && data.errors.length > 0) {
res.status(500).send(data)
}
// Do verification
if (req.query.doVerification) {
data = await doVerification(data)
}
data = data.toJSON()
try {
// Validate JSON
validate(data)
} catch (error) {
data = {
errors: [error.message]
}
}
let statusCode = 200
if ('errors' in data && 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
}
const profile = Claim.fromJson(req.query.data)
// Do verification
let data = await doVerification(profile)
data = data.toJSON()
try {
// Validate JSON
validate(data)
} catch (error) {
data = {
errors: [error.message]
}
}
let statusCode = 200
if ('errors' in data) {
statusCode = 500
}
res.status(statusCode).send(data)
}
)
export default router

View file

@ -51,9 +51,6 @@ const opts = {
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,
@ -138,26 +135,6 @@ router.get(
}
)
// 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',

View file

@ -28,16 +28,19 @@ 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 <https://www.gnu.org/licenses/>.
*/
import express from 'express'
import apiRouter0 from '../api/v0/index.js'
import apiRouter1 from '../api/v1/index.js'
import apiRouter2 from '../api/v2/index.js'
import apiRouter3 from '../api/v3/index.js'
const router = express.Router()
if ((process.env.ENABLE_MAIN_MODULE ?? 'true') === 'true') {
router.use('/0', apiRouter0)
}
router.use('/1', apiRouter1)
router.use('/2', apiRouter2)
router.get('/0', (req, res) => {
return res.status(501).send('Proxy v0 API endpoint is no longer supported, please migrate to proxy v3 API endpoint')
})
router.get('/1', (req, res) => {
return res.status(501).send('Proxy v1 API endpoint is no longer supported, please migrate to proxy v3 API endpoint')
})
router.get('/2', (req, res) => {
return res.status(501).send('Proxy v2 API endpoint is no longer supported, please migrate to proxy v3 API endpoint')
})
router.use('/3', apiRouter3)
export default router

View file

@ -30,7 +30,6 @@ more information on this, and how to apply and follow the GNU AGPL, see <https:/
import express from 'express'
import markdownImport from 'markdown-it'
import { readFileSync } from 'fs'
import demoData from '../server/demo.js'
const router = express.Router()
const md = markdownImport({ typographer: true })
@ -48,7 +47,7 @@ router.get('/', (req, res) => {
}
}
res.render('index', { highlights, demoData })
res.render('index', { highlights })
})
router.get('/privacy', (req, res) => {

View file

@ -30,6 +30,7 @@ more information on this, and how to apply and follow the GNU AGPL, see <https:/
import express from 'express'
import bodyParserImport from 'body-parser'
import { generateSignatureProfile, utils, generateWKDProfile, generateHKPProfile, generateAutoProfile, generateKeybaseProfile } from '../server/index.js'
import { Profile } from 'doipjs'
const router = express.Router()
const bodyParser = bodyParserImport.urlencoded({ extended: false })
@ -41,10 +42,10 @@ router.get('/sig', (req, res) => {
router.post('/sig', bodyParser, async (req, res) => {
const data = await generateSignatureProfile(req.body.signature)
const title = utils.generatePageTitle('profile', data)
res.set('ariadne-identity-proof', data.keyData.openpgp4fpr)
res.set('ariadne-identity-proof', data.identifier)
res.render('profile', {
title,
data,
data: data instanceof Profile ? data.toJSON() : data,
isSignature: true,
signature: req.body.signature,
enable_message_encryption: false,
@ -55,10 +56,10 @@ router.post('/sig', bodyParser, async (req, res) => {
router.get('/wkd/:id', async (req, res) => {
const data = await generateWKDProfile(req.params.id)
const title = utils.generatePageTitle('profile', data)
res.set('ariadne-identity-proof', data.keyData.openpgp4fpr)
res.set('ariadne-identity-proof', data.identifier)
res.render('profile', {
title,
data,
data: data instanceof Profile ? data.toJSON() : data,
enable_message_encryption: false,
enable_signature_verification: false
})
@ -67,10 +68,10 @@ router.get('/wkd/:id', async (req, res) => {
router.get('/hkp/:id', async (req, res) => {
const data = await generateHKPProfile(req.params.id)
const title = utils.generatePageTitle('profile', data)
res.set('ariadne-identity-proof', data.keyData.openpgp4fpr)
res.set('ariadne-identity-proof', data.identifier)
res.render('profile', {
title,
data,
data: data instanceof Profile ? data.toJSON() : data,
enable_message_encryption: false,
enable_signature_verification: false
})
@ -79,10 +80,10 @@ router.get('/hkp/:id', async (req, res) => {
router.get('/hkp/:server/:id', async (req, res) => {
const data = await generateHKPProfile(req.params.id, req.params.server)
const title = utils.generatePageTitle('profile', data)
res.set('ariadne-identity-proof', data.keyData.openpgp4fpr)
res.set('ariadne-identity-proof', data.identifier)
res.render('profile', {
title,
data,
data: data instanceof Profile ? data.toJSON() : data,
enable_message_encryption: false,
enable_signature_verification: false
})
@ -91,10 +92,10 @@ router.get('/hkp/:server/:id', async (req, res) => {
router.get('/keybase/:username/:fingerprint', async (req, res) => {
const data = await generateKeybaseProfile(req.params.username, req.params.fingerprint)
const title = utils.generatePageTitle('profile', data)
res.set('ariadne-identity-proof', data.keyData.openpgp4fpr)
res.set('ariadne-identity-proof', data.identifier)
res.render('profile', {
title,
data,
data: data instanceof Profile ? data.toJSON() : data,
enable_message_encryption: false,
enable_signature_verification: false
})
@ -103,10 +104,10 @@ router.get('/keybase/:username/:fingerprint', async (req, res) => {
router.get('/:id', async (req, res) => {
const data = await generateAutoProfile(req.params.id)
const title = utils.generatePageTitle('profile', data)
res.set('ariadne-identity-proof', data.keyData.openpgp4fpr)
res.set('ariadne-identity-proof', data.identifier)
res.render('profile', {
title,
data,
data: data instanceof Profile ? data.toJSON() : data,
enable_message_encryption: false,
enable_signature_verification: false
})

375
src/schemas.js Normal file
View file

@ -0,0 +1,375 @@
/*
Copyright (C) 2023 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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
export const profileSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://spec.keyoxide.org/2/profile.schema.json',
title: 'Profile',
description: 'Keyoxide profile with personas',
type: 'object',
properties: {
profileVersion: {
description: 'The version of the profile',
type: 'integer'
},
profileType: {
description: 'The type of the profile [openpgp, asp]',
type: 'string'
},
identifier: {
description: 'Identifier of the profile (email, fingerprint, URI)',
type: 'string'
},
personas: {
description: 'The personas inside the profile',
type: 'array',
items: {
$ref: 'https://spec.keyoxide.org/2/persona.schema.json'
},
minItems: 1,
uniqueItems: true
},
primaryPersonaIndex: {
description: 'The index of the primary persona',
type: 'integer'
},
publicKey: {
description: 'The cryptographic key associated with the profile',
type: 'object',
properties: {
keyType: {
description: 'The type of cryptographic key [eddsa, es256, openpgp, none]',
type: 'string'
},
encoding: {
description: 'The encoding of the cryptographic key [pem, jwk, armored_pgp, none]',
type: 'string'
},
encodedKey: {
description: 'The encoded cryptographic key (PEM, stringified JWK, ...)',
type: ['string', 'null']
},
fetch: {
description: 'Details on how to fetch the public key',
type: 'object',
properties: {
method: {
description: 'The method to fetch the key [aspe, hkp, wkd, http, none]',
type: 'string'
},
query: {
description: 'The query to fetch the key',
type: ['string', 'null']
},
resolvedUrl: {
description: 'The URL the method eventually resolved to',
type: ['string', 'null']
}
}
}
},
required: [
'keyType',
'fetch'
]
},
verifiers: {
description: 'A list of links to verifiers',
type: 'array',
items: {
type: 'object',
properties: {
name: {
description: 'Name of the verifier site',
type: 'string'
},
url: {
description: 'URL to the profile page on the verifier site',
type: 'string'
}
}
},
uniqueItems: true
}
},
required: [
'profileVersion',
'profileType',
'identifier',
'personas',
'primaryPersonaIndex',
'publicKey',
'verifiers'
],
additionalProperties: false
}
export const personaSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://spec.keyoxide.org/2/persona.schema.json',
title: 'Profile',
description: 'Keyoxide persona with identity claims',
type: 'object',
properties: {
identifier: {
description: 'Identifier of the persona',
type: ['string', 'null']
},
name: {
description: 'Name of the persona',
type: 'string'
},
email: {
description: 'Email address of the persona',
type: ['string', 'null']
},
description: {
description: 'Description of the persona',
type: ['string', 'null']
},
avatarUrl: {
description: 'URL to an avatar image',
type: ['string', 'null']
},
isRevoked: {
type: 'boolean'
},
claims: {
description: 'A list of identity claims',
type: 'array',
items: {
$ref: 'https://spec.keyoxide.org/2/claim.schema.json'
},
uniqueItems: true
}
},
required: [
'name',
'claims'
],
additionalProperties: false
}
export const claimSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://spec.keyoxide.org/2/claim.schema.json',
title: 'Identity claim',
description: 'Verifiable online identity claim',
type: 'object',
properties: {
claimVersion: {
description: 'The version of the claim',
type: 'integer'
},
uri: {
description: 'The claim URI',
type: 'string'
},
proofs: {
description: 'The proofs that would verify the claim',
type: 'array',
items: {
type: 'string'
},
minItems: 1,
uniqueItems: true
},
matches: {
description: 'Service providers matched to the claim',
type: 'array',
items: {
$ref: 'https://spec.keyoxide.org/2/serviceprovider.schema.json'
},
uniqueItems: true
},
status: {
type: 'integer',
description: 'Claim status code'
},
display: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Account name to display in the user interface'
},
url: {
type: ['string', 'null'],
description: 'URL to link to in the user interface'
},
serviceProviderName: {
type: ['string', 'null'],
description: 'Name of the service provider to display in the user interface'
}
}
}
},
required: [
'claimVersion',
'uri',
'proofs',
'status',
'display'
],
additionalProperties: false
}
export const serviceProviderSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://spec.keyoxide.org/2/serviceprovider.schema.json',
title: 'Service provider',
description: 'A service provider that can be matched to identity claims',
type: 'object',
properties: {
about: {
description: 'Details about the service provider',
type: 'object',
properties: {
name: {
description: 'Full name of the service provider',
type: 'string'
},
id: {
description: 'Identifier of the service provider (no whitespace or symbols, lowercase)',
type: 'string'
},
homepage: {
description: 'URL to the homepage of the service provider',
type: ['string', 'null']
}
}
},
profile: {
description: 'What the profile would look like if the match is correct',
type: 'object',
properties: {
display: {
description: 'Profile name to be displayed',
type: 'string'
},
uri: {
description: 'URI or URL for public access to the profile',
type: 'string'
},
qr: {
description: 'URI or URL associated with the profile usually served as a QR code',
type: ['string', 'null']
}
}
},
claim: {
description: 'Details from the claim matching process',
type: 'object',
properties: {
uriRegularExpression: {
description: 'Regular expression used to parse the URI',
type: 'string'
},
uriIsAmbiguous: {
description: 'Whether this match automatically excludes other matches',
type: 'boolean'
}
}
},
proof: {
description: 'Information for the proof verification process',
type: 'object',
properties: {
request: {
description: 'Details to request the potential proof',
type: 'object',
properties: {
uri: {
description: 'Location of the proof',
type: ['string', 'null']
},
accessRestriction: {
description: 'Type of access restriction [none, nocors, granted, server]',
type: 'string'
},
fetcher: {
description: 'Name of the fetcher to use',
type: 'string'
},
data: {
description: 'Data needed by the fetcher or proxy to request the proof',
type: 'object',
additionalProperties: true
}
}
},
response: {
description: 'Details about the expected response',
type: 'object',
properties: {
format: {
description: 'Expected format of the proof [text, json]',
type: 'string'
}
}
},
target: {
description: 'Details about the target located in the response',
type: 'array',
items: {
type: 'object',
properties: {
format: {
description: 'How is the proof formatted [uri, fingerprint]',
type: 'string'
},
encoding: {
description: 'How is the proof encoded [plain, html, xml]',
type: 'string'
},
relation: {
description: 'How are the response and the target related [contains, equals]',
type: 'string'
},
path: {
description: 'Path to the target location if the response is JSON',
type: 'array',
items: {
type: 'string'
}
}
}
}
}
}
}
},
required: [
'about',
'profile',
'claim',
'proof'
],
additionalProperties: false
}

View file

@ -1,81 +0,0 @@
/*
Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
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 <https://www.gnu.org/licenses/>.
*/
export default {
claimVersion: 1,
uri: 'https://fosstodon.org/@keyoxide',
fingerprint: '9f0048ac0b23301e1f77e994909f6bd6f80f485d',
status: 'verified',
matches: [
{
serviceprovider: {
type: 'web',
name: 'mastodon (demo)'
},
match: {
regularExpression: {},
isAmbiguous: true
},
profile: {
display: '@keyoxide@fosstodon.org',
uri: 'https://fosstodon.org/@keyoxide',
qr: null
},
proof: {
uri: 'https://fosstodon.org/@keyoxide',
request: {
fetcher: 'http',
access: 0,
format: 'json',
data: {
url: 'https://fosstodon.org/@keyoxide',
format: 'json'
}
}
},
claim: {
format: 1,
relation: 0,
path: [
'attachment',
'value'
]
}
}
],
verification: {
result: true,
completed: true,
errors: [],
proof: {
fetcher: 'http',
viaProxy: false
}
}
}

View file

@ -29,45 +29,48 @@ more information on this, and how to apply and follow the GNU AGPL, see <https:/
*/
import logger from '../log.js'
import * as doipjs from 'doipjs'
import { fetchWKD, fetchHKP, fetchSignature, fetchKeybase } from './keys.js'
import { fetchWKD, fetchHKP, fetchSignature, fetchKeybase } from './openpgpProfiles.js'
import libravatar from 'libravatar'
const generateAspeProfile = async (id) => {
logger.debug('Generating an ASPE profile',
{ component: 'aspe_profile_generator', action: 'start', profile_id: id })
return doipjs.asp.fetchASPE(id)
.then(profile => {
profile.addVerifier('keyoxide', `https://${process.env.DOMAIN}/${id}`)
profile = processAspProfile(profile)
return profile
})
.catch(err => {
logger.warn('Failed to generate ASPE profile',
{ component: 'aspe_profile_generator', action: 'failure', error: err.message, profile_id: id })
return {
errors: [err.message]
}
})
}
const generateWKDProfile = async (id) => {
logger.debug('Generating a WKD profile',
{ component: 'wkd_profile_generator', action: 'start', profile_id: id })
return fetchWKD(id)
.then(async key => {
let keyData = await doipjs.keys.process(key.publicKey)
keyData.openpgp4fpr = `openpgp4fpr:${keyData.fingerprint.toLowerCase()}`
keyData.key.fetchMethod = 'wkd'
keyData.key.uri = key.fetchURL
keyData.key.data = {}
keyData = processKeyData(keyData)
const keyoxideData = {}
keyoxideData.url = `${getScheme()}://${process.env.DOMAIN}/wkd/${id}`
.then(async profile => {
profile.addVerifier('keyoxide', `${getScheme()}://${process.env.DOMAIN}/wkd/${id}`)
profile = processOpenPgpProfile(profile)
logger.debug('Generating a WKD profile',
{ component: 'wkd_profile_generator', action: 'done', profile_id: id })
return {
key,
keyData,
keyoxide: keyoxideData,
extra: await computeExtraData(key, keyData),
errors: []
}
return profile
})
.catch(err => {
logger.warn('Failed to generate WKD profile',
{ component: 'wkd_profile_generator', action: 'failure', error: err.message, profile_id: id })
return {
key: {},
keyData: {},
keyoxide: {},
extra: {},
errors: [err.message]
}
})
@ -78,41 +81,27 @@ const generateHKPProfile = async (id, keyserverDomain) => {
{ component: 'hkp_profile_generator', action: 'start', profile_id: id, keyserver_domain: keyserverDomain || '' })
return fetchHKP(id, keyserverDomain)
.then(async key => {
let keyData = await doipjs.keys.process(key.publicKey)
keyData.openpgp4fpr = `openpgp4fpr:${keyData.fingerprint.toLowerCase()}`
keyData.key.fetchMethod = 'hkp'
keyData.key.uri = key.fetchURL
keyData.key.data = {}
keyData = processKeyData(keyData)
const keyoxideData = {}
.then(async profile => {
let keyoxideUrl
if (!keyserverDomain || keyserverDomain === 'keys.openpgp.org') {
keyoxideData.url = `${getScheme()}://${process.env.DOMAIN}/hkp/${id}`
keyoxideUrl = `${getScheme()}://${process.env.DOMAIN}/hkp/${id}`
} else {
keyoxideData.url = `${getScheme()}://${process.env.DOMAIN}/hkp/${keyserverDomain}/${id}`
keyoxideUrl = `${getScheme()}://${process.env.DOMAIN}/hkp/${keyserverDomain}/${id}`
}
profile.addVerifier('keyoxide', keyoxideUrl)
profile = processOpenPgpProfile(profile)
logger.debug('Generating a HKP profile',
{ component: 'hkp_profile_generator', action: 'done', profile_id: id, keyserver_domain: keyserverDomain || '' })
return {
key,
keyData,
keyoxide: keyoxideData,
extra: await computeExtraData(key, keyData),
errors: []
}
return profile
})
.catch(err => {
logger.warn('Failed to generate HKP profile',
{ component: 'hkp_profile_generator', action: 'failure', error: err.message, profile_id: id, keyserver_domain: keyserverDomain || '' })
return {
key: {},
keyData: {},
keyoxide: {},
extra: {},
errors: [err.message]
}
})
@ -121,25 +110,31 @@ const generateHKPProfile = async (id, keyserverDomain) => {
const generateAutoProfile = async (id) => {
let result
const aspeRe = /aspe:(.*):(.*)/
if (aspeRe.test(id)) {
result = await generateAspeProfile(id)
if (result && !('errors' in result)) {
return result
}
}
if (id.includes('@')) {
result = await generateWKDProfile(id)
if (result && result.errors.length === 0) {
if (result && !('errors' in result)) {
return result
}
}
result = await generateHKPProfile(id)
if (result && result.errors.length === 0) {
if (result && !('errors' in result)) {
return result
}
return {
key: {},
keyData: {},
keyoxide: {},
extra: {},
errors: ['No public keys could be found']
errors: ['No public profile/keys could be found']
}
}
@ -149,34 +144,19 @@ const generateSignatureProfile = async (signature) => {
return fetchSignature(signature)
.then(async key => {
let keyData = key.keyData
keyData.openpgp4fpr = `openpgp4fpr:${keyData.fingerprint.toLowerCase()}`
key.keyData = undefined
keyData.key.data = {}
keyData = processKeyData(keyData)
const keyoxideData = {}
let profile = await doipjs.signatures.parse(key.publicKey)
profile = processOpenPgpProfile(profile)
logger.debug('Generating a signature profile',
{ component: 'signature_profile_generator', action: 'done' })
return {
key,
keyData,
keyoxide: keyoxideData,
extra: await computeExtraData(key, keyData),
errors: []
}
return profile
})
.catch(err => {
logger.warn('Failed to generate a signature profile',
{ component: 'signature_profile_generator', action: 'failure', error: err.message })
return {
key: {},
keyData: {},
keyoxide: {},
extra: {},
errors: [err.message]
}
})
@ -187,82 +167,91 @@ const generateKeybaseProfile = async (username, fingerprint) => {
{ component: 'keybase_profile_generator', action: 'start', username, fingerprint })
return fetchKeybase(username, fingerprint)
.then(async key => {
let keyData = await doipjs.keys.process(key.publicKey)
keyData.openpgp4fpr = `openpgp4fpr:${keyData.fingerprint.toLowerCase()}`
keyData.key.fetchMethod = 'hkp'
keyData.key.uri = key.fetchURL
keyData.key.data = {}
keyData = processKeyData(keyData)
const keyoxideData = {}
keyoxideData.url = `${getScheme()}://${process.env.DOMAIN}/keybase/${username}/${fingerprint}`
.then(async profile => {
profile.addVerifier('keyoxide', `${getScheme()}://${process.env.DOMAIN}/keybase/${username}/${fingerprint}`)
profile = processOpenPgpProfile(profile)
logger.debug('Generating a Keybase profile',
{ component: 'keybase_profile_generator', action: 'done', username, fingerprint })
return {
key,
keyData,
keyoxide: keyoxideData,
extra: await computeExtraData(key, keyData),
errors: []
}
return profile
})
.catch(err => {
logger.warn('Failed to generate a Keybase profile',
{ component: 'keybase_profile_generator', action: 'failure', error: err.message, username, fingerprint })
return {
key: {},
keyData: {},
keyoxide: {},
extra: {},
errors: [err.message]
}
})
}
const processKeyData = (keyData) => {
keyData.users.forEach(user => {
const processAspProfile = async (/** @type {import('doipjs').Profile */ profile) => {
profile.personas.forEach(persona => {
// Remove faulty claims
user.claims = user.claims.filter(claim => {
persona.claims = persona.claims.filter(claim => {
return claim instanceof doipjs.Claim
})
// Match claims
user.claims.forEach(claim => {
persona.claims.forEach(claim => {
claim.match()
})
// Sort claims
user.claims.sort((a, b) => {
persona.claims.sort((a, b) => {
if (a.matches.length === 0) return 1
if (b.matches.length === 0) return -1
if (a.matches[0].serviceprovider.name < b.matches[0].serviceprovider.name) {
if (a.matches[0].about.name < b.matches[0].about.name) {
return -1
}
if (a.matches[0].serviceprovider.name > b.matches[0].serviceprovider.name) {
if (a.matches[0].about.name > b.matches[0].about.name) {
return 1
}
return 0
})
})
keyData.primaryUserIndex ||= 0
// Overwrite avatarUrl
// TODO: don't overwrite avatarUrl once it's fully supported
profile.personas[profile.primaryPersonaIndex].avatarUrl = `https://api.dicebear.com/6.x/shapes/svg?seed=${profile.publicKey.fingerprint}&size=128`
return keyData
return profile
}
const computeExtraData = async (key, keyData) => {
// Get the primary user
const primaryUser = await key.publicKey.getPrimaryUser()
const processOpenPgpProfile = async (/** @type {import('doipjs').Profile */ profile) => {
profile.personas.forEach(persona => {
// Remove faulty claims
persona.claims = persona.claims.filter(claim => {
return claim instanceof doipjs.Claim
})
// Query libravatar to get the avatar url
return {
avatarURL: await libravatar.get_avatar_url({ email: primaryUser.user.userID.email, size: 128, default: 'mm', https: true })
}
// Match claims
persona.claims.forEach(claim => {
claim.match()
})
// Sort claims
persona.claims.sort((a, b) => {
if (a.matches.length === 0) return 1
if (b.matches.length === 0) return -1
if (a.matches[0].about.name < b.matches[0].about.name) {
return -1
}
if (a.matches[0].about.name > b.matches[0].about.name) {
return 1
}
return 0
})
})
// Overwrite avatarUrl
// TODO: don't overwrite avatarUrl once it's fully supported
profile.personas[profile.primaryPersonaIndex].avatarUrl = await libravatar.get_avatar_url({ email: profile.personas[profile.primaryPersonaIndex].email, size: 128, default: 'mm', https: true })
return profile
}
const getScheme = () => {
@ -273,6 +262,7 @@ const getScheme = () => {
: 'https'
}
export { generateAspeProfile }
export { generateWKDProfile }
export { generateHKPProfile }
export { generateAutoProfile }

View file

@ -29,7 +29,7 @@ more information on this, and how to apply and follow the GNU AGPL, see <https:/
*/
import got from 'got'
import * as doipjs from 'doipjs'
import { readKey, readCleartextMessage, verify, PublicKey } from 'openpgp'
import { readKey } from 'openpgp'
import { computeWKDLocalPart } from './utils.js'
import { createHash } from 'crypto'
import Keyv from 'keyv'
@ -39,10 +39,9 @@ const c = process.env.ENABLE_EXPERIMENTAL_CACHE ? new Keyv() : null
const fetchWKD = (id) => {
return new Promise((resolve, reject) => {
(async () => {
const output = {
publicKey: null,
fetchURL: null
}
let publicKey = null
let profile = null
let fetchURL = null
if (!id.includes('@')) {
reject(new Error(`The WKD identifier "${id}" is invalid`))
@ -59,14 +58,14 @@ const fetchWKD = (id) => {
const hash = createHash('md5').update(id).digest('hex')
if (c && await c.get(hash)) {
plaintext = Uint8Array.from((await c.get(hash)).split(','))
profile = doipjs.Claim.fromJson(JSON.parse(await c.get(hash)))
}
if (!plaintext) {
if (!profile) {
try {
plaintext = await got(urlAdvanced).then((response) => {
if (response.statusCode === 200) {
output.fetchURL = urlAdvanced
fetchURL = urlAdvanced
return new Uint8Array(response.rawBody)
} else {
return null
@ -76,7 +75,7 @@ const fetchWKD = (id) => {
try {
plaintext = await got(urlDirect).then((response) => {
if (response.statusCode === 200) {
output.fetchURL = urlDirect
fetchURL = urlDirect
return new Uint8Array(response.rawBody)
} else {
return null
@ -91,24 +90,29 @@ const fetchWKD = (id) => {
reject(new Error('No public keys could be fetched using WKD'))
}
if (c && plaintext instanceof Uint8Array) {
await c.set(hash, plaintext.toString(), 60 * 1000)
try {
publicKey = await readKey({
binaryKey: plaintext
})
} catch (error) {
reject(new Error('No public keys could be read from the data fetched using WKD'))
}
if (!publicKey) {
reject(new Error('No public keys could be read from the data fetched using WKD'))
}
profile = await doipjs.openpgp.parsePublicKey(publicKey)
profile.publicKey.fetch.method = 'wkd'
profile.publicKey.fetch.query = id
profile.publicKey.fetch.resolvedUrl = fetchURL
}
try {
output.publicKey = await readKey({
binaryKey: plaintext
})
} catch (error) {
reject(new Error('No public keys could be read from the data fetched using WKD'))
if (c && plaintext instanceof Uint8Array) {
await c.set(hash, JSON.stringify(profile), 60 * 1000)
}
if (!output.publicKey) {
reject(new Error('No public keys could be read from the data fetched using WKD'))
}
resolve(output)
resolve(profile)
})()
})
}
@ -116,12 +120,10 @@ const fetchWKD = (id) => {
const fetchHKP = (id, keyserverDomain) => {
return new Promise((resolve, reject) => {
(async () => {
const output = {
publicKey: null,
fetchURL: null
}
let profile = null
let fetchURL = null
keyserverDomain = keyserverDomain || 'keys.openpgp.org'
const keyserverDomainNormalized = keyserverDomain || 'keys.openpgp.org'
let query = ''
if (id.includes('@')) {
@ -135,31 +137,36 @@ const fetchHKP = (id, keyserverDomain) => {
query = `0x${sanitizedId}`
}
output.fetchURL = `https://${keyserverDomain}/pks/lookup?op=get&options=mr&search=${query}`
fetchURL = `https://${keyserverDomainNormalized}/pks/lookup?op=get&options=mr&search=${query}`
const hash = createHash('md5').update(`${query}__${keyserverDomain}`).digest('hex')
const hash = createHash('md5').update(`${query}__${keyserverDomainNormalized}`).digest('hex')
if (c && await c.get(hash)) {
output.publicKey = await readKey({
armoredKey: await c.get(hash)
})
} else {
profile = doipjs.Claim.fromJson(JSON.parse(await c.get(hash)))
}
if (!profile) {
try {
output.publicKey = await doipjs.keys.fetchHKP(query, keyserverDomain)
profile = await doipjs.openpgp.fetchHKP(query, keyserverDomainNormalized)
} catch (error) {
reject(new Error('No public keys could be fetched using HKP'))
profile = null
}
}
if (!output.publicKey) {
if (!profile) {
reject(new Error('No public keys could be fetched using HKP'))
return
}
if (c && output.publicKey instanceof PublicKey) {
await c.set(hash, output.publicKey.armor(), 60 * 1000)
profile.publicKey.fetch.method = 'hkp'
profile.publicKey.fetch.query = id
profile.publicKey.fetch.resolvedUrl = fetchURL
if (c && profile instanceof doipjs.Profile) {
await c.set(hash, JSON.stringify(profile), 60 * 1000)
}
resolve(output)
resolve(profile)
})()
})
}
@ -167,48 +174,22 @@ const fetchHKP = (id, keyserverDomain) => {
const fetchSignature = (signature) => {
return new Promise((resolve, reject) => {
(async () => {
const output = {
publicKey: null,
fetchURL: null,
keyData: null
}
// Check validity of signature
let signatureData
try {
signatureData = await readCleartextMessage({
cleartextMessage: signature
})
} catch (error) {
reject(new Error(`Signature could not be properly read (${error.message})`))
}
let profile = null
// Process the signature
try {
output.keyData = await doipjs.signatures.process(signature)
output.publicKey = output.keyData.key.data
profile = await doipjs.signatures.parse(signature)
// TODO Find the URL to the key
output.fetchURL = null
} catch (error) {
reject(new Error(`Signature could not be properly read (${error.message})`))
}
// Check if a key was fetched
if (!output.publicKey) {
reject(new Error('No public keys could be fetched'))
if (!profile) {
reject(new Error('No profile could be fetched'))
}
// Check validity of signature
const verified = await verify({
message: signatureData,
verificationKeys: output.publicKey
})
if (!await verified.signatures[0].verified) {
reject(new Error('Signature was invalid'))
}
resolve(output)
resolve(profile)
})()
})
}
@ -216,23 +197,24 @@ const fetchSignature = (signature) => {
const fetchKeybase = (username, fingerprint) => {
return new Promise((resolve, reject) => {
(async () => {
const output = {
publicKey: null,
fetchURL: null
}
let profile = null
let fetchURL = null
try {
output.publicKey = await doipjs.keys.fetchKeybase(username, fingerprint)
output.fetchURL = `https://keybase.io/${username}/pgp_keys.asc?fingerprint=${fingerprint}`
profile = await doipjs.openpgp.fetchKeybase(username, fingerprint)
fetchURL = `https://keybase.io/${username}/pgp_keys.asc?fingerprint=${fingerprint}`
} catch (error) {
reject(new Error('No public keys could be fetched from Keybase'))
}
if (!output.publicKey) {
if (!profile) {
reject(new Error('No public keys could be fetched from Keybase'))
}
resolve(output)
profile.publicKey.fetch.method = 'http'
profile.publicKey.fetch.resolvedUrl = fetchURL
resolve(profile)
})()
})
}

View file

@ -39,7 +39,7 @@ export function generatePageTitle (type, data) {
switch (type) {
case 'profile':
try {
return `${data.keyData.users[data.keyData.primaryUserIndex].userData.name} - Keyoxide`
return `${data.personas[data.primaryPersonaIndex].name} - Keyoxide`
} catch (error) {
return 'Profile - Keyoxide'
}

View file

@ -45,7 +45,7 @@ export class Claim extends HTMLElement {
}
async verify() {
const claim = new doipjs.Claim(JSON.parse(this.getAttribute('data-claim')));
const claim = doipjs.Claim.fromJson(JSON.parse(this.getAttribute('data-claim')));
await claim.verify({
proxy: {
policy: 'adaptive',
@ -58,24 +58,14 @@ export class Claim extends HTMLElement {
updateContent(value) {
const root = this;
const claim = new doipjs.Claim(JSON.parse(value));
const claim = doipjs.Claim.fromJson(JSON.parse(value));
switch (claim.matches[0].serviceprovider.name) {
case 'dns':
case 'xmpp':
case 'irc':
root.querySelector('.info .subtitle').innerText = claim.matches[0].serviceprovider.name.toUpperCase();
break;
default:
root.querySelector('.info .subtitle').innerText = claim.matches[0].serviceprovider.name;
break;
}
root.querySelector('.info .subtitle').innerText = claim.matches[0].about.name;
root.querySelector('.info .title').innerText = claim.matches[0].profile.display;
try {
if (claim.status === 'verified') {
root.querySelector('.icons .verificationStatus').setAttribute('data-value', claim.verification.result ? 'success' : 'failed');
if (claim.status >= 200) {
root.querySelector('.icons .verificationStatus').setAttribute('data-value', claim.status < 300 ? 'success' : 'failed');
} else {
root.querySelector('.icons .verificationStatus').setAttribute('data-value', 'running');
}
@ -87,7 +77,7 @@ export class Claim extends HTMLElement {
elContent.innerHTML = ``;
// Handle failed ambiguous claim
if (claim.status === 'verified' && !claim.verification.result && claim.isAmbiguous()) {
if (claim.status >= 300 && claim.isAmbiguous()) {
root.querySelector('.info .subtitle').innerText = '---';
const subsection_alert = elContent.appendChild(document.createElement('div'));
@ -120,8 +110,8 @@ export class Claim extends HTMLElement {
}
const proof_link = subsection_links_text.appendChild(document.createElement('p'));
if (claim.matches[0].proof.uri) {
proof_link.innerHTML = `Proof link: <a href="${claim.matches[0].proof.uri}" aria-label="link to profile">${claim.matches[0].proof.uri}</a>`;
if (claim.matches[0].proof.request.uri) {
proof_link.innerHTML = `Proof link: <a href="${claim.matches[0].proof.request.uri}" aria-label="link to profile">${claim.matches[0].proof.request.uri}</a>`;
} else {
proof_link.innerHTML = `Proof link: not accessible from browser`;
}
@ -156,7 +146,7 @@ export class Claim extends HTMLElement {
const subsection_status_text = subsection_status.appendChild(document.createElement('div'));
const verification = subsection_status_text.appendChild(document.createElement('p'));
if (claim.status === 'verified') {
if (claim.status >= 200) {
verification.innerHTML = `Claim verification has completed.`;
subsection_status_icon.setAttribute('src', '/static/img/check-decagram.svg');
subsection_status_icon.setAttribute('alt', '');
@ -178,10 +168,10 @@ export class Claim extends HTMLElement {
const subsection_result_text = subsection_result.appendChild(document.createElement('div'));
const result = subsection_result_text.appendChild(document.createElement('p'));
result.innerHTML = `The claim <strong>${claim.verification.result ? 'HAS BEEN' : 'COULD NOT BE'}</strong> verified by the proof.`;
result.innerHTML = `The claim <strong>${claim.status >= 200 && claim.status < 300 ? 'HAS BEEN' : 'COULD NOT BE'}</strong> verified by the proof.`;
// Additional info
if (claim.verification.proof.viaProxy) {
if (claim.status === 201) {
elContent.appendChild(document.createElement('hr'));
const subsection_info = elContent.appendChild(document.createElement('div'));

View file

@ -46,7 +46,7 @@ export class Key extends HTMLElement {
const root = this;
const data = JSON.parse(value);
root.querySelector('.info .subtitle').innerText = data.key.fetchMethod;
root.querySelector('.info .subtitle').innerText = `${data.keyType} / ${data.fetch.method}`;
root.querySelector('.info .title').innerText = data.fingerprint;
const elContent = root.querySelector('.content');
@ -62,22 +62,24 @@ export class Key extends HTMLElement {
const subsection_links_text = subsection_links.appendChild(document.createElement('div'));
const profile_link = subsection_links_text.appendChild(document.createElement('p'));
profile_link.innerHTML = `Key link: <a class="u-key" rel="pgpkey" href="${data.key.uri}" aria-label="Link to cryptographic key">${data.key.uri}</a>`;
profile_link.innerHTML = `Key link: <a class="u-key" rel="pgpkey" href="${data.fetch.resolvedUrl}" aria-label="Link to cryptographic key">${data.fetch.resolvedUrl}</a>`;
elContent.appendChild(document.createElement('hr'));
// QR Code
const subsection_qr = elContent.appendChild(document.createElement('div'));
subsection_qr.setAttribute('class', 'subsection');
const subsection_qr_icon = subsection_qr.appendChild(document.createElement('img'));
subsection_qr_icon.setAttribute('src', '/static/img/qrcode.svg');
subsection_qr_icon.setAttribute('alt', '');
subsection_qr_icon.setAttribute('aria-hidden', 'true');
const subsection_qr_text = subsection_qr.appendChild(document.createElement('div'));
const button_fingerprintQR = subsection_qr_text.appendChild(document.createElement('button'));
button_fingerprintQR.innerText = `Show OpenPGP fingerprint QR`;
button_fingerprintQR.setAttribute('onClick', `window.showQR('${data.fingerprint}', 'fingerprint')`);
button_fingerprintQR.setAttribute('aria-label', `Show QR code for cryptographic fingerprint`);
if (data.keyType === 'openpgp') {
elContent.appendChild(document.createElement('hr'));
// QR Code
const subsection_qr = elContent.appendChild(document.createElement('div'));
subsection_qr.setAttribute('class', 'subsection');
const subsection_qr_icon = subsection_qr.appendChild(document.createElement('img'));
subsection_qr_icon.setAttribute('src', '/static/img/qrcode.svg');
subsection_qr_icon.setAttribute('alt', '');
subsection_qr_icon.setAttribute('aria-hidden', 'true');
const subsection_qr_text = subsection_qr.appendChild(document.createElement('div'));
const button_fingerprintQR = subsection_qr_text.appendChild(document.createElement('button'));
button_fingerprintQR.innerText = `Show OpenPGP fingerprint QR`;
button_fingerprintQR.setAttribute('onClick', `window.showQR('${data.fingerprint}', 'fingerprint')`);
button_fingerprintQR.setAttribute('aria-label', `Show QR code for cryptographic fingerprint`);
}
}
}

View file

@ -75,33 +75,22 @@ export async function generateProfileURL(data) {
// Fetch OpenPGP key based on information stored in window
export async function fetchProfileKey() {
if (window.kx.key.object && window.kx.key.object instanceof openpgp.PublicKey) {
if (window.kx.publicKey.key && window.kx.publicKey.key instanceof openpgp.PublicKey) {
return;
}
const rawKeyData = await fetch(window.kx.key.url)
let key, errorMsg
try {
key = (await openpgp.readKey({
binaryKey: new Uint8Array(await rawKeyData.clone().arrayBuffer())
armoredKey: window.kx.publicKey.encodedKey
}))
} catch(error) {
} catch (error) {
errorMsg = error.message
}
if (!key) {
try {
key = (await openpgp.readKey({
armoredKey: await rawKeyData.clone().text()
}))
} catch (error) {
errorMsg = error.message
}
}
if (key) {
window.kx.key.object = key
window.kx.publicKey.key = key
return
} else {
throw new Error(`Public key could not be fetched (${errorMsg})`)

View file

@ -1,20 +1,23 @@
extends templates/base.pug
mixin generateUser(user, isPrimary)
mixin generatePersona(persona, isPrimary)
h2
span.p-email #{user.userData.email}
if persona.email
span.p-email Identity claims (#{persona.email})
else
span.p-email Identity claims
if isPrimary
small.primary primary
if user.userData.comment
span.p-comment &#9432; #{user.userData.comment}
each claim in user.claims
if persona.description
span.p-comment &#9432; #{persona.description}
each claim in persona.claims
if claim.matches.length > 0
kx-claim.kx-item(data-claim=claim)
details(aria-label="Claim")
summary
.info
p.subtitle= claim.matches[0].serviceprovider.name
p.title= claim.matches[0].profile.display
p.subtitle= claim.display.serviceproviderName
p.title= claim.display.name
.icons
.verificationStatus(data-value='running')
.inProgress
@ -22,12 +25,12 @@ mixin generateUser(user, isPrimary)
.subsection
img(src='/static/img/link.png')
div
if (claim.matches[0].profile.uri)
if (claim.display.url)
p Profile link:
a(rel='me' href=claim.matches[0].profile.uri aria-label="Link to profile")= claim.matches[0].profile.uri
a(rel='me' href=claim.display.url aria-label="Link to profile")= claim.display.url
else
p Profile link: not accessible from browser
if (claim.matches[0].proof.uri)
if (claim.matches.length === 1 && claim.matches[0].proof.uri)
p Proof link:
a(href=claim.matches[0].proof.uri aria-label="Link to proof")= claim.matches[0].proof.uri
else
@ -36,10 +39,7 @@ mixin generateUser(user, isPrimary)
block content
script.
kx = {
key: {
url: "!{data && data.key && data.key.fetchURL ? data.key.fetchURL : null}",
object: null
}
publicKey: !{JSON.stringify(data.publicKey)}
}
if (data && 'errors' in data && data.errors.length > 0)
@ -80,12 +80,7 @@ block content
dialog#dialog--verifySignature
div
form(method='post')
label(for="sigVerInput") Signature
textarea#sigVerInput.input(name='signature')
input.no-margin(type='submit' name='submit' value='VERIFY SIGNATURE')
br
br
label(for="sigVerOutput") Verification result
label(for="sigVerInput") Signature name
textarea#sigVerOutput.output(name='message' placeholder='Waiting for input' readonly)
form(method="dialog")
input(type="submit" value="Close")
@ -108,9 +103,9 @@ block content
unless (isSignature && !signature)
#profileHeader.card.card--transparent.card--profileHeader
img#profileAvatar.u-logo(src=data.extra.avatarURL alt="avatar")
img#profileAvatar.u-logo(src=data.personas[data.primaryPersonaIndex].avatarUrl alt="avatar")
p#profileName.p-name= data.keyData.users[data.keyData.primaryUserIndex].userData.name
p#profileName.p-name= data.personas[data.primaryPersonaIndex].name
if (enable_message_encryption || enable_signature_verification)
.button-wrapper
@ -119,27 +114,28 @@ block content
if (enable_signature_verification)
button(onClick="document.querySelector('#dialog--verifySignature').showModal();") Verify signature
+generateUser(data.keyData.users[data.keyData.primaryUserIndex], true)
each user, index in data.keyData.users
unless index == data.keyData.primaryUserIndex
+generateUser(user, false)
+generatePersona(data.personas[data.primaryPersonaIndex], true && data.personas.length > 1)
each persona, index in data.personas
unless index == data.primaryPersonaIndex
+generatePersona(persona, false)
#profileProofs.card.card--transparent
h2 Key
kx-key.kx-item(data-keydata=data.keyData)
kx-key.kx-item(data-keydata=data.publicKey)
details(aria-label="Key")
summary
.info
p.subtitle= data.keyData.key.fetchMethod
p.title= data.keyData.fingerprint
p.subtitle= data.publicKey.fetch.method
p.title= data.identifier
.content
.subsection
img(src='/static/img/link.png')
div
p Key link:
a.u-key(href=data.keyData.key.uri rel="pgpkey" aria-label="Link to cryptographic key")= data.keyData.key.uri
a.u-key(href=data.publicKey.fetch.resolvedUrl rel="pgpkey" aria-label="Link to cryptographic key")= data.publicKey.fetch.resolvedUrl
hr
.subsection
img(src='/static/img/qrcode.png')
div
button(onClick=`showQR('${data.keyData.fingerprint}', 'fingerprint')` aria-label='Show QR code for cryptographic fingerprint') Show OpenPGP fingerprint QR
if (data.profileType === 'openpgp')
.subsection
img(src='/static/img/qrcode.png')
div
button(onClick=`showQR('${data.publicKey.fingerprint}', 'fingerprint')` aria-label='Show QR code for cryptographic fingerprint') Show OpenPGP fingerprint QR

View file

@ -18,4 +18,6 @@ html(lang='en')
link(rel='stylesheet' href='/static/main.css')
script(type='application/javascript' defer src='/static/openpgp.js' charset='utf-8')
script(type='application/javascript' defer src='/static/doipFetchers.js' charset='utf-8')
script(type='application/javascript' defer src='/static/doip.js' charset='utf-8')
script(type='application/javascript' defer src='/static/main.js' charset='utf-8')

View file

@ -13,10 +13,8 @@ export default (env) => {
mode: env.mode,
entry: {
main: {
import: './static-src/index.js',
dependOn: 'openpgp',
},
openpgp: './node_modules/openpgp/dist/openpgp.js',
import: './static-src/index.js'
}
},
output: {
filename: '[name].js',
@ -62,12 +60,19 @@ export default (env) => {
new CopyPlugin({
patterns: [
{ from: './static-src/files/', to: '../static/' },
{ from: './node_modules/openpgp/dist/openpgp.js', to: '../static/openpgp.js' },
{ from: './node_modules/doipjs/dist/doip.core.js', to: '../static/doip.js' },
{ from: './node_modules/doipjs/dist/doip.fetchers.minimal.js', to: '../static/doipFetchers.js' },
],
options: {
concurrency: 10,
},
}),
],
externals: {
doipjs: 'doip',
openpgp: 'openpgp'
}
}
} else {
return {}

1492
yarn.lock

File diff suppressed because it is too large Load diff